├── .cargo └── config ├── .clippy.toml ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml ├── renovate.json5 ├── settings.yml └── workflows │ ├── audit.yml │ ├── ci.yml │ ├── committed.yml │ ├── post-release.yml │ ├── pre-commit.yml │ ├── release-notes.py │ ├── rust-next.yml │ └── spelling.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── committed.toml ├── deny.toml ├── docs ├── comparison.md ├── design.md ├── reference.md └── screenshot.png ├── release.toml ├── src ├── any.rs ├── bin │ └── git-stack │ │ ├── alias.rs │ │ ├── amend.rs │ │ ├── args.rs │ │ ├── config.rs │ │ ├── logger.rs │ │ ├── main.rs │ │ ├── next.rs │ │ ├── ops.rs │ │ ├── prev.rs │ │ ├── reword.rs │ │ ├── run.rs │ │ ├── stack.rs │ │ └── sync.rs ├── config.rs ├── git │ ├── mod.rs │ ├── protect.rs │ └── repo.rs ├── graph │ ├── branch.rs │ ├── commit.rs │ ├── mod.rs │ └── ops.rs ├── legacy │ ├── git │ │ ├── branches.rs │ │ ├── commands.rs │ │ ├── mod.rs │ │ ├── protect.rs │ │ └── repo.rs │ ├── graph │ │ ├── actions.rs │ │ ├── mod.rs │ │ ├── node.rs │ │ └── ops.rs │ └── mod.rs ├── lib.rs └── rewrite │ └── mod.rs └── tests ├── fixtures ├── branches.yml ├── conflict.yml ├── fixup.yml ├── git_rebase_existing.yml ├── git_rebase_new.yml ├── pr-semi-linear-merge.yml └── pr-squash.yml ├── legacy ├── branches.rs ├── fixture.rs ├── graph.rs ├── main.rs └── repo.rs └── testsuite ├── alias.rs ├── amend.rs ├── branches.rs ├── fixture.rs ├── graph.rs ├── main.rs ├── ops.rs ├── repo.rs └── reword.rs /.cargo/config: -------------------------------------------------------------------------------- 1 | [target.x86_64-pc-windows-msvc] 2 | rustflags = ["-Ctarget-feature=+crt-static"] 3 | 4 | [target.i686-pc-windows-msvc] 5 | rustflags = ["-Ctarget-feature=+crt-static"] 6 | -------------------------------------------------------------------------------- /.clippy.toml: -------------------------------------------------------------------------------- 1 | msrv = "1.65.0" # MSRV 2 | warn-on-all-wildcard-imports = true 3 | allow-expect-in-tests = true 4 | allow-unwrap-in-tests = true 5 | allow-dbg-in-tests = true 6 | disallowed-methods = [ 7 | { path = "std::option::Option::map_or", reason = "prefer `map(..).unwrap_or(..)` for legibility" }, 8 | { path = "std::option::Option::map_or_else", reason = "prefer `map(..).unwrap_or_else(..)` for legibility" }, 9 | { path = "std::result::Result::map_or", reason = "prefer `map(..).unwrap_or(..)` for legibility" }, 10 | { path = "std::result::Result::map_or_else", reason = "prefer `map(..).unwrap_or_else(..)` for legibility" }, 11 | { path = "std::iter::Iterator::for_each", reason = "prefer `for` for side-effects" }, 12 | { path = "std::iter::Iterator::try_for_each", reason = "prefer `for` for side-effects" }, 13 | ] 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: "Bug report" 2 | description: "Things not quite working right" 3 | labels: ['bug'] 4 | body: 5 | - type: "checkboxes" 6 | attributes: 7 | label: "Please complete the following tasks" 8 | options: 9 | - label: "I have searched the [discussions](https://github.com/gitext-rs/git-stack/discussions)" 10 | - label: "I have searched the existing issues" 11 | - type: "textarea" 12 | attributes: 13 | label: "Description" 14 | validations: 15 | required: true 16 | - type: "input" 17 | attributes: 18 | label: "Version" 19 | description: "Output of `git stack -V`" 20 | - type: "textarea" 21 | attributes: 22 | label: "Steps to reproduce" 23 | description: "For describing git state, see [`git-fixture`](../blob/main/CONTRIBUTING.md)" 24 | - type: "textarea" 25 | attributes: 26 | label: "Actual Behaviour" 27 | description: "When I do like *this*, *that* is happening and I think it shouldn't." 28 | - type: "textarea" 29 | attributes: 30 | label: "Expected Behaviour" 31 | description: "I think *this* should happen instead." 32 | - type: "textarea" 33 | attributes: 34 | label: "Debug Output" 35 | description: "Run with `-vv`" 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: "Ask a question" 4 | about: "For support or brainstorming" 5 | url: "https://github.com/gitext-rs/git-stack/discussions/new" 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: "Feature request" 2 | description: "Suggest an idea for this project" 3 | labels: ['question'] 4 | body: 5 | - type: "checkboxes" 6 | attributes: 7 | label: "Please complete the following tasks" 8 | options: 9 | - label: "I have searched the [discussions](https://github.com/gitext-rs/git-stack/discussions)" 10 | - label: "I have searched the existing issues" 11 | - type: "input" 12 | attributes: 13 | label: "Version" 14 | description: "Output of `git stack -V`" 15 | - type: "textarea" 16 | attributes: 17 | label: "Use Case" 18 | description: "Describe the problem you're trying to solve." 19 | - type: "textarea" 20 | attributes: 21 | label: "Requirements" 22 | description: "Describe what is needed to satisfy your use case." 23 | - type: "textarea" 24 | attributes: 25 | label: "Possible Solutions" 26 | description: "A clear and concise description of any solutions or features you've managed to come up with." 27 | -------------------------------------------------------------------------------- /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | schedule: [ 3 | 'before 3am on the first day of the month', 4 | ], 5 | semanticCommits: 'enabled', 6 | configMigration: true, 7 | dependencyDashboard: true, 8 | regexManagers: [ 9 | { 10 | fileMatch: [ 11 | '^rust-toolchain\\.toml$', 12 | 'Cargo.toml$', 13 | 'clippy.toml$', 14 | '\\.clippy.toml$', 15 | '^\\.github/workflows/ci.yml$', 16 | '^\\.github/workflows/rust-next.yml$', 17 | ], 18 | matchStrings: [ 19 | 'MSRV.*?(?\\d+\\.\\d+(\\.\\d+)?)', 20 | '(?\\d+\\.\\d+(\\.\\d+)?).*?MSRV', 21 | ], 22 | depNameTemplate: 'rust', 23 | packageNameTemplate: 'rust-lang/rust', 24 | datasourceTemplate: 'github-releases', 25 | }, 26 | ], 27 | packageRules: [ 28 | { 29 | commitMessageTopic: 'MSRV', 30 | matchManagers: [ 31 | 'regex', 32 | ], 33 | matchPackageNames: [ 34 | 'rust', 35 | ], 36 | minimumReleaseAge: "126 days", // 3 releases * 6 weeks per release * 7 days per week 37 | internalChecksFilter: "strict", 38 | }, 39 | // Goals: 40 | // - Rollup safe upgrades to reduce CI runner load 41 | // - Have lockfile and manifest in-sync 42 | { 43 | matchManagers: [ 44 | 'cargo', 45 | ], 46 | matchCurrentVersion: '>=0.1.0', 47 | matchUpdateTypes: [ 48 | 'patch', 49 | ], 50 | automerge: true, 51 | groupName: 'compatible', 52 | }, 53 | { 54 | matchManagers: [ 55 | 'cargo', 56 | ], 57 | matchCurrentVersion: '>=1.0.0', 58 | matchUpdateTypes: [ 59 | 'minor', 60 | ], 61 | automerge: true, 62 | groupName: 'compatible', 63 | }, 64 | ], 65 | } 66 | -------------------------------------------------------------------------------- /.github/settings.yml: -------------------------------------------------------------------------------- 1 | # These settings are synced to GitHub by https://probot.github.io/apps/settings/ 2 | 3 | repository: 4 | description: "Stacked branch management for Git" 5 | topics: "git rust cli" 6 | has_issues: true 7 | has_projects: false 8 | has_wiki: false 9 | has_downloads: true 10 | default_branch: main 11 | 12 | # Preference: people do clean commits 13 | allow_merge_commit: true 14 | # Backup in case we need to clean up commits 15 | allow_squash_merge: true 16 | # Not really needed 17 | allow_rebase_merge: false 18 | 19 | allow_auto_merge: true 20 | delete_branch_on_merge: true 21 | 22 | squash_merge_commit_title: "PR_TITLE" 23 | squash_merge_commit_message: "PR_BODY" 24 | merge_commit_message: "PR_BODY" 25 | 26 | labels: 27 | # Type 28 | - name: bug 29 | color: '#b60205' 30 | description: "Not as expected" 31 | - name: enhancement 32 | color: '#1d76db' 33 | description: "Improve the expected" 34 | # Flavor 35 | - name: question 36 | color: "#cc317c" 37 | description: "Uncertainty is involved" 38 | - name: breaking-change 39 | color: "#e99695" 40 | - name: good first issue 41 | color: '#c2e0c6' 42 | description: "Help wanted!" 43 | 44 | branches: 45 | - name: main 46 | protection: 47 | required_pull_request_reviews: null 48 | required_conversation_resolution: true 49 | required_status_checks: 50 | # Required. Require branches to be up to date before merging. 51 | strict: false 52 | contexts: ["CI", "Lint Commits", "Spell Check with Typos"] 53 | enforce_admins: false 54 | restrictions: null 55 | -------------------------------------------------------------------------------- /.github/workflows/audit.yml: -------------------------------------------------------------------------------- 1 | name: Security audit 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | pull_request: 8 | paths: 9 | - '**/Cargo.toml' 10 | - '**/Cargo.lock' 11 | push: 12 | branches: 13 | - main 14 | 15 | env: 16 | RUST_BACKTRACE: 1 17 | CARGO_TERM_COLOR: always 18 | CLICOLOR: 1 19 | 20 | jobs: 21 | security_audit: 22 | permissions: 23 | issues: write # to create issues (actions-rs/audit-check) 24 | checks: write # to create check (actions-rs/audit-check) 25 | runs-on: ubuntu-latest 26 | # Prevent sudden announcement of a new advisory from failing ci: 27 | continue-on-error: true 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v3 31 | - uses: actions-rs/audit-check@v1 32 | with: 33 | token: ${{ secrets.GITHUB_TOKEN }} 34 | 35 | cargo_deny: 36 | permissions: 37 | issues: write # to create issues (actions-rs/audit-check) 38 | checks: write # to create check (actions-rs/audit-check) 39 | runs-on: ubuntu-latest 40 | strategy: 41 | matrix: 42 | checks: 43 | - bans licenses sources 44 | steps: 45 | - uses: actions/checkout@v3 46 | - uses: EmbarkStudios/cargo-deny-action@v1 47 | with: 48 | command: check ${{ matrix.checks }} 49 | rust-version: stable 50 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | paths: 5 | - '**' 6 | - '!/*.md' 7 | - '!/docs/**' 8 | - "!/LICENSE-*" 9 | push: 10 | branches: 11 | - main 12 | paths: 13 | - '**' 14 | - '!/*.md' 15 | - '!/docs/**' 16 | - "!/LICENSE-*" 17 | schedule: 18 | - cron: '7 7 7 * *' 19 | jobs: 20 | ci: 21 | name: CI 22 | needs: [test, msrv, docs, rustfmt, clippy] 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Done 26 | run: exit 0 27 | test: 28 | name: Test 29 | strategy: 30 | matrix: 31 | os: ["ubuntu-latest", "windows-latest", "macos-latest"] 32 | rust: ["stable"] 33 | continue-on-error: ${{ matrix.rust != 'stable' }} 34 | runs-on: ${{ matrix.os }} 35 | steps: 36 | - name: Checkout repository 37 | uses: actions/checkout@v3 38 | - name: Install Rust 39 | uses: actions-rs/toolchain@v1 40 | with: 41 | toolchain: ${{ matrix.rust }} 42 | profile: minimal 43 | override: true 44 | - uses: Swatinem/rust-cache@v2 45 | - name: Configure git 46 | run: | 47 | git config --global user.name "Test User" 48 | git config --global user.email "test_user@example.com" 49 | - name: Build 50 | run: cargo test --no-run --workspace --all-features 51 | - name: Default features 52 | run: cargo test --workspace 53 | - name: All features 54 | run: cargo test --workspace --all-features 55 | - name: No-default features 56 | run: cargo test --workspace --no-default-features 57 | msrv: 58 | name: "Check MSRV: 1.65.0" 59 | runs-on: ubuntu-latest 60 | steps: 61 | - name: Checkout repository 62 | uses: actions/checkout@v3 63 | - name: Install Rust 64 | uses: actions-rs/toolchain@v1 65 | with: 66 | toolchain: 1.65.0 # MSRV 67 | profile: minimal 68 | override: true 69 | - uses: Swatinem/rust-cache@v2 70 | - name: Default features 71 | run: cargo check --workspace --all-targets 72 | - name: All features 73 | run: cargo check --workspace --all-targets --all-features 74 | - name: No-default features 75 | run: cargo check --workspace --all-targets --no-default-features 76 | docs: 77 | name: Docs 78 | runs-on: ubuntu-latest 79 | steps: 80 | - name: Checkout repository 81 | uses: actions/checkout@v3 82 | - name: Install Rust 83 | uses: actions-rs/toolchain@v1 84 | with: 85 | toolchain: stable 86 | profile: minimal 87 | override: true 88 | - uses: Swatinem/rust-cache@v2 89 | - name: Check documentation 90 | env: 91 | RUSTDOCFLAGS: -D warnings 92 | run: cargo doc --workspace --all-features --no-deps --document-private-items 93 | rustfmt: 94 | name: rustfmt 95 | runs-on: ubuntu-latest 96 | steps: 97 | - name: Checkout repository 98 | uses: actions/checkout@v3 99 | - name: Install Rust 100 | uses: actions-rs/toolchain@v1 101 | with: 102 | # Not MSRV because its harder to jump between versions and people are 103 | # more likely to have stable 104 | toolchain: stable 105 | profile: minimal 106 | override: true 107 | components: rustfmt 108 | - uses: Swatinem/rust-cache@v2 109 | - name: Check formatting 110 | run: cargo fmt --all -- --check 111 | clippy: 112 | name: clippy 113 | runs-on: ubuntu-latest 114 | steps: 115 | - name: Checkout repository 116 | uses: actions/checkout@v3 117 | - name: Install Rust 118 | uses: actions-rs/toolchain@v1 119 | with: 120 | toolchain: 1.65.0 # MSRV 121 | profile: minimal 122 | override: true 123 | components: clippy 124 | - uses: Swatinem/rust-cache@v2 125 | - uses: actions-rs/clippy-check@v1 126 | with: 127 | token: ${{ secrets.GITHUB_TOKEN }} 128 | args: --workspace --all-features --all-targets -- -D warnings --allow deprecated 129 | -------------------------------------------------------------------------------- /.github/workflows/committed.yml: -------------------------------------------------------------------------------- 1 | # Not run as part of pre-commit checks because they don't handle sending the correct commit 2 | # range to `committed` 3 | name: Lint Commits 4 | on: [pull_request] 5 | 6 | permissions: 7 | contents: read 8 | 9 | env: 10 | RUST_BACKTRACE: 1 11 | CARGO_TERM_COLOR: always 12 | CLICOLOR: 1 13 | 14 | jobs: 15 | committed: 16 | name: Lint Commits 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout Actions Repository 20 | uses: actions/checkout@v3 21 | with: 22 | fetch-depth: 0 23 | - name: Lint Commits 24 | uses: crate-ci/committed@master 25 | -------------------------------------------------------------------------------- /.github/workflows/post-release.yml: -------------------------------------------------------------------------------- 1 | # The way this works is the following: 2 | # 3 | # The create-release job runs purely to initialize the GitHub release itself 4 | # and to output upload_url for the following job. 5 | # 6 | # The build-release job runs only once create-release is finished. It gets the 7 | # release upload URL from create-release job outputs, then builds the release 8 | # executables for each supported platform and attaches them as release assets 9 | # to the previously created release. 10 | # 11 | # The key here is that we create the release only once. 12 | # 13 | # Reference: 14 | # https://eugene-babichenko.github.io/blog/2020/05/09/github-actions-cross-platform-auto-releases/ 15 | 16 | name: post-release 17 | on: 18 | push: 19 | tags: 20 | - "v*" 21 | env: 22 | CRATE_NAME: git-stack 23 | jobs: 24 | create-release: 25 | name: create-release 26 | runs-on: ubuntu-latest 27 | outputs: 28 | upload_url: ${{ steps.release.outputs.upload_url }} 29 | release_version: ${{ env.RELEASE_VERSION }} 30 | steps: 31 | - name: Get the release version from the tag 32 | shell: bash 33 | if: env.RELEASE_VERSION == '' 34 | run: | 35 | # See: https://github.community/t5/GitHub-Actions/How-to-get-just-the-tag-name/m-p/32167/highlight/true#M1027 36 | echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV 37 | echo "version is: ${{ env.RELEASE_VERSION }}" 38 | - name: Checkout repository 39 | uses: actions/checkout@v3 40 | with: 41 | fetch-depth: 1 42 | - name: Generate Release Notes 43 | run: | 44 | ./.github/workflows/release-notes.py --tag ${{ env.RELEASE_VERSION }} --output notes-${{ env.RELEASE_VERSION }}.md 45 | cat notes-${{ env.RELEASE_VERSION }}.md 46 | - name: Create GitHub release 47 | id: release 48 | uses: actions/create-release@v1 49 | env: 50 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 51 | with: 52 | tag_name: ${{ env.RELEASE_VERSION }} 53 | release_name: ${{ env.RELEASE_VERSION }} 54 | body_path: notes-${{ env.RELEASE_VERSION }}.md 55 | build-release: 56 | name: build-release 57 | needs: create-release 58 | strategy: 59 | fail-fast: false 60 | matrix: 61 | build: [linux, macos, win-msvc] 62 | include: 63 | - build: linux 64 | os: ubuntu-20.04 65 | rust: stable 66 | target: x86_64-unknown-linux-musl 67 | - build: macos 68 | os: macos-latest 69 | rust: stable 70 | target: x86_64-apple-darwin 71 | - build: win-msvc 72 | os: windows-2019 73 | rust: stable 74 | target: x86_64-pc-windows-msvc 75 | runs-on: ${{ matrix.os }} 76 | steps: 77 | - name: Checkout repository 78 | uses: actions/checkout@v3 79 | with: 80 | fetch-depth: 1 81 | - name: Install packages (Ubuntu) 82 | if: matrix.os == 'ubuntu-20.04' 83 | run: | 84 | sudo apt-get update 85 | sudo apt-get install -y --no-install-recommends xz-utils liblz4-tool musl-tools 86 | - name: Install Rust 87 | uses: actions-rs/toolchain@v1 88 | with: 89 | toolchain: ${{ matrix.rust }} 90 | profile: minimal 91 | override: true 92 | target: ${{ matrix.target }} 93 | - name: Build release binary 94 | run: cargo build --target ${{ matrix.target }} --verbose --release 95 | - name: Build archive 96 | shell: bash 97 | run: | 98 | outdir="./target/${{ env.TARGET_DIR }}/release" 99 | staging="${{ env.CRATE_NAME }}-${{ needs.create-release.outputs.release_version }}-${{ matrix.target }}" 100 | mkdir -p "$staging"/{complete,doc} 101 | cp {README.md,LICENSE-*} "$staging/" 102 | cp {CHANGELOG.md,docs/*} "$staging/doc/" 103 | if [ "${{ matrix.os }}" = "windows-2019" ]; then 104 | cp "target/${{ matrix.target }}/release/git-stack.exe" "$staging/" 105 | cd "$staging" 106 | 7z a "../$staging.zip" . 107 | echo "ASSET=$staging.zip" >> $GITHUB_ENV 108 | else 109 | cp "target/${{ matrix.target }}/release/git-stack" "$staging/" 110 | tar czf "$staging.tar.gz" -C "$staging" . 111 | echo "ASSET=$staging.tar.gz" >> $GITHUB_ENV 112 | fi 113 | - name: Upload release archive 114 | uses: actions/upload-release-asset@v1.0.2 115 | env: 116 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 117 | with: 118 | upload_url: ${{ needs.create-release.outputs.upload_url }} 119 | asset_path: ${{ env.ASSET }} 120 | asset_name: ${{ env.ASSET }} 121 | asset_content_type: application/octet-stream 122 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yml: -------------------------------------------------------------------------------- 1 | name: pre-commit 2 | 3 | permissions: {} # none 4 | 5 | on: 6 | pull_request: 7 | push: 8 | branches: [main] 9 | 10 | env: 11 | RUST_BACKTRACE: 1 12 | CARGO_TERM_COLOR: always 13 | CLICOLOR: 1 14 | 15 | jobs: 16 | pre-commit: 17 | permissions: 18 | contents: read 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v3 22 | - uses: actions/setup-python@v4 23 | - uses: pre-commit/action@v3.0.0 24 | -------------------------------------------------------------------------------- /.github/workflows/release-notes.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import argparse 4 | import re 5 | import pathlib 6 | import sys 7 | 8 | 9 | _STDIO = pathlib.Path("-") 10 | 11 | 12 | def main(): 13 | parser = argparse.ArgumentParser() 14 | parser.add_argument("-i", "--input", type=pathlib.Path, default="CHANGELOG.md") 15 | parser.add_argument("--tag", required=True) 16 | parser.add_argument("-o", "--output", type=pathlib.Path, required=True) 17 | args = parser.parse_args() 18 | 19 | if args.input == _STDIO: 20 | lines = sys.stdin.readlines() 21 | else: 22 | with args.input.open() as fh: 23 | lines = fh.readlines() 24 | version = args.tag.lstrip("v") 25 | 26 | note_lines = [] 27 | for line in lines: 28 | if line.startswith("## ") and version in line: 29 | note_lines.append(line) 30 | elif note_lines and line.startswith("## "): 31 | break 32 | elif note_lines: 33 | note_lines.append(line) 34 | 35 | notes = "".join(note_lines).strip() 36 | if args.output == _STDIO: 37 | print(notes) 38 | else: 39 | args.output.write_text(notes) 40 | 41 | 42 | if __name__ == "__main__": 43 | main() 44 | -------------------------------------------------------------------------------- /.github/workflows/rust-next.yml: -------------------------------------------------------------------------------- 1 | name: rust-next 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | schedule: 8 | - cron: '7 7 7 * *' 9 | 10 | env: 11 | RUST_BACKTRACE: 1 12 | CARGO_TERM_COLOR: always 13 | CLICOLOR: 1 14 | 15 | jobs: 16 | test: 17 | name: Test 18 | strategy: 19 | matrix: 20 | os: ["ubuntu-latest", "windows-latest", "macos-latest"] 21 | rust: ["stable", "beta"] 22 | include: 23 | - os: ubuntu-latest 24 | rust: "nightly" 25 | continue-on-error: ${{ matrix.rust != 'stable' }} 26 | runs-on: ${{ matrix.os }} 27 | steps: 28 | - name: Checkout repository 29 | uses: actions/checkout@v3 30 | - name: Install Rust 31 | uses: dtolnay/rust-toolchain@stable 32 | with: 33 | toolchain: ${{ matrix.rust }} 34 | - uses: Swatinem/rust-cache@v2 35 | - name: Configure git 36 | run: | 37 | git config --global user.name "Test User" 38 | git config --global user.email "test_user@example.com" 39 | - name: Default features 40 | run: cargo test --workspace 41 | - name: All features 42 | run: cargo test --workspace --all-features 43 | - name: No-default features 44 | run: cargo test --workspace --no-default-features 45 | -------------------------------------------------------------------------------- /.github/workflows/spelling.yml: -------------------------------------------------------------------------------- 1 | name: Spelling 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: [pull_request] 7 | 8 | env: 9 | RUST_BACKTRACE: 1 10 | CARGO_TERM_COLOR: always 11 | CLICOLOR: 1 12 | 13 | jobs: 14 | spelling: 15 | name: Spell Check with Typos 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout Actions Repository 19 | uses: actions/checkout@v3 20 | - name: Spell Check Repo 21 | uses: crate-ci/typos@master 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.3.0 4 | hooks: 5 | - id: check-yaml 6 | stages: [commit] 7 | - id: check-json 8 | stages: [commit] 9 | - id: check-toml 10 | stages: [commit] 11 | - id: check-merge-conflict 12 | stages: [commit] 13 | - id: check-case-conflict 14 | stages: [commit] 15 | - id: detect-private-key 16 | stages: [commit] 17 | - repo: https://github.com/crate-ci/typos 18 | rev: v1.11.1 19 | hooks: 20 | - id: typos 21 | stages: [commit] 22 | - repo: https://github.com/crate-ci/committed 23 | rev: v1.0.4 24 | hooks: 25 | - id: committed 26 | stages: [commit-msg] 27 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to `typos` 2 | 3 | Thanks for wanting to contribute! There are many ways to contribute and we 4 | appreciate any level you're willing to do. 5 | 6 | ## Feature Requests 7 | 8 | Need some new functionality to help? You can let us know by opening an 9 | [issue][new issue]. It's helpful to look through [all issues][all issues] in 10 | case its already being talked about. 11 | 12 | ## Bug Reports 13 | 14 | Please let us know about what problems you run into, whether in behavior or 15 | ergonomics of API. You can do this by opening an [issue][new issue]. It's 16 | helpful to look through [all issues][all issues] in case its already being 17 | talked about. 18 | 19 | ### Reproducing Bugs 20 | 21 | To make reproduction easier, we've created a YAML format for describing git 22 | trees. You can verify your yaml file by the `git-fixture` command. 23 | 24 | - [Schema](crates/git-fixture/docs/schema.json) 25 | - [Examples](tests/fixtures/) 26 | 27 | ## Pull Requests 28 | 29 | Looking for an idea? Check our [issues][issues]. If it's look more open ended, 30 | it is probably best to post on the issue how you are thinking of resolving the 31 | issue so you can get feedback early in the process. We want you to be 32 | successful and it can be discouraging to find out a lot of re-work is needed. 33 | 34 | Already have an idea? It might be good to first [create an issue][new issue] 35 | to propose it so we can make sure we are aligned and lower the risk of having 36 | to re-work some of it and the discouragement that goes along with that. 37 | 38 | ### Process 39 | 40 | When you first post a PR, we request that the commit history get cleaned 41 | up. We recommend avoiding this during the PR to make it easier to review how 42 | feedback was handled. Once the commit is ready, we'll ask you to clean up the 43 | commit history. Once you let us know this is done, we can move forward with 44 | merging! If you are uncomfortable with these parts of git, let us know and we 45 | can help. 46 | 47 | We ask that all new files have the copyright header. Please update the 48 | copyright year for files you are modifying. 49 | 50 | As a heads up, we'll be running your PR through the following gauntlet: 51 | - warnings turned to compile errors 52 | - `cargo test` 53 | - `rustfmt` 54 | - `clippy` 55 | - `rustdoc` 56 | 57 | ## Releasing 58 | 59 | Pre-requisites 60 | - Running `cargo login` 61 | - A member of `ORG:Maintainers` 62 | - Push permission to the repo 63 | - [`cargo-release`](https://github.com/crate-ci/cargo-release/) 64 | 65 | When we're ready to release, a project owner should do the following 66 | 1. Update the changelog (see `cargo release changes` for ideas) 67 | 2. Determine what the next version is, according to semver 68 | 3. Run [`cargo release -x `](https://github.com/crate-ci/cargo-release) 69 | 70 | [issues]: https://github.com/gitext-rs/git-stack/issues 71 | [new issue]: https://github.com/gitext-rs/git-stack/issues/new 72 | [all issues]: https://github.com/gitext-rs/git-stack/issues?utf8=%E2%9C%93&q=is%3Aissue 73 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | 4 | [workspace.package] 5 | license = "MIT OR Apache-2.0" 6 | edition = "2021" 7 | rust-version = "1.65.0" # MSRV 8 | include = [ 9 | "build.rs", 10 | "src/**/*", 11 | "Cargo.toml", 12 | "Cargo.lock", 13 | "LICENSE*", 14 | "README.md", 15 | "benches/**/*", 16 | "examples/**/*" 17 | ] 18 | 19 | [package] 20 | name = "git-stack" 21 | description = "Stacked branch management for Git" 22 | version = "0.10.16" 23 | repository = "https://github.com/gitext-rs/git-stack.git" 24 | documentation = "https://github.com/gitext-rs/git-stack.git" 25 | readme = "README.md" 26 | categories = ["command-line-interface", "development-tools"] 27 | keywords = ["git", "cli"] 28 | license.workspace = true 29 | edition.workspace = true 30 | rust-version.workspace = true 31 | include.workspace = true 32 | 33 | [package.metadata.docs.rs] 34 | all-features = true 35 | rustdoc-args = ["--cfg", "docsrs"] 36 | 37 | [package.metadata.release] 38 | pre-release-replacements = [ 39 | {file="CHANGELOG.md", search="Unreleased", replace="{{version}}", min=1}, 40 | {file="CHANGELOG.md", search="\\.\\.\\.HEAD", replace="...{{tag_name}}", exactly=1}, 41 | {file="CHANGELOG.md", search="ReleaseDate", replace="{{date}}", min=1}, 42 | {file="CHANGELOG.md", search="", replace="\n## [Unreleased] - ReleaseDate\n", exactly=1}, 43 | {file="CHANGELOG.md", search="", replace="\n[Unreleased]: https://github.com/gitext-rs/git-stack/compare/{{tag_name}}...HEAD", exactly=1}, 44 | ] 45 | 46 | [dependencies] 47 | git2 = { version = "0.17", default-features = false, features = ["vendored-libgit2"] } 48 | git-config-env = "0.1" 49 | clap = { version = "4.3.0", features = ["derive"] } 50 | clap-verbosity-flag = "2.0.1" 51 | log = "0.4" 52 | env_logger = { version = "0.10", default-features = false, features = ["color"] } 53 | colorchoice-clap = "1.0.0" 54 | anstyle = "1.0.0" 55 | anstream = "0.3.2" 56 | proc-exit = "2" 57 | eyre = "0.6" 58 | human-panic = "1" 59 | termtree = "0.4" 60 | indexmap = "1" 61 | 62 | git2-ext = "0.6.0" 63 | git-branch-stash = "0.10.0" 64 | humantime = "2" 65 | itertools = "0.10" 66 | ignore = "0.4" 67 | bstr = "1.5.0" 68 | maplit = "1" 69 | petgraph = "0.6.3" 70 | downcast-rs = "1.2.0" 71 | names = { version = "0.14.0", default-features = false } 72 | elsa = "1.8.1" 73 | shlex = "1.1.0" 74 | 75 | [dev-dependencies] 76 | git-fixture = { version = "0.3", features = ["yaml"] } 77 | assert_fs = "1.0.13" 78 | snapbox = { version = "0.4.11", features = ["cmd", "path"] } 79 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) Individual contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # git-stack 2 | 3 | > **Stacked branch management for Git** 4 | 5 | ![Screenshot](./docs/screenshot.png) 6 | 7 | [![codecov](https://codecov.io/gh/gitext-rs/git-stack/branch/master/graph/badge.svg)](https://codecov.io/gh/gitext-rs/git-stack) 8 | [![Documentation](https://img.shields.io/badge/docs-master-blue.svg)][Documentation] 9 | ![License](https://img.shields.io/crates/l/git-stack.svg) 10 | [![Crates Status](https://img.shields.io/crates/v/git-stack.svg)](https://crates.io/crates/git-stack) 11 | 12 | Dual-licensed under [MIT](LICENSE-MIT) or [Apache 2.0](LICENSE-APACHE) 13 | 14 | ## Documentation 15 | 16 | - [About](#about) 17 | - [Install](#install) 18 | - [Getting Started](#getting-started) 19 | - [Reference](docs/reference.md) 20 | - [FAQ](#faq) 21 | - [Comparison](docs/comparison.md) 22 | - [Design](docs/design.md) 23 | - [Contribute](CONTRIBUTING.md) 24 | - [CHANGELOG](CHANGELOG.md) 25 | 26 | ## About 27 | 28 | Like Stacked-Diffs? `git-stack` is [another approach](docs/comparison.md) to bringing the 29 | [Stacked Diff workflow](https://jg.gg/2018/09/29/stacked-diffs-versus-pull-requests/) 30 | to PRs/branches that aims to be unintrusive to a project's workflow. Branches are the unit 31 | of work and review in `git-stack`. As you create branches on top of each 32 | other (i.e. "stacked" branches), `git-stack` will takes care of all of the 33 | micromanagement for you. 34 | 35 | Unfamiliar with Stacked-Diffs? `git-stack` helps automate a lot of common 36 | workflows when dealing with PRs, especially when you start to create PRs on top 37 | of PRs. 38 | 39 | Features: 40 | - Upstream parent branch auto-detection 41 | - Maintain branches relative to each other through rebase 42 | - Defers all permanent changes until the end (e.g. HEAD, re-targeting 43 | branches), always leaving you in a good state 44 | (similar to [`git revise`](https://github.com/mystor/git-revise/)) 45 | - Separates out pull/push remotes for working from a fork 46 | - On `--push`, detects which branches are "ready" (e.g. root of stack, no WIP) 47 | - Undo support: backs up branch state prior to rewriting history 48 | 49 | Non-features 50 | - Conflict resolution: `git-stack` will give up and you'll have to use 51 | `git rebase` yourself to resolve the conflict. 52 | 53 | To see how `git-stack` compares to other stacked git tools, see the [Comparison](docs/comparison.md). 54 | 55 | ## Example 56 | 57 | From your feature branch, run: 58 | ```console 59 | jira-3423423 $ git stack --pull 60 | ``` 61 | 62 | `git stack --pull`: 63 | 1. Auto-detects your parent remote branch (e.g. `main`). 64 | 2. Performs a `git pull --rebase ` 65 | 3. Rebases `jira-3423423` (and any dev branches on the stack) onto `` 66 | 4. Shows the stacked branches 67 | 68 | See [Getting Start](#using) for a complete workflow example. 69 | 70 | The closest equivalent is: 71 | ```bash 72 | jira-3423 $ git checkout main 73 | main $ git pull --rebase upstream main 74 | main $ git checkout jira-3154 75 | jira-3154 $ git rebase HEAD~~ --onto main 76 | jira-3154 $ git checkout jira-3259 77 | jira-3259 $ git rebase HEAD~ --onto jira-3154 78 | jira-3259 $ git checkout jira-3423 79 | jira-3423 $ git rebase HEAD~ --onto jurao-3259 80 | jira-3423 $ git log --graph --all --oneline --decorate main..HEAD 81 | ``` 82 | *For more, see [Command Reference](docs/reference.md#commands)* 83 | 84 | *Parent branch auto-detection works by separating the concept of 85 | upstream-controlled branches (called "protected branches") and your development 86 | branches.* 87 | 88 | ## Install 89 | 90 | [Download](https://github.com/gitext-rs/git-stack/releases) a pre-built binary 91 | (installable via [gh-install](https://github.com/crate-ci/gh-install)). 92 | 93 | Or use rust to install: 94 | ```console 95 | $ cargo install git-stack 96 | ``` 97 | 98 | We also recommend installing 99 | [`git-branch-stash`](https://github.com/gitext-rs/git-branch-stash) for easily 100 | undoing `git stack` operations: 101 | ```console 102 | $ cargo install git-branch-stash-cli 103 | ``` 104 | 105 | ### Configuring `git-stack` 106 | 107 | **Aliases:** To avoid name collisions while keeping things brief, `git-stack` 108 | ships as one binary but can help configure aliases by running `git 109 | stack alias --register`. You can then modify the aliases if you want to make 110 | some flags the default. 111 | 112 | **Protected branches:** These are branches that `git-stack` should not modify. 113 | `git-stack` will also rebase local protected branches against 114 | their remote counter parts. Usually you mark shared or long-lived branches as 115 | protected, like `main`, `v3`. 116 | 117 | Run `git-stack --protected -v` to test your config 118 | - To locally protect additional branches, run `git-stack --protect `. 119 | - When adopting `git-stack` as a team, you can move the protected branches from 120 | `$REPO/.git/config` to `$REPO/.gitconfig` and commit it. 121 | 122 | **Pull remote** when working from a fork, where upstream is a different remote than 123 | `origin`, run `git config --add stack.pull-remote ` to set your remote in `$REPO/.git/config`. 124 | 125 | To see the config, run `git-stack --dump-config -`. 126 | 127 | For more, see [Configuration Reference](docs/reference.md#configuration). 128 | 129 | ### Uninstall 130 | 131 | If you registered aliases, you'll want to run `git stack alias --unregister` to remove them. 132 | 133 | See the uninstall method for your installer. 134 | 135 | Once removed, `git-stack` leaves behind: 136 | - `.git/branch-stash` 137 | 138 | Removing this is safe and will have no effect. 139 | 140 | ## Getting Started 141 | 142 | ### Using 143 | 144 | ```console 145 | $ # Update branches against upstream 146 | $ git sync 147 | 148 | $ # Start a new branch / PR 149 | $ git switch -c feature1 150 | $ git add -A; git commit -m "Work" 151 | $ git add -A; git commit -m "More Work" 152 | $ git run cargo check 153 | $ git prev 154 | $ git add -A; git amend # Fix problems in "Work" commit 155 | $ git run cargo check 156 | $ git next 157 | 158 | $ # See what this looks like 159 | $ git stack 160 | 161 | $ # Clean up in preparation for a push 162 | $ git sync 163 | 164 | $ # Push whats ready 165 | $ git stack --push 166 | ``` 167 | 168 | For more, see [Command Reference](docs/reference.md#commands). 169 | 170 | ## FAQ 171 | 172 | ### When should my branches be stacked? 173 | 174 | This is up to you. Some might prefer to have linear development (single branch) and just manipulate ordering within that. 175 | 176 | For me, I prefer to stack branches of related work or when there is a 177 | dependency between them, like a feature being stacked on top of a refactor to 178 | enable that feature 179 | - Only deal with conflicts when I have to (one gets merged we're rebasing on top of it) 180 | - Stacking of PRs, especially of unrelated work, doesn't work too well in Github 181 | 182 | ### How do I stack another branch on top of an existing one? 183 | 184 | - New branch: `git switch feature1 && git switch -c feature2` and start adding commits 185 | - Moving existing: `git stack --rebase --base feature1 --onto main` moves `feature2` to `main`, from off of `feature1` 186 | - Without `git stack`: `git rebase feature1 --onto main` 187 | 188 | ### How do I start a new feature? 189 | 190 | This works like normal, just checkout the branch you want to base the feature on and start adding commits. 191 | 192 | For example: 193 | ```console 194 | $ git switch feature1 195 | $ git switch -c feature2 196 | ``` 197 | 198 | ### How do I add a commit to a parent branch in a stack? 199 | 200 | - If this is for fixing a problem in a previous commit, 201 | [`git commit --fixup `](https://git-scm.com/docs/git-commit#Documentation/git-commit.txt---fixupamendrewordltcommitgt) 202 | and then `git-stack --rebase` will move it to where it needs to be. 203 | - If this is to append to the parent branch, for now you'll have to use `git rebase -i` 204 | 205 | ### How do I stack my PRs in Github? 206 | 207 | Currently, Github is limited to showing all commits for a branch, even if some 208 | of those commits are "owned" by another PR. We recommend only posting one PR 209 | at a time within a stack. If you really need to, you can direct your reviewers 210 | to the commits within each PR to look at. However, you will see the CI run 211 | status of top commit for each PR dependency. 212 | 213 | ### When is a commit considered WIP? 214 | 215 | If a commit summary is only `WIP` or is prefixed by: 216 | - `WIP:` 217 | - `draft:` 218 | - `Draft:` 219 | - `wip ` 220 | - `WIP ` 221 | 222 | *This includes the prefixes used by [Gitlab](https://docs.gitlab.com/ee/user/project/merge_requests/drafts.html)* 223 | 224 | ### What is `git branch-stash` 225 | 226 | [`git-branch-stash`](https://github.com/gitext-rs/git-branch-stash) is a 227 | separate utility that is like `git stash` for instead of your working tree, it 228 | stashes what commit each of your branches points to. `git stack` backs up 229 | using `git branch-stash`s file format to lower the risk of trying things out 230 | with `git stack`. 231 | 232 | ### Why don't you just ...? 233 | 234 | Have an idea, we'd love to [hear it](https://github.com/gitext-rs/git-stack/discussions)! 235 | There are probably `git` operations or workflows we haven't heard of and would 236 | welcome the opportunity to learn more. 237 | 238 | [Crates.io]: https://crates.io/crates/git-stack 239 | [Documentation]: https://docs.rs/git-stack 240 | -------------------------------------------------------------------------------- /committed.toml: -------------------------------------------------------------------------------- 1 | style="conventional" 2 | ignore_author_re="(dependabot|renovate)" 3 | merge_commit = false 4 | -------------------------------------------------------------------------------- /deny.toml: -------------------------------------------------------------------------------- 1 | # Note that all fields that take a lint level have these possible values: 2 | # * deny - An error will be produced and the check will fail 3 | # * warn - A warning will be produced, but the check will not fail 4 | # * allow - No warning or error will be produced, though in some cases a note 5 | # will be 6 | 7 | # This section is considered when running `cargo deny check advisories` 8 | # More documentation for the advisories section can be found here: 9 | # https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html 10 | [advisories] 11 | # The lint level for security vulnerabilities 12 | vulnerability = "deny" 13 | # The lint level for unmaintained crates 14 | unmaintained = "warn" 15 | # The lint level for crates that have been yanked from their source registry 16 | yanked = "warn" 17 | # The lint level for crates with security notices. Note that as of 18 | # 2019-12-17 there are no security notice advisories in 19 | # https://github.com/rustsec/advisory-db 20 | notice = "warn" 21 | # A list of advisory IDs to ignore. Note that ignored advisories will still 22 | # output a note when they are encountered. 23 | # 24 | # e.g. "RUSTSEC-0000-0000", 25 | ignore = [ 26 | ] 27 | 28 | # This section is considered when running `cargo deny check licenses` 29 | # More documentation for the licenses section can be found here: 30 | # https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html 31 | [licenses] 32 | unlicensed = "deny" 33 | # List of explicitly allowed licenses 34 | # See https://spdx.org/licenses/ for list of possible licenses 35 | # [possible values: any SPDX 3.11 short identifier (+ optional exception)]. 36 | allow = [ 37 | "MIT", 38 | "MIT-0", 39 | "Apache-2.0", 40 | "BSD-3-Clause", 41 | "MPL-2.0", 42 | "Unicode-DFS-2016", 43 | "CC0-1.0", 44 | ] 45 | # List of explicitly disallowed licenses 46 | # See https://spdx.org/licenses/ for list of possible licenses 47 | # [possible values: any SPDX 3.11 short identifier (+ optional exception)]. 48 | deny = [ 49 | ] 50 | # Lint level for licenses considered copyleft 51 | copyleft = "deny" 52 | # Blanket approval or denial for OSI-approved or FSF Free/Libre licenses 53 | # * both - The license will be approved if it is both OSI-approved *AND* FSF 54 | # * either - The license will be approved if it is either OSI-approved *OR* FSF 55 | # * osi-only - The license will be approved if is OSI-approved *AND NOT* FSF 56 | # * fsf-only - The license will be approved if is FSF *AND NOT* OSI-approved 57 | # * neither - This predicate is ignored and the default lint level is used 58 | allow-osi-fsf-free = "neither" 59 | # Lint level used when no other predicates are matched 60 | # 1. License isn't in the allow or deny lists 61 | # 2. License isn't copyleft 62 | # 3. License isn't OSI/FSF, or allow-osi-fsf-free = "neither" 63 | default = "deny" 64 | # The confidence threshold for detecting a license from license text. 65 | # The higher the value, the more closely the license text must be to the 66 | # canonical license text of a valid SPDX license file. 67 | # [possible values: any between 0.0 and 1.0]. 68 | confidence-threshold = 0.8 69 | # Allow 1 or more licenses on a per-crate basis, so that particular licenses 70 | # aren't accepted for every possible crate as with the normal allow list 71 | exceptions = [ 72 | # Each entry is the crate and version constraint, and its specific allow 73 | # list 74 | #{ allow = ["Zlib"], name = "adler32", version = "*" }, 75 | ] 76 | 77 | [licenses.private] 78 | # If true, ignores workspace crates that aren't published, or are only 79 | # published to private registries. 80 | # To see how to mark a crate as unpublished (to the official registry), 81 | # visit https://doc.rust-lang.org/cargo/reference/manifest.html#the-publish-field. 82 | ignore = true 83 | 84 | # This section is considered when running `cargo deny check bans`. 85 | # More documentation about the 'bans' section can be found here: 86 | # https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html 87 | [bans] 88 | # Lint level for when multiple versions of the same crate are detected 89 | multiple-versions = "warn" 90 | # Lint level for when a crate version requirement is `*` 91 | wildcards = "deny" 92 | # The graph highlighting used when creating dotgraphs for crates 93 | # with multiple versions 94 | # * lowest-version - The path to the lowest versioned duplicate is highlighted 95 | # * simplest-path - The path to the version with the fewest edges is highlighted 96 | # * all - Both lowest-version and simplest-path are used 97 | highlight = "all" 98 | # The default lint level for `default` features for crates that are members of 99 | # the workspace that is being checked. This can be overridden by allowing/denying 100 | # `default` on a crate-by-crate basis if desired. 101 | workspace-default-features = "allow" 102 | # The default lint level for `default` features for external crates that are not 103 | # members of the workspace. This can be overridden by allowing/denying `default` 104 | # on a crate-by-crate basis if desired. 105 | external-default-features = "allow" 106 | # List of crates that are allowed. Use with care! 107 | allow = [ 108 | #{ name = "ansi_term", version = "=0.11.0" }, 109 | ] 110 | # List of crates to deny 111 | deny = [ 112 | # Each entry the name of a crate and a version range. If version is 113 | # not specified, all versions will be matched. 114 | #{ name = "ansi_term", version = "=0.11.0" }, 115 | # 116 | # Wrapper crates can optionally be specified to allow the crate when it 117 | # is a direct dependency of the otherwise banned crate 118 | #{ name = "ansi_term", version = "=0.11.0", wrappers = [] }, 119 | ] 120 | 121 | # This section is considered when running `cargo deny check sources`. 122 | # More documentation about the 'sources' section can be found here: 123 | # https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html 124 | [sources] 125 | # Lint level for what to happen when a crate from a crate registry that is not 126 | # in the allow list is encountered 127 | unknown-registry = "deny" 128 | # Lint level for what to happen when a crate from a git repository that is not 129 | # in the allow list is encountered 130 | unknown-git = "deny" 131 | # List of URLs for allowed crate registries. Defaults to the crates.io index 132 | # if not specified. If it is specified but empty, no registries are allowed. 133 | allow-registry = ["https://github.com/rust-lang/crates.io-index"] 134 | # List of URLs for allowed Git repositories 135 | allow-git = [] 136 | 137 | [sources.allow-org] 138 | # 1 or more github.com organizations to allow git sources for 139 | github = [] 140 | -------------------------------------------------------------------------------- /docs/comparison.md: -------------------------------------------------------------------------------- 1 | # Related Stacking Tools 2 | 3 | ## `arcanist` (`arc`) 4 | 5 | [Website](https://secure.phabricator.com/book/phabricator/article/arcanist/) 6 | 7 | Pros: 8 | - Rebases each branch when merging 9 | - Show review status of each Diff (Phab's equivalent of PR) 10 | - Nicer status view than `git log` 11 | 12 | Cons: 13 | - Coupled to Phabricator which is EOL 14 | - Auto-rebasing doesn't preserve branch relationships (stacks) 15 | - No auto-rebase outside of "landing" a Diff (merging a PR) 16 | 17 | ## depo-tools 18 | 19 | [Website](https://commondatastorage.googleapis.com/chrome-infra-docs/flat/depot_tools/docs/html/depot_tools_tutorial.html) 20 | 21 | - `git rebase-update` to pull, rebase, and cleanup merged changes 22 | - `git map` and `git map-branches` for showing branch and commit relationships 23 | - `git reparent-branch` to rebase a tree of branches onto another branch 24 | - `git nav-downstream` / `git nav-upstream` to move between parent / child branches in a stack 25 | - `git nav-downstream` prompts on ambiguity 26 | 27 | Cons: 28 | - Relies on a branch's upstream being set to the parent branch, rather than the remote used for PRs 29 | 30 | ## `git-branchless` 31 | 32 | [Website](https://github.com/arxanas/git-branchless) 33 | 34 | Pros: 35 | - `git undo` seems to provide a nice experience! 36 | - `git smartlog` 37 | - Identifies orphaned commits 38 | - Nice use of glyphs in visualization 39 | - `git restack` 40 | - Fixes when a commit is rewritten but dependents weren't updated 41 | 42 | Cons: 43 | - Only as reliable as information it can gather through hooks (incompatible with `git-revise` and others) 44 | - Assumes hook installs will append to existing hooks 45 | 46 | ## Graphite 47 | 48 | [Website](https://github.com/screenplaydev/graphite-cli) 49 | 50 | Uses refs to track what branches make up a stack 51 | 52 | Supports creating PRs for multiple branches in a stack but they don't describe how they do this 53 | 54 | Pros: 55 | - Has web dashboard 56 | - Interactive branch checkout 57 | - Direct support for Github PRs 58 | - Has "max days behind trunk" 59 | - Can run a command on each branch in a stack 60 | 61 | Cons: 62 | - Has you replace `git` with `gt` with a slightly different interface 63 | - Requires giving access to a third party 64 | - Only supports Github 65 | - Sounds like they require user-prefixes for branches 66 | 67 | ## `git-machete` 68 | 69 | [Website](https://github.com/VirtusLab/git-machete) 70 | 71 | Pros: 72 | - Supports going up and down stacks (`go up`, `go down`, `go next`, `go prev`, `go root`) 73 | - Quick way to diff a branch on a stack 74 | 75 | Cons: 76 | - Manually managed branch relationships 77 | - `discover` to get started 78 | - `add` to edit the file from the command-line 79 | 80 | ## `git spr` 81 | 82 | [Website](https://github.com/ejoffe/spr) 83 | 84 | Cons: 85 | - Blackbox: no explanation for how the PRs are stacked or if any relationship data is shown to the user 86 | 87 | ## `ghstack` 88 | 89 | [Website](https://github.com/ezyang/ghstack) 90 | 91 | Pros: 92 | - Authors can upload multiple PRs at once with each PR showing only the commits relevant for it. 93 | 94 | Cons: 95 | - Not integrated into `git` workflow (e.g. custom config file, rather than `.gitconfig`) 96 | - Incompatible with fork workflow / requires upstream access 97 | - It manage custom branches 98 | - You must merge from `ghstack` 99 | - Incompatible with host-side merge tools (auto-merge, merge queues, etc) and branch-protections 100 | - Leaves behind stale branches in upstream, requiring custom cleanup 101 | - Requires Python runtime / virtualenv 102 | 103 | ## `gh-stack` 104 | 105 | [Website](https://github.com/timothyandrew/gh-stack) 106 | 107 | Pros: 108 | - Updates PR summary with other PRs in the stack 109 | 110 | Cons: 111 | - Requires each commit start with an identifier, grouping by identifier into a PR 112 | - In contrast, `git-stack` relies on branches (multi-commit PRs) and 113 | ["fixup" commits (auto-squashing)](https://thoughtbot.com/blog/autosquashing-git-commits) 114 | 115 | ## `git-ps` 116 | 117 | [Website](https://github.com/uptech/git-ps) 118 | - [Introduction](https://upte.ch/blog/how-we-should-be-using-git/) 119 | - [Guide](https://github.com/uptech/git-ps/wiki/Guide) 120 | 121 | Cons: 122 | - Blackbox: no explanation for how they manage the patch/PR relationship 123 | - Dependent on Swift support for your platform 124 | 125 | ## Jujutsu 126 | 127 | [Website](https://github.com/martinvonz/jj) 128 | 129 | Pros: 130 | - When a commit is rewritten, descendants are automatically rebased 131 | - Supports undo, including undo of a past operation 132 | - Simpler CLI than `git` (e.g. no "index") 133 | - Powerful history-editing features, such as for splitting and squashing 134 | commits, for moving parts of a commit to or from its parent, and for 135 | editing the contents or commit message of any commit 136 | - First-class conflicts means that conflicts won't prevent rebase, and 137 | existing conflicts can be rebased or rolled back 138 | - Merge commits are correctly rebased, edited, split, etc. 139 | 140 | Cons: 141 | - The working copy cannot be used with `git`, you have to use `jj` instead 142 | - Missing functionality such as `git blame`, `git log `, `git apply` 143 | - Can work around it by running the `git` commands on the underlying Git 144 | repository 145 | - Working with multiple remotes requires many manual steps to manage 146 | branches 147 | 148 | ## Stacked Git 149 | 150 | [Website](https://stacked-git.github.io/) 151 | 152 | Cons: 153 | - I've looked over the docs multiple times and haven't quite "gotten it" for 154 | how to use this in a PR workflow. 155 | 156 | ## `git-branchstack` 157 | 158 | [Website](https://git.sr.ht/~krobelus/git-branchstack) 159 | 160 | Cons: 161 | - Requires each commit start with an identifier, grouping by identifier into a PR 162 | - In contrast, `git-stack` relies on branches (multi-commit PRs) and 163 | ["fixup" commits (auto-squashing)](https://thoughtbot.com/blog/autosquashing-git-commits) 164 | 165 | ## `git-series` 166 | 167 | [Website](https://github.com/git-series/git-series) 168 | 169 | ## `git-chain` 170 | 171 | [Website](https://github.com/Shopify/git-chain) 172 | - [Rewrite](https://github.com/dashed/git-chain) 173 | 174 | Cons: 175 | - Requires manually defining a chain 176 | -------------------------------------------------------------------------------- /docs/design.md: -------------------------------------------------------------------------------- 1 | # Design 2 | 3 | The goal of `git-stack` is to streamline the PR workflow. 4 | 5 | Requirements: 6 | - Prioritize the PR workflow 7 | - Interoperate with non-`git-stack` PR workflows 8 | - Allow gradual adoption 9 | - Allow dropping down to more familiar, widely documented commands (i.e. I can apply answers from stack overflow) 10 | - Do not interfere with other tools 11 | 12 | Example: When pushing a branch and creating a PR, people general mark the 13 | remote branch as the upstream for their branch, allowing them to do a simple 14 | `git push` in the future. We need to set this for the user and can't use it like 15 | [depot-tools](https://commondatastorage.googleapis.com/chrome-infra-docs/flat/depot_tools/docs/html/depot_tools_tutorial.html) 16 | which simplifies some of `git-stack`s work by having the parent branch be the 17 | upstream. 18 | 19 | ## Defining stacks 20 | 21 | A stack is a series of commits with branches on some of the commits that ends 22 | on a commit in a protected branch. We assume the closest protected branch to 23 | that protected commit is the base. 24 | 25 | [Other tools](./comparison.md) rely on external information for defining and 26 | maintaining stacks, including: 27 | - git hooks 28 | - A branch's "upstream" 29 | - A data file 30 | - Identifiers in commits 31 | 32 | To meet `git-stack`s goals, we cannot rely on any of these. `git-stack` 33 | provides some operations, like `--rebase` and `--fixup`, to modify the tree of stacks 34 | without losing relationships. For when the stack gets messed up outside of 35 | `--rebase` and `--fixup`, a `--repair` will be provided that assumes that 36 | `HEAD` is the core of the stack and fixes what it can (see 37 | [Issue #6](https://github.com/gitext-rs/git-stack/issues/6)). Outside of that, it 38 | is left to the user to fix the stacks. 39 | -------------------------------------------------------------------------------- /docs/reference.md: -------------------------------------------------------------------------------- 1 | # `git-stack` Reference 2 | 3 | ## Concepts 4 | 5 | ### Protected Branch 6 | 7 | These are branches like `main` or `v3` that `git-stack` must not modify. If 8 | there is a matching branch in the `stack.push-remote`, we assume that is the 9 | canonical version of the branch (the one being modified) and we will track the 10 | local branch to that. 11 | 12 | `git-stack` finds the best-match protected base branch for each development branch: 13 | - `--pull` will only pull protected bases 14 | - `--rebase` will move development development branches to the latest commit of this protected base 15 | 16 | ### pull-remote 17 | 18 | The remote that contains shared branches you are developing against. Because 19 | these are shared branches, we do not want to modify their history locally. 20 | 21 | ### push-remote 22 | 23 | The remote that contains your personal branches in preparation for being merged 24 | into a shared branch in the pull-remote. `git-stack` assumes the local version 25 | is canonical (that no edits are happening in the remote) and that `git-stack` 26 | is free to modify and force-push to this remote. 27 | 28 | This may be the same as the `pull-remote` when working directly in the upstream org, rather than on a fork. 29 | 30 | ## Commands 31 | 32 | ### `git stack alias` 33 | 34 | View, register, and unregister `git stack` specific aliases. 35 | 36 | Use case: keep commands short while avoiding name conflicts with existing aliases or other installed commands. 37 | 38 | ### `git stack` 39 | 40 | Visualizes the branch stacks on top of their protected bases. 41 | 42 | Why not `git log --graph --all --oneline --decorate main..HEAD`? 43 | - Doesn't show status as you progress through review 44 | - Fairly verbose 45 | - Have to manually select your base to limit to relevant commits 46 | - Slower because it loads the entire commit graph into memory to sort it 47 | 48 | ### `git sync` 49 | *i.e. `git stack sync`* 50 | 51 | Pulls your protected branches from the `stack.pull-remote` and then rebases 52 | your development branches on top of their relevant protected branches. 53 | 54 | Unlike `--rebase`, this does not perform any "auto" operations. 55 | 56 | Note: 57 | - This also performs a fetch of your `stack.push-remote` to prune any removed remotes 58 | 59 | Use case: detect merge and semantic conflicts early 60 | 61 | Why not `git pull --rebase upstream main`? 62 | - Have to manually select your remote/branch 63 | - Only updates current branch 64 | - Even looping over all branches, the relationship between branches gets 65 | lost, requiring rebasing branches back on top of each other, making sure 66 | you do it in a way to avoid conflicts. 67 | - Have to manually delete merged branches 68 | - Only fetches from `upstream`, leaving your deleted `origin` branches lingering locally 69 | 70 | ### `git next` 71 | *i.e. `git stack next`* 72 | 73 | Switch to a child commit. 74 | 75 | Use case: easily navigate to edit commits with commands like `git amend`. 76 | 77 | Why not `git stack && git checkout `? 78 | - Saves you from having to type or copy/paste `` 79 | 80 | ### `git prev` 81 | *i.e. `git stack prev`* 82 | 83 | Switch to a parent commit. 84 | 85 | Use case: easily navigate to edit commits with commands like `git amend`. 86 | 87 | Why not `git stack && git checkout `? 88 | - Saves you from having to type or copy/paste `` 89 | 90 | ### `git reword` 91 | *i.e. `git stack reword`* 92 | 93 | Edit the current commit's message. 94 | 95 | Use case: easily edit parent commits. 96 | 97 | Why not `git commit --amend`? 98 | - Automatically rebases all children commits / branches 99 | - Avoid accidentally editing a protected commit or a commit with fixups referencing it 100 | 101 | Why not `git rebase -i ` and setting it the action to `r`? 102 | - Fewer steps (no need to choose ref, go to correct line and edit it to then edit the message) 103 | - Automatically rebases all children commits / branches 104 | 105 | ### `git amend` 106 | *i.e. `git stack amend`* 107 | 108 | Squash staged changes into the current commit. 109 | 110 | Use case: easily edit parent commits. 111 | 112 | Why not `git commit --amend --no-edit`? 113 | - Automatically rebases all children commits / branches 114 | - Avoid accidentally editing a protected commit or a commit with fixups referencing it 115 | 116 | ### `git run` 117 | *i.e. `git stack run`* 118 | 119 | Run a command across the current stack of commits. 120 | 121 | Use case: verify your commits still build after editing history. 122 | 123 | ### `git stack --rebase` 124 | 125 | Rebase development branches on their relevant protected branches. 126 | 127 | This performs "auto" operations, like 128 | - `stack.auto-fixup`: see `--fixup` 129 | 130 | Why not `git rebase -i --autosquash master`? 131 | - Have to manually select the base 132 | - By default, it will squash the `fixup!` commits. If this isn't what you 133 | want, you are likely to defer this until you are ready to squash and you 134 | won't know of any merge-conflicts that arise from moving the `fixup!` commits. 135 | 136 | ### `git stack --fixup ` 137 | 138 | Process [fixup!](https://git-scm.com/docs/git-commit#Documentation/git-commit.txt---fixupamendrewordltcommitgt) commits according to the specified action. 139 | 140 | Note: 141 | - This can be used to override `stack.auto-fixup` during a `--rebase`. 142 | 143 | ### `git stack --repair` 144 | 145 | This attempts to clean up stacks 146 | - If you commit directly on a parent stack, this will update the dependent stacks to be on top of that new commit 147 | - If you used `git rebase`, then the stack will be split in two. This will merge them. 148 | 149 | ### `git stack --push` 150 | 151 | Push all "ready" development branches to your `stack.push-remote`. 152 | 153 | A branch is ready if 154 | - It is not stacked on top of any other development branches (see ["How do I stack my PRs in Github"](../README.md#how-do-i-stack-my-prs-in-github)) 155 | - It has no [WIP commits](../README.md#when-is-a-commit-considered-wip) 156 | 157 | We consider branches with 158 | [`fixup!` commits](https://git-scm.com/docs/git-commit#Documentation/git-commit.txt---fixupamendrewordltcommitgt) 159 | to be ready in case you are wanting reviewers to see some intermediate states. 160 | You can use a tool like [committed](https://github.com/crate-ci/committed) to 161 | prevent these from being merged. 162 | 163 | Why not `git push --set-upstream --force-with-lease origin `? 164 | - A bit verbose to do this right 165 | - Might forget to clean up your branch (e.g. WIP, fixup) 166 | 167 | ### `git branch-stash` 168 | 169 | While `git stash` backs up and restores your working tree, 170 | [`git branch-stash`](https://github.com/gitext-rs/git-branch-stash) backs up 171 | and restores the state of all of your branches. 172 | 173 | `git-stack` implicitly does a `git branch-stash` whenever modifying the tree. 174 | 175 | Why not `git reflog` and manually restoring the branches? 176 | - A lot of manual work to find the correct commit SHAs and adjust the branches to point to them 177 | 178 | ## Configuration 179 | 180 | ### Sources 181 | 182 | Configuration is read from the following (in precedence order): 183 | - [`git -c`](https://git-scm.com/docs/git#Documentation/git.txt--cltnamegtltvaluegt) 184 | - [`GIT_CONFIG`](https://git-scm.com/docs/git-config#Documentation/git-config.txt-GITCONFIGCOUNT) 185 | - `$REPO/.git/config` 186 | - `$REPO/.gitconfig` 187 | - [Other `.gitconfig`](https://git-scm.com/docs/git-config#FILES) 188 | 189 | ### Config Fields 190 | 191 | | Field | Argument | Format | Description | 192 | |------------------------|----------|----------------------------|-------------| 193 | | stack.protected-branch | \- | multivar of globs | Branch names that match these globs (`.gitignore` syntax) are considered protected branches | 194 | | stack.protect-commit-count | \- | integer | Protect commits that are on a branch with `count`+ commits | 195 | | stack.protect-commit-age | \- | time delta (e.g. 10days) | Protect commits that older than the specified time | 196 | | stack.auto-base-commit-count | \- | integer | Split off branches that are more than `count` commits away from the implied base | 197 | | stack.stack | --stack | "current", "dependents", "descendants", "all" | Which development branch-stacks to operate on | 198 | | stack.push-remote | \- | string | Development remote for pushing local branches | 199 | | stack.pull-remote | \- | string | Upstream remote for pulling protected branches | 200 | | stack.show-format | --format | "silent", "branches", "branch-commits", "commits", "debug" | How to show the stacked diffs at the end | 201 | | stack.show-stacked | \- | bool | Show branches as stacked on top of each other, where possible | 202 | | stack.auto-fixup | --fixup | "ignore", "move", "squash" | Default fixup operation with `--rebase` | 203 | | stack.auto-repair | \- | bool | Perform branch repair with `--rebase` | 204 | | stack.gpgSign | \- | bool | Sign commits, falling back to `commit.gpgSign` | 205 | -------------------------------------------------------------------------------- /docs/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epage/git-stack/4dac5bc9090f5aa34a48dab57ef4f675a1f3e50a/docs/screenshot.png -------------------------------------------------------------------------------- /release.toml: -------------------------------------------------------------------------------- 1 | allow-branch = ["main"] 2 | -------------------------------------------------------------------------------- /src/any.rs: -------------------------------------------------------------------------------- 1 | /// Mark a type as a [`Resource`] 2 | pub trait ResourceTag: std::fmt::Debug + downcast_rs::DowncastSync + 'static {} 3 | 4 | pub trait Resource: ResourceTag { 5 | fn clone_resource(&self) -> Box; 6 | } 7 | 8 | impl Resource for T { 9 | fn clone_resource(&self) -> Box { 10 | Box::new(self.clone()) 11 | } 12 | } 13 | 14 | downcast_rs::impl_downcast!(sync Resource); 15 | 16 | impl Clone for Box { 17 | fn clone(&self) -> Self { 18 | self.clone_resource() 19 | } 20 | } 21 | 22 | #[derive(Clone)] 23 | #[repr(transparent)] 24 | pub struct BoxedResource(Box); 25 | 26 | impl BoxedResource { 27 | pub(crate) fn as_ref(&self) -> &R { 28 | self.0.as_any().downcast_ref::().unwrap() 29 | } 30 | 31 | pub(crate) fn as_mut(&mut self) -> &mut R { 32 | self.0.as_any_mut().downcast_mut::().unwrap() 33 | } 34 | } 35 | 36 | impl std::fmt::Debug for BoxedResource { 37 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { 38 | self.0.fmt(f) 39 | } 40 | } 41 | 42 | #[derive(Clone)] 43 | pub struct BoxedEntry { 44 | pub(crate) id: AnyId, 45 | pub(crate) value: BoxedResource, 46 | } 47 | 48 | impl BoxedEntry { 49 | pub(crate) fn new(r: impl Resource) -> Self { 50 | let id = AnyId::from(&r); 51 | let value = BoxedResource(Box::new(r)); 52 | BoxedEntry { id, value } 53 | } 54 | } 55 | 56 | impl From for BoxedEntry { 57 | fn from(inner: R) -> Self { 58 | BoxedEntry::new(inner) 59 | } 60 | } 61 | 62 | #[derive(Copy, Clone)] 63 | pub struct AnyId { 64 | type_id: std::any::TypeId, 65 | #[cfg(debug_assertions)] 66 | type_name: &'static str, 67 | } 68 | 69 | impl AnyId { 70 | pub fn of() -> Self { 71 | Self { 72 | type_id: std::any::TypeId::of::(), 73 | #[cfg(debug_assertions)] 74 | type_name: std::any::type_name::(), 75 | } 76 | } 77 | } 78 | 79 | impl PartialEq for AnyId { 80 | fn eq(&self, other: &Self) -> bool { 81 | self.type_id == other.type_id 82 | } 83 | } 84 | 85 | impl Eq for AnyId {} 86 | 87 | impl PartialOrd for AnyId { 88 | fn partial_cmp(&self, other: &Self) -> Option { 89 | self.type_id.partial_cmp(&other.type_id) 90 | } 91 | } 92 | 93 | impl Ord for AnyId { 94 | fn cmp(&self, other: &Self) -> std::cmp::Ordering { 95 | self.type_id.cmp(&other.type_id) 96 | } 97 | } 98 | 99 | impl std::hash::Hash for AnyId { 100 | fn hash(&self, state: &mut H) { 101 | self.type_id.hash(state); 102 | } 103 | } 104 | 105 | impl std::fmt::Debug for AnyId { 106 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { 107 | #[cfg(not(debug_assertions))] 108 | { 109 | self.type_id.fmt(f) 110 | } 111 | #[cfg(debug_assertions)] 112 | { 113 | f.debug_tuple(self.type_name).field(&self.type_id).finish() 114 | } 115 | } 116 | } 117 | 118 | impl<'a, A: ?Sized + 'static> From<&'a A> for AnyId { 119 | fn from(_: &'a A) -> Self { 120 | Self::of::() 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/bin/git-stack/alias.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | 3 | use proc_exit::prelude::*; 4 | 5 | #[derive(clap::Args)] 6 | pub struct AliasArgs { 7 | #[arg(long)] 8 | register: bool, 9 | 10 | #[arg(long)] 11 | unregister: bool, 12 | } 13 | 14 | impl AliasArgs { 15 | pub fn exec(&self) -> proc_exit::ExitResult { 16 | if self.register { 17 | register()?; 18 | } else if self.unregister { 19 | unregister()?; 20 | } else { 21 | status()?; 22 | } 23 | 24 | Ok(()) 25 | } 26 | } 27 | 28 | fn register() -> proc_exit::ExitResult { 29 | let config = if let Ok(config) = open_repo_config() { 30 | config 31 | } else { 32 | git2::Config::open_default().with_code(proc_exit::Code::FAILURE)? 33 | } 34 | .snapshot() 35 | .with_code(proc_exit::Code::FAILURE)?; 36 | 37 | let mut user_config = git2::Config::open_default() 38 | .with_code(proc_exit::Code::FAILURE)? 39 | .open_global() 40 | .with_code(proc_exit::Code::FAILURE)?; 41 | 42 | let stderr_palette = crate::ops::Palette::colored(); 43 | let mut stderr = anstream::stderr().lock(); 44 | 45 | let mut success = true; 46 | for alias in ALIASES { 47 | let key = format!("alias.{}", alias.alias); 48 | match config.get_string(&key) { 49 | Ok(value) => { 50 | if value == alias.action { 51 | log::debug!("{}=\"{}\" is registered", alias.alias, value); 52 | } else if value.starts_with(alias.action_base) { 53 | log::debug!( 54 | "{}=\"{}\" is registered but diverged from \"{}\"", 55 | alias.alias, 56 | value, 57 | alias.action 58 | ); 59 | } else { 60 | let _ = writeln!( 61 | stderr, 62 | "{}: {}=\"{}\" is registered, not overwriting with \"{}\"", 63 | stderr_palette.error("error"), 64 | alias.alias, 65 | value, 66 | alias.action_base 67 | ); 68 | success = false; 69 | } 70 | } 71 | Err(_) => { 72 | let _ = writeln!( 73 | stderr, 74 | "{}: {}=\"{}\"", 75 | stderr_palette.good("Registering"), 76 | alias.alias, 77 | alias.action 78 | ); 79 | user_config 80 | .set_str(&key, alias.action) 81 | .with_code(proc_exit::Code::FAILURE)?; 82 | } 83 | } 84 | } 85 | 86 | if success { 87 | Ok(()) 88 | } else { 89 | Err(proc_exit::Code::FAILURE.as_exit()) 90 | } 91 | } 92 | 93 | fn unregister() -> proc_exit::ExitResult { 94 | let config = if let Ok(config) = open_repo_config() { 95 | config 96 | } else { 97 | git2::Config::open_default().with_code(proc_exit::Code::FAILURE)? 98 | } 99 | .snapshot() 100 | .with_code(proc_exit::Code::FAILURE)?; 101 | 102 | let mut user_config = git2::Config::open_default() 103 | .with_code(proc_exit::Code::FAILURE)? 104 | .open_global() 105 | .with_code(proc_exit::Code::FAILURE)?; 106 | 107 | let stderr_palette = crate::ops::Palette::colored(); 108 | let mut stderr = anstream::stderr().lock(); 109 | 110 | let mut entries = config 111 | .entries(Some("alias.*")) 112 | .with_code(proc_exit::Code::FAILURE)?; 113 | while let Some(entry) = entries.next() { 114 | let entry = entry.with_code(proc_exit::Code::FAILURE)?; 115 | let Some(key) = entry.name() else {continue}; 116 | let name = key.split_once('.').map(|n| n.1).unwrap_or(key); 117 | let Some(value) = entry.value() else {continue}; 118 | 119 | let mut unregister = false; 120 | if let Some(alias) = ALIASES.iter().find(|a| a.alias == name) { 121 | if value == alias.action { 122 | unregister = true; 123 | } else if value.starts_with(alias.action_base) { 124 | unregister = true; 125 | } 126 | } else if let Some(_alias) = ALIASES.iter().find(|a| value.starts_with(a.action_base)) { 127 | unregister = true; 128 | } 129 | 130 | if unregister { 131 | let _ = writeln!( 132 | stderr, 133 | "{}: {}=\"{}\"", 134 | stderr_palette.good("Unregistering"), 135 | name, 136 | value 137 | ); 138 | user_config 139 | .remove(key) 140 | .with_code(proc_exit::Code::FAILURE)?; 141 | } 142 | } 143 | 144 | Ok(()) 145 | } 146 | 147 | fn status() -> proc_exit::ExitResult { 148 | let config = if let Ok(config) = open_repo_config() { 149 | config 150 | } else { 151 | git2::Config::open_default().with_code(proc_exit::sysexits::USAGE_ERR)? 152 | }; 153 | 154 | let stdout_palette = crate::ops::Palette::colored(); 155 | let stderr_palette = crate::ops::Palette::colored(); 156 | let mut stdout = anstream::stdout().lock(); 157 | let mut stderr = anstream::stderr().lock(); 158 | let _ = writeln!(stdout, "[alias]"); 159 | 160 | let mut registered = false; 161 | let mut covered = std::collections::HashSet::new(); 162 | let mut entries = config 163 | .entries(Some("alias.*")) 164 | .with_code(proc_exit::Code::FAILURE)?; 165 | while let Some(entry) = entries.next() { 166 | let entry = entry.with_code(proc_exit::Code::FAILURE)?; 167 | let Some(name) = entry.name() else {continue}; 168 | let name = name.split_once('.').map(|n| n.1).unwrap_or(name); 169 | let Some(value) = entry.value() else {continue}; 170 | 171 | if let Some(alias) = ALIASES.iter().find(|a| a.alias == name) { 172 | if value == alias.action { 173 | let _ = writeln!( 174 | stdout, 175 | "{}{}", 176 | stdout_palette.good(format_args!(" {name} = {value}")), 177 | stdout_palette.hint(" # registered") 178 | ); 179 | registered = true; 180 | } else if value.starts_with(alias.action_base) { 181 | let _ = writeln!( 182 | stdout, 183 | "{}{}", 184 | stdout_palette.warn(format_args!(" {name} = {value}")), 185 | stdout_palette.hint(format_args!(" # diverged from \"{}\"", alias.action)) 186 | ); 187 | registered = true; 188 | } else { 189 | let _ = writeln!( 190 | stdout, 191 | "{}{}", 192 | stdout_palette.error(format_args!(" {name} = {value}")), 193 | stdout_palette.hint(format_args!(" # instead of `{}`", alias.action)) 194 | ); 195 | } 196 | covered.insert(name.to_owned()); 197 | } else if let Some(_alias) = ALIASES.iter().find(|a| value.starts_with(a.action_base)) { 198 | let _ = writeln!(stdout, " {name} = {value}"); 199 | registered = true; 200 | } 201 | } 202 | 203 | let mut unregistered = false; 204 | for alias in ALIASES { 205 | if covered.contains(alias.alias) { 206 | continue; 207 | } 208 | let _ = writeln!( 209 | stdout, 210 | "{}{}", 211 | stdout_palette.error(format_args!("# {} = {}", alias.alias, alias.action)), 212 | stdout_palette.hint(" # unregistered") 213 | ); 214 | unregistered = true; 215 | } 216 | 217 | if registered { 218 | let _ = writeln!( 219 | stderr, 220 | "{}: To unregister, pass {}", 221 | stderr_palette.info("note"), 222 | stderr_palette.error("`--unregister`") 223 | ); 224 | } 225 | if unregistered { 226 | let _ = writeln!( 227 | stderr, 228 | "{}: To register, pass {}", 229 | stderr_palette.info("note"), 230 | stderr_palette.good("`--register`") 231 | ); 232 | } 233 | 234 | Ok(()) 235 | } 236 | 237 | pub struct Alias { 238 | pub alias: &'static str, 239 | pub action: &'static str, 240 | pub action_base: &'static str, 241 | } 242 | 243 | const ALIASES: &[Alias] = &[ 244 | crate::next::NextArgs::alias(), 245 | crate::prev::PrevArgs::alias(), 246 | crate::reword::RewordArgs::alias(), 247 | crate::amend::AmendArgs::alias(), 248 | crate::sync::SyncArgs::alias(), 249 | crate::run::RunArgs::alias(), 250 | ]; 251 | 252 | fn open_repo_config() -> Result { 253 | let cwd = std::env::current_dir()?; 254 | let repo = git2::Repository::discover(cwd)?; 255 | let config = repo.config()?; 256 | Ok(config) 257 | } 258 | -------------------------------------------------------------------------------- /src/bin/git-stack/args.rs: -------------------------------------------------------------------------------- 1 | #[derive(clap::Parser)] 2 | #[command(about, author, version)] 3 | #[command(group = clap::ArgGroup::new("mode").multiple(false))] 4 | #[command(args_conflicts_with_subcommands = true)] 5 | pub struct Args { 6 | /// Rebase the selected stacks 7 | #[arg(short, long, group = "mode")] 8 | pub rebase: bool, 9 | 10 | /// Pull the parent branch and rebase onto it. 11 | #[arg(long)] 12 | pub pull: bool, 13 | 14 | /// Push all ready branches 15 | #[arg(long)] 16 | pub push: bool, 17 | 18 | /// Which branch stacks to include 19 | #[arg(short, long, value_enum)] 20 | pub stack: Option, 21 | 22 | /// Branch to evaluate from (default: most-recent protected branch) 23 | #[arg(long)] 24 | pub base: Option, 25 | 26 | /// Branch to rebase onto (default: base) 27 | #[arg(long)] 28 | pub onto: Option, 29 | 30 | /// Action to perform with fixup-commits 31 | #[arg(long, value_enum)] 32 | pub fixup: Option, 33 | 34 | /// Repair diverging branches. 35 | #[arg(long, overrides_with("no_repair"))] 36 | repair: bool, 37 | #[arg(long, overrides_with("repair"), hide = true)] 38 | no_repair: bool, 39 | 40 | #[arg(short = 'n', long)] 41 | pub dry_run: bool, 42 | 43 | #[arg(long, value_enum)] 44 | pub format: Option, 45 | 46 | #[arg(long, value_enum)] 47 | pub show_commits: Option, 48 | 49 | /// See what branches are protected 50 | #[arg(long, group = "mode")] 51 | pub protected: bool, 52 | 53 | /// Append a protected branch to the repository's config (gitignore syntax) 54 | #[arg(long, group = "mode")] 55 | pub protect: Option, 56 | 57 | /// Run as if git was started in `PATH` instead of the current working directory. 58 | /// 59 | /// When multiple -C options are given, each subsequent 60 | /// non-absolute -C is interpreted relative to the preceding -C . If is present but empty, e.g. -C "", then the 61 | /// current working directory is left unchanged. 62 | /// 63 | /// This option affects options that expect path name like --git-dir and --work-tree in that their interpretations of the path names 64 | /// would be made relative to the working directory caused by the -C option. For example the following invocations are equivalent: 65 | /// 66 | /// git --git-dir=a.git --work-tree=b -C c status 67 | /// git --git-dir=c/a.git --work-tree=c/b status 68 | #[arg(short = 'C', hide = true, value_name = "PATH")] 69 | pub current_dir: Option>, 70 | 71 | /// Write the current configuration to file with `-` for stdout 72 | #[arg(long, group = "mode")] 73 | pub dump_config: Option, 74 | 75 | #[command(flatten)] 76 | pub(crate) color: colorchoice_clap::Color, 77 | 78 | #[command(flatten)] 79 | pub verbose: clap_verbosity_flag::Verbosity, 80 | 81 | #[command(subcommand)] 82 | command: Option, 83 | } 84 | 85 | #[derive(clap::Subcommand)] 86 | pub enum Command { 87 | #[command(alias = "prev")] 88 | Previous(crate::prev::PrevArgs), 89 | Next(crate::next::NextArgs), 90 | Reword(crate::reword::RewordArgs), 91 | Amend(crate::amend::AmendArgs), 92 | Sync(crate::sync::SyncArgs), 93 | Run(crate::run::RunArgs), 94 | Alias(crate::alias::AliasArgs), 95 | } 96 | 97 | impl Args { 98 | pub fn exec(&self) -> proc_exit::ExitResult { 99 | match &self.command { 100 | Some(Command::Previous(c)) => c.exec(), 101 | Some(Command::Next(c)) => c.exec(), 102 | Some(Command::Reword(c)) => c.exec(), 103 | Some(Command::Amend(c)) => c.exec(), 104 | Some(Command::Sync(c)) => c.exec(), 105 | Some(Command::Run(c)) => c.exec(), 106 | Some(Command::Alias(c)) => c.exec(), 107 | None => { 108 | if let Some(output_path) = self.dump_config.as_deref() { 109 | crate::config::dump_config(self, output_path) 110 | } else if let Some(ignore) = self.protect.as_deref() { 111 | crate::config::protect(self, ignore) 112 | } else if self.protected { 113 | crate::config::protected(self) 114 | } else { 115 | crate::stack::stack(self) 116 | } 117 | } 118 | } 119 | } 120 | 121 | pub fn to_config(&self) -> git_stack::config::RepoConfig { 122 | git_stack::config::RepoConfig { 123 | editor: None, 124 | protected_branches: None, 125 | protect_commit_count: None, 126 | protect_commit_age: None, 127 | auto_base_commit_count: None, 128 | stack: self.stack, 129 | push_remote: None, 130 | pull_remote: None, 131 | show_format: self.format, 132 | show_commits: self.show_commits, 133 | show_stacked: None, 134 | auto_fixup: None, 135 | auto_repair: None, 136 | 137 | capacity: None, 138 | } 139 | } 140 | 141 | pub fn repair(&self) -> Option { 142 | resolve_bool_arg(self.repair, self.no_repair) 143 | } 144 | } 145 | 146 | fn resolve_bool_arg(yes: bool, no: bool) -> Option { 147 | match (yes, no) { 148 | (true, false) => Some(true), 149 | (false, true) => Some(false), 150 | (false, false) => None, 151 | (_, _) => unreachable!("clap should make this impossible"), 152 | } 153 | } 154 | 155 | #[cfg(test)] 156 | mod test { 157 | use super::*; 158 | 159 | #[test] 160 | fn verify_app() { 161 | use clap::CommandFactory; 162 | Args::command().debug_assert() 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/bin/git-stack/config.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | 3 | use proc_exit::prelude::*; 4 | 5 | pub fn dump_config( 6 | args: &crate::args::Args, 7 | output_path: &std::path::Path, 8 | ) -> proc_exit::ExitResult { 9 | log::trace!("Initializing"); 10 | let cwd = std::env::current_dir().with_code(proc_exit::sysexits::USAGE_ERR)?; 11 | let repo = git2::Repository::discover(cwd).with_code(proc_exit::sysexits::USAGE_ERR)?; 12 | 13 | let repo_config = git_stack::config::RepoConfig::from_all(&repo) 14 | .with_code(proc_exit::sysexits::CONFIG_ERR)? 15 | .update(args.to_config()); 16 | 17 | let output = repo_config.to_string(); 18 | 19 | if output_path == std::path::Path::new("-") { 20 | anstream::stdout() 21 | .write_all(output.as_bytes()) 22 | .to_sysexits()?; 23 | } else { 24 | std::fs::write(output_path, &output).to_sysexits()?; 25 | } 26 | 27 | Ok(()) 28 | } 29 | 30 | pub fn protect(args: &crate::args::Args, ignore: &str) -> proc_exit::ExitResult { 31 | log::trace!("Initializing"); 32 | let cwd = std::env::current_dir().with_code(proc_exit::sysexits::USAGE_ERR)?; 33 | let repo = git2::Repository::discover(cwd).with_code(proc_exit::sysexits::USAGE_ERR)?; 34 | 35 | let mut repo_config = git_stack::config::RepoConfig::from_repo(&repo) 36 | .with_code(proc_exit::sysexits::CONFIG_ERR)? 37 | .update(args.to_config()); 38 | repo_config 39 | .protected_branches 40 | .get_or_insert_with(Vec::new) 41 | .push(ignore.to_owned()); 42 | 43 | repo_config 44 | .write_repo(&repo) 45 | .with_code(proc_exit::Code::FAILURE)?; 46 | 47 | Ok(()) 48 | } 49 | 50 | pub fn protected(args: &crate::args::Args) -> proc_exit::ExitResult { 51 | log::trace!("Initializing"); 52 | let cwd = std::env::current_dir().with_code(proc_exit::sysexits::USAGE_ERR)?; 53 | let repo = git2::Repository::discover(cwd).with_code(proc_exit::sysexits::USAGE_ERR)?; 54 | 55 | let repo_config = git_stack::config::RepoConfig::from_all(&repo) 56 | .with_code(proc_exit::sysexits::CONFIG_ERR)? 57 | .update(args.to_config()); 58 | let protected = git_stack::legacy::git::ProtectedBranches::new( 59 | repo_config.protected_branches().iter().map(|s| s.as_str()), 60 | ) 61 | .with_code(proc_exit::sysexits::CONFIG_ERR)?; 62 | 63 | let repo = git_stack::legacy::git::GitRepo::new(repo); 64 | let mut branches = git_stack::legacy::git::Branches::new([]); 65 | let mut protected_branches = git_stack::legacy::git::Branches::new([]); 66 | for branch in repo.local_branches() { 67 | if protected.is_protected(&branch.name) { 68 | log::trace!("Branch {} is protected", branch); 69 | protected_branches.insert(branch.clone()); 70 | if let Some(remote) = repo.find_remote_branch(repo.pull_remote(), &branch.name) { 71 | protected_branches.insert(remote.clone()); 72 | branches.insert(remote); 73 | } 74 | } 75 | branches.insert(branch); 76 | } 77 | 78 | for (branch_id, branches) in branches.iter() { 79 | if protected_branches.contains_oid(branch_id) { 80 | for branch in branches { 81 | writeln!(anstream::stdout(), "{branch}").to_sysexits()?; 82 | } 83 | } else { 84 | for branch in branches { 85 | log::debug!("Unprotected: {}", branch); 86 | } 87 | } 88 | } 89 | 90 | Ok(()) 91 | } 92 | -------------------------------------------------------------------------------- /src/bin/git-stack/logger.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | 3 | pub fn init_logging( 4 | level: clap_verbosity_flag::Verbosity, 5 | colored: bool, 6 | ) { 7 | if let Some(level) = level.log_level() { 8 | let palette = if colored { 9 | Palette::colored() 10 | } else { 11 | Palette::plain() 12 | }; 13 | 14 | let mut builder = env_logger::Builder::new(); 15 | builder.write_style(if colored { 16 | env_logger::WriteStyle::Always 17 | } else { 18 | env_logger::WriteStyle::Never 19 | }); 20 | 21 | builder.filter(None, level.to_level_filter()); 22 | 23 | if level == log::LevelFilter::Trace || level == log::LevelFilter::Debug { 24 | builder.format_timestamp_secs(); 25 | } else { 26 | builder.format(move |f, record| match record.level() { 27 | log::Level::Error => { 28 | writeln!(f, "{}: {}", palette.error(record.level()), record.args()) 29 | } 30 | log::Level::Warn => { 31 | writeln!(f, "{}: {}", palette.warn(record.level()), record.args()) 32 | } 33 | log::Level::Info => writeln!(f, "{}", record.args()), 34 | log::Level::Debug => { 35 | writeln!(f, "{}: {}", palette.debug(record.level()), record.args()) 36 | } 37 | log::Level::Trace => { 38 | writeln!(f, "{}: {}", palette.trace(record.level()), record.args()) 39 | } 40 | }); 41 | } 42 | 43 | builder.init(); 44 | } 45 | } 46 | 47 | #[derive(Copy, Clone, Default, Debug)] 48 | struct Palette { 49 | error: anstyle::Style, 50 | warn: anstyle::Style, 51 | debug: anstyle::Style, 52 | trace: anstyle::Style, 53 | } 54 | 55 | impl Palette { 56 | pub fn colored() -> Self { 57 | Self { 58 | error: anstyle::AnsiColor::Red.on_default() | anstyle::Effects::BOLD, 59 | warn: anstyle::AnsiColor::Yellow.on_default(), 60 | debug: anstyle::AnsiColor::Blue.on_default(), 61 | trace: anstyle::AnsiColor::Cyan.on_default(), 62 | } 63 | } 64 | 65 | pub fn plain() -> Self { 66 | Self::default() 67 | } 68 | 69 | pub(crate) fn error(self, display: D) -> Styled { 70 | Styled::new(display, self.error) 71 | } 72 | 73 | pub(crate) fn warn(self, display: D) -> Styled { 74 | Styled::new(display, self.warn) 75 | } 76 | 77 | pub(crate) fn debug(self, display: D) -> Styled { 78 | Styled::new(display, self.debug) 79 | } 80 | 81 | pub(crate) fn trace(self, display: D) -> Styled { 82 | Styled::new(display, self.trace) 83 | } 84 | } 85 | 86 | #[derive(Debug)] 87 | pub(crate) struct Styled { 88 | display: D, 89 | style: anstyle::Style, 90 | } 91 | 92 | impl Styled { 93 | pub(crate) fn new(display: D, style: anstyle::Style) -> Self { 94 | Self { display, style } 95 | } 96 | } 97 | 98 | impl std::fmt::Display for Styled { 99 | #[inline] 100 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 101 | if f.alternate() { 102 | write!(f, "{}", self.style.render())?; 103 | self.display.fmt(f)?; 104 | write!(f, "{}", self.style.render_reset())?; 105 | Ok(()) 106 | } else { 107 | self.display.fmt(f) 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/bin/git-stack/main.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::collapsible_else_if)] 2 | #![allow(clippy::let_and_return)] 3 | #![allow(clippy::if_same_then_else)] 4 | #![allow(clippy::bool_to_int_with_if)] 5 | 6 | use clap::Parser; 7 | use proc_exit::WithCodeResultExt; 8 | 9 | mod alias; 10 | mod amend; 11 | mod args; 12 | mod config; 13 | mod logger; 14 | mod next; 15 | mod ops; 16 | mod prev; 17 | mod reword; 18 | mod run; 19 | mod stack; 20 | mod sync; 21 | 22 | fn main() { 23 | human_panic::setup_panic!(); 24 | let result = run(); 25 | proc_exit::exit(result); 26 | } 27 | 28 | fn run() -> proc_exit::ExitResult { 29 | // clap's `get_matches` uses Failure rather than Usage, so bypass it for `get_matches_safe`. 30 | let args = match args::Args::try_parse() { 31 | Ok(args) => args, 32 | Err(e) if e.use_stderr() => { 33 | let _ = e.print(); 34 | return proc_exit::sysexits::USAGE_ERR.ok(); 35 | } 36 | Err(e) => { 37 | let _ = e.print(); 38 | return proc_exit::Code::SUCCESS.ok(); 39 | } 40 | }; 41 | 42 | args.color.write_global(); 43 | let colored_stderr = !matches!( 44 | anstream::AutoStream::choice(&std::io::stderr()), 45 | anstream::ColorChoice::Never 46 | ); 47 | 48 | logger::init_logging(args.verbose.clone(), colored_stderr); 49 | 50 | if let Some(current_dir) = args.current_dir.as_deref() { 51 | let current_dir = current_dir 52 | .iter() 53 | .fold(std::path::PathBuf::new(), |current, next| { 54 | current.join(next) 55 | }); 56 | log::trace!("CWD={}", current_dir.display()); 57 | std::env::set_current_dir(current_dir).with_code(proc_exit::sysexits::USAGE_ERR)?; 58 | } 59 | 60 | args.exec() 61 | } 62 | -------------------------------------------------------------------------------- /src/bin/git-stack/next.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | 3 | use proc_exit::prelude::*; 4 | 5 | /// Switch to a descendant commit 6 | #[derive(clap::Args)] 7 | pub struct NextArgs { 8 | /// Jump back the specified number of commits or branches 9 | #[arg(default_value = "1")] 10 | num_commits: usize, 11 | 12 | /// Jump directly to the previous branch 13 | #[arg(short, long)] 14 | branch: bool, 15 | 16 | /// Stash prior to switch 17 | #[arg(long)] 18 | stash: bool, 19 | 20 | /// On ambiguity, select the oldest commit 21 | #[arg(long)] 22 | oldest: bool, 23 | 24 | /// Don't actually switch 25 | #[arg(short = 'n', long)] 26 | dry_run: bool, 27 | } 28 | 29 | impl NextArgs { 30 | pub const fn alias() -> crate::alias::Alias { 31 | let alias = "next"; 32 | let action = "stack next"; 33 | crate::alias::Alias { 34 | alias, 35 | action, 36 | action_base: action, 37 | } 38 | } 39 | 40 | pub fn exec(&self) -> proc_exit::ExitResult { 41 | let stderr_palette = crate::ops::Palette::colored(); 42 | 43 | let cwd = std::env::current_dir().with_code(proc_exit::sysexits::USAGE_ERR)?; 44 | let repo = git2::Repository::discover(cwd).with_code(proc_exit::sysexits::USAGE_ERR)?; 45 | let mut repo = git_stack::git::GitRepo::new(repo); 46 | 47 | let repo_config = git_stack::config::RepoConfig::from_all(repo.raw()) 48 | .with_code(proc_exit::sysexits::CONFIG_ERR)?; 49 | repo.set_push_remote(repo_config.push_remote()); 50 | repo.set_pull_remote(repo_config.pull_remote()); 51 | 52 | let protected = git_stack::git::ProtectedBranches::new( 53 | repo_config.protected_branches().iter().map(|s| s.as_str()), 54 | ) 55 | .with_code(proc_exit::sysexits::CONFIG_ERR)?; 56 | let branches = git_stack::graph::BranchSet::from_repo(&repo, &protected) 57 | .with_code(proc_exit::Code::FAILURE)?; 58 | 59 | if repo.raw().state() != git2::RepositoryState::Clean { 60 | let message = format!("cannot move to next, {:?} in progress", repo.raw().state()); 61 | if self.dry_run { 62 | let _ = writeln!( 63 | anstream::stderr(), 64 | "{}: {}", 65 | stderr_palette.error("error"), 66 | message 67 | ); 68 | } else { 69 | return Err(proc_exit::sysexits::USAGE_ERR.with_message(message)); 70 | } 71 | } 72 | 73 | if self.stash && !self.dry_run { 74 | git_stack::git::stash_push(&mut repo, "branch-stash"); 75 | } 76 | if repo.is_dirty() { 77 | let message = "Working tree is dirty, aborting"; 78 | if self.dry_run { 79 | let _ = writeln!( 80 | anstream::stderr(), 81 | "{}: {}", 82 | stderr_palette.error("error"), 83 | message 84 | ); 85 | } else { 86 | return Err(proc_exit::sysexits::USAGE_ERR.with_message(message)); 87 | } 88 | } 89 | 90 | let head_id = repo.head_commit().id; 91 | let base = crate::ops::resolve_implicit_base( 92 | &repo, 93 | head_id, 94 | &branches, 95 | repo_config.auto_base_commit_count(), 96 | ); 97 | let merge_base_oid = repo 98 | .merge_base(base.id, head_id) 99 | .ok_or_else(|| { 100 | git2::Error::new( 101 | git2::ErrorCode::NotFound, 102 | git2::ErrorClass::Reference, 103 | format!("could not find base between {base} and HEAD"), 104 | ) 105 | }) 106 | .with_code(proc_exit::sysexits::USAGE_ERR)?; 107 | let stack_branches = branches.descendants(&repo, merge_base_oid); 108 | let graph = git_stack::graph::Graph::from_branches(&repo, stack_branches) 109 | .with_code(proc_exit::Code::FAILURE)?; 110 | 111 | let mut current_id = head_id; 112 | let mut progress = 0; 113 | while progress < self.num_commits { 114 | let mut next_ids = graph.children_of(current_id).collect::>(); 115 | if next_ids.is_empty() { 116 | if progress == 0 { 117 | let _ = writeln!( 118 | anstream::stderr(), 119 | "{}: no child commit", 120 | stderr_palette.info("note"), 121 | ); 122 | } else { 123 | let _ = writeln!( 124 | anstream::stderr(), 125 | "{}: not enough child {}, only able to go forward {}", 126 | stderr_palette.info("note"), 127 | if self.branch { "branches" } else { "commits" }, 128 | self.num_commits 129 | ); 130 | } 131 | break; 132 | } 133 | 134 | next_ids.sort_by_key(|id| repo.find_commit(*id).map(|c| c.time)); 135 | if !self.oldest { 136 | next_ids.reverse(); 137 | } 138 | current_id = *next_ids.first().expect("next_ids.is_empty checked"); 139 | if 1 < next_ids.len() { 140 | log::debug!( 141 | "selected {} over {}", 142 | crate::ops::render_id(&repo, &branches, current_id), 143 | next_ids 144 | .iter() 145 | .skip(1) 146 | .map(|id| crate::ops::render_id(&repo, &branches, *id)) 147 | .collect::>() 148 | .join(", ") 149 | ); 150 | } 151 | if self.branch { 152 | if let Some(current) = branches.get(current_id) { 153 | log::debug!( 154 | "traversing {}", 155 | current 156 | .iter() 157 | .map(|b| b.display_name().to_string()) 158 | .collect::>() 159 | .join(", ") 160 | ); 161 | progress += 1; 162 | } 163 | } else { 164 | progress += 1; 165 | } 166 | } 167 | 168 | if current_id != head_id { 169 | crate::ops::switch( 170 | &mut repo, 171 | &branches, 172 | current_id, 173 | stderr_palette, 174 | self.dry_run, 175 | ) 176 | .with_code(proc_exit::Code::FAILURE)?; 177 | } 178 | 179 | Ok(()) 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/bin/git-stack/prev.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | 3 | use proc_exit::prelude::*; 4 | 5 | /// Switch to an ancestor commit 6 | #[derive(clap::Args)] 7 | pub struct PrevArgs { 8 | /// Jump back the specified number of commits or branches 9 | #[arg(default_value = "1")] 10 | num_commits: usize, 11 | 12 | /// Jump directly to the previous branch 13 | #[arg(short, long)] 14 | branch: bool, 15 | 16 | /// Stash prior to switch 17 | #[arg(long)] 18 | stash: bool, 19 | 20 | /// On ambiguity, select the oldest commit 21 | #[arg(long)] 22 | oldest: bool, 23 | 24 | /// Traverse across protected commits 25 | #[arg(long)] 26 | protected: bool, 27 | 28 | /// Don't actually switch 29 | #[arg(short = 'n', long)] 30 | dry_run: bool, 31 | } 32 | 33 | impl PrevArgs { 34 | pub const fn alias() -> crate::alias::Alias { 35 | let alias = "prev"; 36 | let action = "stack previous"; 37 | crate::alias::Alias { 38 | alias, 39 | action, 40 | action_base: action, 41 | } 42 | } 43 | 44 | pub fn exec(&self) -> proc_exit::ExitResult { 45 | let stderr_palette = crate::ops::Palette::colored(); 46 | 47 | let cwd = std::env::current_dir().with_code(proc_exit::sysexits::USAGE_ERR)?; 48 | let repo = git2::Repository::discover(cwd).with_code(proc_exit::sysexits::USAGE_ERR)?; 49 | let mut repo = git_stack::git::GitRepo::new(repo); 50 | 51 | let repo_config = git_stack::config::RepoConfig::from_all(repo.raw()) 52 | .with_code(proc_exit::sysexits::CONFIG_ERR)?; 53 | repo.set_push_remote(repo_config.push_remote()); 54 | repo.set_pull_remote(repo_config.pull_remote()); 55 | 56 | let protected = git_stack::git::ProtectedBranches::new( 57 | repo_config.protected_branches().iter().map(|s| s.as_str()), 58 | ) 59 | .with_code(proc_exit::sysexits::CONFIG_ERR)?; 60 | let branches = git_stack::graph::BranchSet::from_repo(&repo, &protected) 61 | .with_code(proc_exit::Code::FAILURE)?; 62 | 63 | if repo.raw().state() != git2::RepositoryState::Clean { 64 | let message = format!( 65 | "cannot move to previous, {:?} in progress", 66 | repo.raw().state() 67 | ); 68 | if self.dry_run { 69 | let _ = writeln!( 70 | anstream::stderr(), 71 | "{}: {}", 72 | stderr_palette.error("error"), 73 | message 74 | ); 75 | } else { 76 | return Err(proc_exit::sysexits::USAGE_ERR.with_message(message)); 77 | } 78 | } 79 | 80 | if self.stash && !self.dry_run { 81 | git_stack::git::stash_push(&mut repo, "branch-stash"); 82 | } 83 | if repo.is_dirty() { 84 | let message = "Working tree is dirty, aborting"; 85 | if self.dry_run { 86 | let _ = writeln!( 87 | anstream::stderr(), 88 | "{}: {}", 89 | stderr_palette.error("error"), 90 | message 91 | ); 92 | } else { 93 | return Err(proc_exit::sysexits::USAGE_ERR.with_message(message)); 94 | } 95 | } 96 | 97 | let head_id = repo.head_commit().id; 98 | let mut current_id = head_id; 99 | let mut progress = 0; 100 | while progress < self.num_commits { 101 | let is_current_protected = matches!( 102 | branches 103 | .get(current_id) 104 | .and_then(|b| b.iter().map(|b| b.kind()).max()), 105 | // HACK: We should be checking whether the commits are protected, rather than 106 | // to allow user commits on Mixed branches 107 | Some(git_stack::graph::BranchKind::Protected) 108 | | Some(git_stack::graph::BranchKind::Mixed) 109 | ); 110 | if is_current_protected && !self.protected { 111 | if progress == 0 { 112 | let _ = writeln!( 113 | anstream::stderr(), 114 | "{}: no unprotected parent commit; to traverse protected commits, pass `--protected`", 115 | stderr_palette.info("note"), 116 | ); 117 | } else { 118 | let _ = writeln!( 119 | anstream::stderr(), 120 | "{}: not enough unprotected parent {}, only able to go back {}; to traverse protected commits, pass `--protected`", 121 | stderr_palette.info("note"), 122 | if self.branch { "branches" } else { "commits" }, 123 | self.num_commits 124 | ); 125 | } 126 | break; 127 | } 128 | 129 | let mut next_ids = repo 130 | .parent_ids(current_id) 131 | .with_code(proc_exit::Code::FAILURE)?; 132 | if next_ids.is_empty() { 133 | if progress == 0 { 134 | let _ = writeln!( 135 | anstream::stderr(), 136 | "{}: no parent commit", 137 | stderr_palette.info("note"), 138 | ); 139 | } else { 140 | let _ = writeln!( 141 | anstream::stderr(), 142 | "{}: not enough parent {}, only able to go forward {}", 143 | stderr_palette.info("note"), 144 | if self.branch { "branches" } else { "commits" }, 145 | self.num_commits 146 | ); 147 | } 148 | break; 149 | } 150 | 151 | if self.oldest { 152 | next_ids.sort_by_key(|id| { 153 | let branch_kind = branches 154 | .get(*id) 155 | .and_then(|b| b.iter().map(|b| b.kind()).max()); 156 | let commit_time = repo.find_commit(*id).map(|c| c.time); 157 | // Prefer user branches 158 | (branch_kind, commit_time) 159 | }); 160 | } else { 161 | next_ids.sort_by_key(|id| { 162 | let branch_kind = branches 163 | .get(*id) 164 | .and_then(|b| b.iter().map(|b| b.kind()).max()); 165 | let commit_time = repo.find_commit(*id).map(|c| c.time); 166 | // Prefer user branches 167 | (branch_kind, std::cmp::Reverse(commit_time)) 168 | }); 169 | } 170 | current_id = *next_ids.first().expect("next_ids.is_empty checked"); 171 | if 1 < next_ids.len() { 172 | log::debug!( 173 | "selected {} over {}", 174 | crate::ops::render_id(&repo, &branches, current_id), 175 | next_ids 176 | .iter() 177 | .skip(1) 178 | .map(|id| crate::ops::render_id(&repo, &branches, *id)) 179 | .collect::>() 180 | .join(", ") 181 | ); 182 | } 183 | if self.branch { 184 | if let Some(current) = branches.get(current_id) { 185 | log::debug!( 186 | "Traversing {}", 187 | current 188 | .iter() 189 | .map(|b| b.display_name().to_string()) 190 | .collect::>() 191 | .join(", ") 192 | ); 193 | progress += 1; 194 | } 195 | } else { 196 | progress += 1; 197 | } 198 | } 199 | 200 | if current_id != head_id { 201 | crate::ops::switch( 202 | &mut repo, 203 | &branches, 204 | current_id, 205 | stderr_palette, 206 | self.dry_run, 207 | ) 208 | .with_code(proc_exit::Code::FAILURE)?; 209 | } 210 | 211 | Ok(()) 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /src/bin/git-stack/reword.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | 3 | use itertools::Itertools; 4 | use proc_exit::prelude::*; 5 | 6 | use git_stack::git::Repo; 7 | 8 | /// Rewrite the commit message 9 | /// 10 | /// When you reword a commit that has descendants, those descendants are rebased on top of the 11 | /// reworded version of the commit. 12 | #[derive(clap::Args)] 13 | pub struct RewordArgs { 14 | /// Commit to rewrite 15 | #[arg(default_value = "HEAD")] 16 | rev: String, 17 | 18 | /// Commit message 19 | #[arg(short, long)] 20 | message: Option, 21 | 22 | /// Don't actually switch 23 | #[arg(short = 'n', long)] 24 | dry_run: bool, 25 | } 26 | 27 | impl RewordArgs { 28 | pub const fn alias() -> crate::alias::Alias { 29 | let alias = "reword"; 30 | let action = "stack reword"; 31 | crate::alias::Alias { 32 | alias, 33 | action, 34 | action_base: action, 35 | } 36 | } 37 | 38 | pub fn exec(&self) -> proc_exit::ExitResult { 39 | let stderr_palette = crate::ops::Palette::colored(); 40 | 41 | let cwd = std::env::current_dir().with_code(proc_exit::sysexits::USAGE_ERR)?; 42 | let repo = git2::Repository::discover(&cwd).with_code(proc_exit::sysexits::USAGE_ERR)?; 43 | let mut repo = git_stack::git::GitRepo::new(repo); 44 | 45 | let repo_config = git_stack::config::RepoConfig::from_all(repo.raw()) 46 | .with_code(proc_exit::sysexits::CONFIG_ERR)?; 47 | repo.set_push_remote(repo_config.push_remote()); 48 | repo.set_pull_remote(repo_config.pull_remote()); 49 | let config = repo 50 | .raw() 51 | .config() 52 | .with_code(proc_exit::sysexits::CONFIG_ERR)?; 53 | repo.set_sign( 54 | config 55 | .get_bool("stack.gpgSign") 56 | .or_else(|_| config.get_bool("commit.gpgSign")) 57 | .unwrap_or_default(), 58 | ) 59 | .with_code(proc_exit::Code::FAILURE)?; 60 | 61 | let protected = git_stack::git::ProtectedBranches::new( 62 | repo_config.protected_branches().iter().map(|s| s.as_str()), 63 | ) 64 | .with_code(proc_exit::sysexits::CONFIG_ERR)?; 65 | let branches = git_stack::graph::BranchSet::from_repo(&repo, &protected) 66 | .with_code(proc_exit::Code::FAILURE)?; 67 | 68 | let head_ann_id = crate::ops::resolve_explicit_base(&repo, &self.rev) 69 | .with_code(proc_exit::Code::FAILURE)?; 70 | let head_id = head_ann_id.id; 71 | let head = repo.find_commit(head_id).expect("resolve found a commit"); 72 | let head_branch = head_ann_id.branch.as_ref(); 73 | let base = crate::ops::resolve_implicit_base( 74 | &repo, 75 | head_id, 76 | &branches, 77 | repo_config.auto_base_commit_count(), 78 | ); 79 | let merge_base_oid = repo 80 | .merge_base(base.id, head_id) 81 | .ok_or_else(|| { 82 | git2::Error::new( 83 | git2::ErrorCode::NotFound, 84 | git2::ErrorClass::Reference, 85 | format!("could not find base between {base} and HEAD"), 86 | ) 87 | }) 88 | .with_code(proc_exit::sysexits::USAGE_ERR)?; 89 | let stack_branches = branches.descendants(&repo, merge_base_oid); 90 | let mut graph = git_stack::graph::Graph::from_branches(&repo, stack_branches) 91 | .with_code(proc_exit::Code::FAILURE)?; 92 | git_stack::graph::protect_branches(&mut graph); 93 | git_stack::graph::mark_fixup(&mut graph, &repo); 94 | git_stack::graph::mark_wip(&mut graph, &repo); 95 | 96 | if repo.raw().state() != git2::RepositoryState::Clean { 97 | let message = format!("cannot walk commits, {:?} in progress", repo.raw().state()); 98 | if self.dry_run { 99 | let _ = writeln!( 100 | anstream::stderr(), 101 | "{}: {}", 102 | stderr_palette.error("error"), 103 | message 104 | ); 105 | } else { 106 | return Err(proc_exit::sysexits::USAGE_ERR.with_message(message)); 107 | } 108 | } 109 | let action = graph 110 | .commit_get::(head_id) 111 | .copied() 112 | .unwrap_or_default(); 113 | match action { 114 | git_stack::graph::Action::Pick => {} 115 | git_stack::graph::Action::Fixup => { 116 | return Err(proc_exit::Code::FAILURE.with_message("cannot reword fixup commits")); 117 | } 118 | git_stack::graph::Action::Protected => { 119 | return Err( 120 | proc_exit::Code::FAILURE.with_message("cannot reword protected commits") 121 | ); 122 | } 123 | } 124 | 125 | let new_message = if let Some(message) = self.message.as_deref() { 126 | message.trim().to_owned() 127 | } else { 128 | use std::fmt::Write; 129 | 130 | let raw_commit = repo 131 | .raw() 132 | .find_commit(head.id) 133 | .expect("head_commit is always valid"); 134 | let existing = String::from_utf8_lossy(raw_commit.message_bytes()); 135 | let mut template = String::new(); 136 | writeln!(&mut template, "{existing}").unwrap(); 137 | writeln!(&mut template).unwrap(); 138 | writeln!( 139 | &mut template, 140 | "# Please enter the commit message for your changes. Lines starting" 141 | ) 142 | .unwrap(); 143 | writeln!( 144 | &mut template, 145 | "# with '#' will be ignored, and an empty message aborts the commit." 146 | ) 147 | .unwrap(); 148 | if let Some(head_branch) = &head_branch { 149 | writeln!(&mut template, "#").unwrap(); 150 | writeln!(&mut template, "# On branch {head_branch}").unwrap(); 151 | } 152 | let message = crate::ops::edit_commit( 153 | repo.path() 154 | .ok_or_else(|| eyre::format_err!("no `.git` path found")) 155 | .with_code(proc_exit::Code::FAILURE)?, 156 | repo_config.editor(), 157 | &template, 158 | ) 159 | .with_code(proc_exit::Code::FAILURE)?; 160 | let message = match message { 161 | Some(message) => message, 162 | None => { 163 | return Err(proc_exit::Code::SUCCESS.with_message("Nothing to do.")); 164 | } 165 | }; 166 | message 167 | }; 168 | 169 | git_stack::graph::reword_commit(&mut graph, &repo, head_id, new_message) 170 | .with_code(proc_exit::Code::FAILURE)?; 171 | 172 | let mut stash_id = None; 173 | if !self.dry_run { 174 | stash_id = git_stack::git::stash_push(&mut repo, "reword"); 175 | } 176 | 177 | let mut backed_up = false; 178 | { 179 | let stash_repo = 180 | git2::Repository::discover(&cwd).with_code(proc_exit::sysexits::USAGE_ERR)?; 181 | let stash_repo = git_branch_stash::GitRepo::new(stash_repo); 182 | let mut snapshots = 183 | git_branch_stash::Stack::new(crate::ops::STASH_STACK_NAME, &stash_repo); 184 | let snapshot_capacity = repo_config.capacity(); 185 | snapshots.capacity(snapshot_capacity); 186 | let snapshot = git_branch_stash::Snapshot::from_repo(&stash_repo) 187 | .with_code(proc_exit::Code::FAILURE)?; 188 | if !self.dry_run { 189 | snapshots.push(snapshot).to_sysexits()?; 190 | backed_up = true; 191 | } 192 | } 193 | 194 | let mut success = true; 195 | let scripts = git_stack::graph::to_scripts(&graph, vec![]); 196 | let mut executor = git_stack::rewrite::Executor::new(self.dry_run); 197 | for script in scripts { 198 | let results = executor.run(&mut repo, &script); 199 | for (err, name, dependents) in results.iter() { 200 | success = false; 201 | log::error!("Failed to re-stack branch `{}`: {}", name, err); 202 | if !dependents.is_empty() { 203 | log::error!(" Blocked dependents: {}", dependents.iter().join(", ")); 204 | } 205 | } 206 | } 207 | executor 208 | .close(&mut repo, head_branch.as_ref().and_then(|b| b.local_name())) 209 | .with_code(proc_exit::Code::FAILURE)?; 210 | 211 | git_stack::git::stash_pop(&mut repo, stash_id); 212 | if backed_up { 213 | anstream::eprintln!( 214 | "{}: to undo, run {}", 215 | stderr_palette.info("note"), 216 | stderr_palette.highlight(format_args!( 217 | "`git branch-stash pop {}`", 218 | crate::ops::STASH_STACK_NAME 219 | )) 220 | ); 221 | } 222 | 223 | if success { 224 | Ok(()) 225 | } else { 226 | Err(proc_exit::Code::FAILURE.as_exit()) 227 | } 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /src/bin/git-stack/run.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | 3 | use proc_exit::prelude::*; 4 | 5 | /// Run across commands in the current stack 6 | #[derive(clap::Args)] 7 | pub struct RunArgs { 8 | #[arg(value_names = ["COMMAND", "ARG"], trailing_var_arg = true, required=true)] 9 | command: Vec, 10 | 11 | /// Keep going on failure 12 | #[arg(long, alias = "no-ff")] 13 | no_fail_fast: bool, 14 | #[arg(long, alias = "ff", hide = true, overrides_with = "no_fail_fast")] 15 | fail_fast: bool, 16 | 17 | /// Switch to the first commit that failed 18 | #[arg(short, long)] 19 | switch: bool, 20 | 21 | /// Don't actually switch 22 | #[arg(short = 'n', long)] 23 | dry_run: bool, 24 | } 25 | 26 | impl RunArgs { 27 | pub const fn alias() -> crate::alias::Alias { 28 | let alias = "run"; 29 | let action = "stack run"; 30 | crate::alias::Alias { 31 | alias, 32 | action, 33 | action_base: action, 34 | } 35 | } 36 | 37 | pub fn exec(&self) -> proc_exit::ExitResult { 38 | let stderr_palette = crate::ops::Palette::colored(); 39 | 40 | let cwd = std::env::current_dir().with_code(proc_exit::sysexits::USAGE_ERR)?; 41 | let repo = git2::Repository::discover(cwd).with_code(proc_exit::sysexits::USAGE_ERR)?; 42 | let mut repo = git_stack::git::GitRepo::new(repo); 43 | 44 | let repo_config = git_stack::config::RepoConfig::from_all(repo.raw()) 45 | .with_code(proc_exit::sysexits::CONFIG_ERR)?; 46 | repo.set_push_remote(repo_config.push_remote()); 47 | repo.set_pull_remote(repo_config.pull_remote()); 48 | 49 | let protected = git_stack::git::ProtectedBranches::new( 50 | repo_config.protected_branches().iter().map(|s| s.as_str()), 51 | ) 52 | .with_code(proc_exit::sysexits::CONFIG_ERR)?; 53 | let branches = git_stack::graph::BranchSet::from_repo(&repo, &protected) 54 | .with_code(proc_exit::Code::FAILURE)?; 55 | 56 | if repo.raw().state() != git2::RepositoryState::Clean { 57 | let message = format!("cannot walk commits, {:?} in progress", repo.raw().state()); 58 | if self.dry_run { 59 | let _ = writeln!( 60 | anstream::stderr(), 61 | "{}: {}", 62 | stderr_palette.error("error"), 63 | message 64 | ); 65 | } else { 66 | return Err(proc_exit::sysexits::USAGE_ERR.with_message(message)); 67 | } 68 | } 69 | 70 | let mut stash_id = None; 71 | if !self.dry_run && !self.switch { 72 | stash_id = git_stack::git::stash_push(&mut repo, "run"); 73 | } 74 | if repo.is_dirty() { 75 | let message = "Working tree is dirty, aborting"; 76 | if self.dry_run { 77 | let _ = writeln!( 78 | anstream::stderr(), 79 | "{}: {}", 80 | stderr_palette.error("error"), 81 | message 82 | ); 83 | } else { 84 | return Err(proc_exit::sysexits::USAGE_ERR.with_message(message)); 85 | } 86 | } 87 | 88 | let head_branch = repo.head_branch(); 89 | let head_id = repo.head_commit().id; 90 | let base = crate::ops::resolve_implicit_base( 91 | &repo, 92 | head_id, 93 | &branches, 94 | repo_config.auto_base_commit_count(), 95 | ); 96 | let merge_base_oid = repo 97 | .merge_base(base.id, head_id) 98 | .ok_or_else(|| { 99 | git2::Error::new( 100 | git2::ErrorCode::NotFound, 101 | git2::ErrorClass::Reference, 102 | format!("could not find base between {base} and HEAD"), 103 | ) 104 | }) 105 | .with_code(proc_exit::sysexits::USAGE_ERR)?; 106 | let stack_branches = branches.dependents(&repo, merge_base_oid, head_id); 107 | let graph = git_stack::graph::Graph::from_branches(&repo, stack_branches) 108 | .with_code(proc_exit::Code::FAILURE)?; 109 | 110 | let mut first_failure = None; 111 | 112 | let mut success = true; 113 | let mut cursor = graph.descendants_of(merge_base_oid).into_cursor(); 114 | while let Some(current_id) = cursor.next(&graph) { 115 | let current_commit = repo 116 | .find_commit(current_id) 117 | .expect("children/head are always present"); 118 | let _ = writeln!( 119 | anstream::stderr(), 120 | "{} to {}: {}", 121 | stderr_palette.good("Switching"), 122 | stderr_palette.highlight(crate::ops::render_id(&repo, &branches, current_id)), 123 | stderr_palette.hint(¤t_commit.summary) 124 | ); 125 | if !self.dry_run { 126 | repo.switch_commit(current_id) 127 | .with_code(proc_exit::Code::FAILURE)?; 128 | } 129 | let status = std::process::Command::new(&self.command[0]) 130 | .args(&self.command[1..]) 131 | .status(); 132 | let mut current_success = true; 133 | match status { 134 | Ok(status) if status.success() => { 135 | let _ = writeln!( 136 | anstream::stderr(), 137 | "{} with {}", 138 | stderr_palette.good("Success"), 139 | stderr_palette 140 | .highlight(crate::ops::render_id(&repo, &branches, current_id)), 141 | ); 142 | } 143 | Ok(status) => match status.code() { 144 | Some(code) => { 145 | let _ = writeln!( 146 | anstream::stderr(), 147 | "{} with {}: exit code {}", 148 | stderr_palette.error("Failed"), 149 | stderr_palette 150 | .highlight(crate::ops::render_id(&repo, &branches, current_id)), 151 | code, 152 | ); 153 | current_success = false; 154 | } 155 | None => { 156 | let _ = writeln!( 157 | anstream::stderr(), 158 | "{} with {}: signal caught", 159 | stderr_palette.error("Failed"), 160 | stderr_palette 161 | .highlight(crate::ops::render_id(&repo, &branches, current_id)), 162 | ); 163 | current_success = false; 164 | } 165 | }, 166 | Err(err) => { 167 | let _ = writeln!( 168 | anstream::stderr(), 169 | "{} with {}: {}", 170 | stderr_palette.error("Failed"), 171 | stderr_palette 172 | .highlight(crate::ops::render_id(&repo, &branches, current_id)), 173 | err 174 | ); 175 | current_success = false; 176 | } 177 | } 178 | if !current_success { 179 | first_failure.get_or_insert(current_id); 180 | if self.fail_fast() { 181 | cursor.stop(); 182 | } 183 | success = false; 184 | } 185 | } 186 | 187 | if !success && self.switch && first_failure != Some(head_id) { 188 | assert!( 189 | stash_id.is_none(), 190 | "prevented earlier to avoid people losing track of their work" 191 | ); 192 | let first_failure = first_failure.unwrap(); 193 | let _ = writeln!( 194 | anstream::stderr(), 195 | "{} to failed commit {}", 196 | stderr_palette.error("Switching"), 197 | stderr_palette.highlight(crate::ops::render_id(&repo, &branches, first_failure)), 198 | ); 199 | crate::ops::switch( 200 | &mut repo, 201 | &branches, 202 | first_failure, 203 | stderr_palette, 204 | self.dry_run, 205 | ) 206 | .with_code(proc_exit::Code::FAILURE)?; 207 | } else { 208 | if let Some(first_failure) = first_failure { 209 | let _ = writeln!( 210 | anstream::stderr(), 211 | "{} starting at {}", 212 | stderr_palette.error("Failed"), 213 | stderr_palette.highlight(crate::ops::render_id( 214 | &repo, 215 | &branches, 216 | first_failure 217 | )), 218 | ); 219 | } 220 | if let Some(branch) = head_branch { 221 | if !self.dry_run { 222 | repo.switch_branch(branch.local_name().expect("HEAD is always local")) 223 | .with_code(proc_exit::Code::FAILURE)?; 224 | } 225 | } else { 226 | if !self.dry_run { 227 | repo.switch_commit(head_id) 228 | .with_code(proc_exit::Code::FAILURE)?; 229 | } 230 | } 231 | 232 | git_stack::git::stash_pop(&mut repo, stash_id); 233 | } 234 | 235 | if success { 236 | Ok(()) 237 | } else { 238 | Err(proc_exit::Code::FAILURE.as_exit()) 239 | } 240 | } 241 | 242 | fn fail_fast(&self) -> bool { 243 | resolve_bool_arg(self.fail_fast, self.no_fail_fast).unwrap_or(true) 244 | } 245 | } 246 | 247 | fn resolve_bool_arg(yes: bool, no: bool) -> Option { 248 | match (yes, no) { 249 | (true, false) => Some(true), 250 | (false, true) => Some(false), 251 | (false, false) => None, 252 | (_, _) => unreachable!("clap should make this impossible"), 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /src/bin/git-stack/sync.rs: -------------------------------------------------------------------------------- 1 | use itertools::Itertools; 2 | use proc_exit::prelude::*; 3 | 4 | /// Rebase local branches on top of pull remotes 5 | #[derive(clap::Args)] 6 | pub struct SyncArgs { 7 | /// Don't actually switch 8 | #[arg(short = 'n', long)] 9 | dry_run: bool, 10 | } 11 | 12 | impl SyncArgs { 13 | pub const fn alias() -> crate::alias::Alias { 14 | let alias = "sync"; 15 | let action = "stack sync"; 16 | crate::alias::Alias { 17 | alias, 18 | action, 19 | action_base: action, 20 | } 21 | } 22 | 23 | pub fn exec(&self) -> proc_exit::ExitResult { 24 | let stderr_palette = crate::ops::Palette::colored(); 25 | 26 | let cwd = std::env::current_dir().with_code(proc_exit::sysexits::USAGE_ERR)?; 27 | let repo = git2::Repository::discover(&cwd).with_code(proc_exit::sysexits::USAGE_ERR)?; 28 | let mut repo = git_stack::git::GitRepo::new(repo); 29 | 30 | let repo_config = git_stack::config::RepoConfig::from_all(repo.raw()) 31 | .with_code(proc_exit::sysexits::CONFIG_ERR)?; 32 | repo.set_push_remote(repo_config.push_remote()); 33 | repo.set_pull_remote(repo_config.pull_remote()); 34 | let config = repo 35 | .raw() 36 | .config() 37 | .with_code(proc_exit::sysexits::CONFIG_ERR)?; 38 | repo.set_sign( 39 | config 40 | .get_bool("stack.gpgSign") 41 | .or_else(|_| config.get_bool("commit.gpgSign")) 42 | .unwrap_or_default(), 43 | ) 44 | .with_code(proc_exit::Code::FAILURE)?; 45 | 46 | let protected = git_stack::git::ProtectedBranches::new( 47 | repo_config.protected_branches().iter().map(|s| s.as_str()), 48 | ) 49 | .with_code(proc_exit::sysexits::CONFIG_ERR)?; 50 | let branches = git_stack::graph::BranchSet::from_repo(&repo, &protected) 51 | .with_code(proc_exit::Code::FAILURE)?; 52 | 53 | let head = repo.head_commit(); 54 | let head_id = head.id; 55 | let mut head_branch = repo.head_branch(); 56 | let mut onto = crate::ops::resolve_implicit_base( 57 | &repo, 58 | head_id, 59 | &branches, 60 | repo_config.auto_base_commit_count(), 61 | ); 62 | let mut base = crate::ops::resolve_base_from_onto(&repo, &onto); 63 | let merge_base_oid = repo 64 | .merge_base(base.id, head_id) 65 | .ok_or_else(|| { 66 | git2::Error::new( 67 | git2::ErrorCode::NotFound, 68 | git2::ErrorClass::Reference, 69 | format!("could not find base between {base} and HEAD"), 70 | ) 71 | }) 72 | .with_code(proc_exit::sysexits::USAGE_ERR)?; 73 | let mut branches = branches.descendants(&repo, merge_base_oid); 74 | 75 | let mut stash_id = None; 76 | if !self.dry_run { 77 | stash_id = git_stack::git::stash_push(&mut repo, "reword"); 78 | } 79 | 80 | let mut backed_up = false; 81 | { 82 | let stash_repo = 83 | git2::Repository::discover(&cwd).with_code(proc_exit::sysexits::USAGE_ERR)?; 84 | let stash_repo = git_branch_stash::GitRepo::new(stash_repo); 85 | let mut snapshots = 86 | git_branch_stash::Stack::new(crate::ops::STASH_STACK_NAME, &stash_repo); 87 | let snapshot_capacity = repo_config.capacity(); 88 | snapshots.capacity(snapshot_capacity); 89 | let snapshot = git_branch_stash::Snapshot::from_repo(&stash_repo) 90 | .with_code(proc_exit::Code::FAILURE)?; 91 | if !self.dry_run { 92 | snapshots.push(snapshot).to_sysexits()?; 93 | backed_up = true; 94 | } 95 | } 96 | 97 | // Update status of remote unprotected branches 98 | let mut update_branches = false; 99 | let mut push_branches: Vec<_> = branches 100 | .iter() 101 | .flat_map(|(_, b)| b.iter()) 102 | .filter(|b| match b.kind() { 103 | git_stack::graph::BranchKind::Mutable => true, 104 | git_stack::graph::BranchKind::Deleted 105 | | git_stack::graph::BranchKind::Protected 106 | | git_stack::graph::BranchKind::Mixed => false, 107 | }) 108 | .filter_map(|b| b.push_id().and_then(|_| b.local_name())) 109 | .collect(); 110 | push_branches.sort_unstable(); 111 | if !push_branches.is_empty() { 112 | match crate::ops::git_prune_development(&mut repo, &push_branches, self.dry_run) { 113 | Ok(_) => update_branches = true, 114 | Err(err) => { 115 | log::warn!("Skipping fetch of `{}`, {}", repo.push_remote(), err); 116 | } 117 | } 118 | } 119 | if let Some(branch) = &onto.branch { 120 | if let Some(remote) = &branch.remote { 121 | match crate::ops::git_fetch_upstream(remote, branch.name.as_str()) { 122 | Ok(_) => update_branches = true, 123 | Err(err) => { 124 | log::warn!("Skipping pull of `{}`, {}", branch, err); 125 | } 126 | } 127 | } 128 | } 129 | if update_branches { 130 | branches.update(&repo).with_code(proc_exit::Code::FAILURE)?; 131 | base.update(&repo).with_code(proc_exit::Code::FAILURE)?; 132 | onto.update(&repo).with_code(proc_exit::Code::FAILURE)?; 133 | } 134 | 135 | let protect_commit_count = repo_config.protect_commit_count(); 136 | let protect_commit_age = repo_config.protect_commit_age(); 137 | let protect_commit_time = std::time::SystemTime::now() - protect_commit_age; 138 | let scripts = plan_changes( 139 | &repo, 140 | &base, 141 | &onto, 142 | &branches, 143 | protect_commit_count, 144 | protect_commit_time, 145 | ) 146 | .with_code(proc_exit::Code::FAILURE)?; 147 | let head_local_branch = head_branch.clone(); 148 | if let Some(head_local_branch) = head_local_branch.as_ref().and_then(|b| b.local_name()) { 149 | for script in &scripts { 150 | if script.is_branch_deleted(head_local_branch) { 151 | // Current branch is deleted, fallback to the local version of the onto branch, 152 | // if possible. 153 | if let Some(local_branch) = base 154 | .branch 155 | .as_ref() 156 | .map(|b| b.name.as_str()) 157 | .and_then(|n| repo.find_local_branch(n)) 158 | { 159 | head_branch = Some(local_branch); 160 | } 161 | } 162 | } 163 | } 164 | 165 | let mut success = true; 166 | let mut executor = git_stack::rewrite::Executor::new(self.dry_run); 167 | for script in scripts { 168 | let results = executor.run(&mut repo, &script); 169 | for (err, name, dependents) in results.iter() { 170 | success = false; 171 | log::error!("Failed to re-stack branch `{}`: {}", name, err); 172 | if !dependents.is_empty() { 173 | log::error!(" Blocked dependents: {}", dependents.iter().join(", ")); 174 | } 175 | } 176 | } 177 | executor 178 | .close(&mut repo, head_branch.as_ref().and_then(|b| b.local_name())) 179 | .with_code(proc_exit::Code::FAILURE)?; 180 | 181 | git_stack::git::stash_pop(&mut repo, stash_id); 182 | if backed_up { 183 | anstream::eprintln!( 184 | "{}: to undo, run {}", 185 | stderr_palette.info("note"), 186 | stderr_palette.highlight(format_args!( 187 | "`git branch-stash pop {}`", 188 | crate::ops::STASH_STACK_NAME 189 | )) 190 | ); 191 | } 192 | 193 | if success { 194 | Ok(()) 195 | } else { 196 | Err(proc_exit::Code::FAILURE.as_exit()) 197 | } 198 | } 199 | } 200 | 201 | fn plan_changes( 202 | repo: &dyn git_stack::git::Repo, 203 | base: &crate::ops::AnnotatedOid, 204 | onto: &crate::ops::AnnotatedOid, 205 | branches: &git_stack::graph::BranchSet, 206 | protect_commit_count: Option, 207 | protect_commit_time: std::time::SystemTime, 208 | ) -> eyre::Result> { 209 | log::trace!("Planning stack changes with base={}, onto={}", base, onto); 210 | let graphed_branches = branches.clone(); 211 | let mut graph = git_stack::graph::Graph::from_branches(repo, graphed_branches)?; 212 | git_stack::graph::protect_branches(&mut graph); 213 | if let Some(protect_commit_count) = protect_commit_count { 214 | git_stack::graph::protect_large_branches(&mut graph, protect_commit_count); 215 | } 216 | let head_id = repo.head_commit().id; 217 | git_stack::graph::protect_stale_branches(&mut graph, repo, protect_commit_time, &[head_id]); 218 | if let Some(user) = repo.user() { 219 | git_stack::graph::protect_foreign_branches(&mut graph, repo, &user, &[]); 220 | } 221 | 222 | let mut dropped_branches = Vec::new(); 223 | 224 | let onto_id = onto.id; 225 | let pull_start_id = base.id; 226 | let pull_start_id = repo.merge_base(pull_start_id, onto_id).unwrap_or(onto_id); 227 | git_stack::graph::rebase_development_branches(&mut graph, onto_id); 228 | git_stack::graph::rebase_pulled_branches(&mut graph, pull_start_id, onto_id); 229 | 230 | let pull_range: Vec<_> = git_stack::git::commit_range(repo, onto_id..pull_start_id)? 231 | .into_iter() 232 | .map(|id| repo.find_commit(id).unwrap()) 233 | .collect(); 234 | dropped_branches.extend(git_stack::graph::delete_squashed_branches_by_tree_id( 235 | &mut graph, 236 | repo, 237 | pull_start_id, 238 | pull_range.iter().map(|c| c.tree_id), 239 | )); 240 | dropped_branches.extend(git_stack::graph::delete_merged_branches( 241 | &mut graph, 242 | pull_range.iter().map(|c| c.id), 243 | )); 244 | 245 | log::trace!("Generating script"); 246 | let scripts = git_stack::graph::to_scripts(&graph, dropped_branches); 247 | Ok(scripts) 248 | } 249 | -------------------------------------------------------------------------------- /src/git/mod.rs: -------------------------------------------------------------------------------- 1 | mod protect; 2 | mod repo; 3 | 4 | pub use protect::*; 5 | pub use repo::*; 6 | -------------------------------------------------------------------------------- /src/git/protect.rs: -------------------------------------------------------------------------------- 1 | #[derive(Clone, Debug)] 2 | pub struct ProtectedBranches { 3 | ignores: ignore::gitignore::Gitignore, 4 | } 5 | 6 | impl ProtectedBranches { 7 | pub fn new<'p>(patterns: impl IntoIterator) -> eyre::Result { 8 | let mut ignores = ignore::gitignore::GitignoreBuilder::new(""); 9 | for pattern in patterns { 10 | ignores.add_line(None, pattern)?; 11 | } 12 | let ignores = ignores.build()?; 13 | Ok(Self { ignores }) 14 | } 15 | 16 | pub fn is_protected(&self, name: &str) -> bool { 17 | let name_match = self.ignores.matched_path_or_any_parents(name, false); 18 | match name_match { 19 | ignore::Match::None => false, 20 | ignore::Match::Ignore(glob) => { 21 | log::trace!("`{}` is ignored by {:?}", name, glob.original()); 22 | true 23 | } 24 | ignore::Match::Whitelist(glob) => { 25 | log::trace!("`{}` is allowed by {:?}", name, glob.original()); 26 | false 27 | } 28 | } 29 | } 30 | } 31 | 32 | #[cfg(test)] 33 | mod test { 34 | use super::*; 35 | 36 | #[test] 37 | fn empty_allows_all() { 38 | let protect = ProtectedBranches::new(None).unwrap(); 39 | assert!(!protect.is_protected("main")); 40 | } 41 | 42 | #[test] 43 | fn protect_branch() { 44 | let protect = ProtectedBranches::new(Some("main")).unwrap(); 45 | assert!(protect.is_protected("main")); 46 | assert!(!protect.is_protected("feature")); 47 | } 48 | 49 | #[test] 50 | fn negation_patterns() { 51 | let protect = ProtectedBranches::new(vec!["v*", "!very"]).unwrap(); 52 | assert!(protect.is_protected("v1.0.0")); 53 | assert!(!protect.is_protected("very")); 54 | assert!(!protect.is_protected("feature")); 55 | } 56 | 57 | #[test] 58 | fn folders() { 59 | let protect = ProtectedBranches::new(vec!["release/"]).unwrap(); 60 | assert!(!protect.is_protected("release")); 61 | assert!(protect.is_protected("release/v1.0.0")); 62 | assert!(!protect.is_protected("feature")); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/graph/commit.rs: -------------------------------------------------------------------------------- 1 | #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] 2 | pub enum Action { 3 | Pick, 4 | Fixup, 5 | Protected, 6 | } 7 | 8 | impl Action { 9 | pub fn is_pick(&self) -> bool { 10 | matches!(self, Action::Pick) 11 | } 12 | 13 | pub fn is_fixup(&self) -> bool { 14 | matches!(self, Action::Fixup) 15 | } 16 | 17 | pub fn is_protected(&self) -> bool { 18 | matches!(self, Action::Protected) 19 | } 20 | } 21 | 22 | impl Default for Action { 23 | fn default() -> Self { 24 | Self::Pick 25 | } 26 | } 27 | 28 | impl crate::any::ResourceTag for Action {} 29 | -------------------------------------------------------------------------------- /src/graph/mod.rs: -------------------------------------------------------------------------------- 1 | mod branch; 2 | mod commit; 3 | mod ops; 4 | 5 | pub use branch::*; 6 | pub use commit::*; 7 | pub use ops::*; 8 | 9 | use std::collections::BTreeMap; 10 | use std::collections::VecDeque; 11 | 12 | use crate::any::AnyId; 13 | use crate::any::BoxedEntry; 14 | use crate::any::BoxedResource; 15 | use crate::any::Resource; 16 | 17 | #[derive(Clone, Debug)] 18 | pub struct Graph { 19 | graph: petgraph::graphmap::DiGraphMap, 20 | root_id: git2::Oid, 21 | commits: BTreeMap>, 22 | pub branches: BranchSet, 23 | } 24 | 25 | impl Graph { 26 | pub fn from_branches( 27 | repo: &dyn crate::git::Repo, 28 | branches: BranchSet, 29 | ) -> crate::git::Result { 30 | let mut root_id = None; 31 | for branch_id in branches.oids() { 32 | if let Some(old_root_id) = root_id { 33 | root_id = repo.merge_base(old_root_id, branch_id); 34 | if root_id.is_none() { 35 | return Err(git2::Error::new( 36 | git2::ErrorCode::NotFound, 37 | git2::ErrorClass::Reference, 38 | format!("no merge base between {old_root_id} and {branch_id}"), 39 | )); 40 | } 41 | } else { 42 | root_id = Some(branch_id); 43 | } 44 | } 45 | let root_id = root_id.ok_or_else(|| { 46 | git2::Error::new( 47 | git2::ErrorCode::NotFound, 48 | git2::ErrorClass::Reference, 49 | "at least one branch is required to make a graph", 50 | ) 51 | })?; 52 | 53 | let mut graph = Graph::with_base_id(root_id); 54 | graph.branches = branches; 55 | for branch_id in graph.branches.oids() { 56 | for commit_id in crate::git::commit_range(repo, branch_id..root_id)? { 57 | for (weight, parent_id) in repo.parent_ids(commit_id)?.into_iter().enumerate() { 58 | graph.graph.add_edge(commit_id, parent_id, weight); 59 | } 60 | } 61 | } 62 | 63 | Ok(graph) 64 | } 65 | 66 | pub fn insert(&mut self, node: Node, parent_id: git2::Oid) { 67 | assert!( 68 | self.contains_id(parent_id), 69 | "expected to contain {parent_id}", 70 | ); 71 | let Node { 72 | id, 73 | branches, 74 | commit, 75 | } = node; 76 | self.graph.add_edge(id, parent_id, 0); 77 | for branch in branches.into_iter().flatten() { 78 | self.branches.insert(branch); 79 | } 80 | if let Some(commit) = commit { 81 | self.commits.insert(id, commit); 82 | } 83 | } 84 | 85 | pub fn rebase(&mut self, id: git2::Oid, from: git2::Oid, to: git2::Oid) { 86 | assert!(self.contains_id(id), "expected to contain {id}"); 87 | assert!(self.contains_id(from), "expected to contain {from}"); 88 | assert!(self.contains_id(to), "expected to contain {to}"); 89 | assert_eq!( 90 | self.parents_of(id).find(|parent| *parent == from), 91 | Some(from) 92 | ); 93 | assert_ne!(id, self.root_id, "Cannot rebase root ({id})"); 94 | let weight = self.graph.remove_edge(id, from).unwrap(); 95 | self.graph.add_edge(id, to, weight); 96 | } 97 | 98 | pub fn remove(&mut self, id: git2::Oid) -> Option { 99 | assert_ne!(id, self.root_id, "Cannot remove root ({id})"); 100 | let children = self.children_of(id).collect::>(); 101 | if !children.is_empty() { 102 | let parents = self.parents_of(id).collect::>(); 103 | for child_id in children.iter().copied() { 104 | for (weight, parent_id) in parents.iter().copied().enumerate() { 105 | self.graph.add_edge(child_id, parent_id, weight); 106 | } 107 | } 108 | } 109 | self.graph.remove_node(id).then(|| { 110 | let branches = self.branches.remove(id); 111 | let commit = self.commits.remove(&id); 112 | Node { 113 | id, 114 | branches, 115 | commit, 116 | } 117 | }) 118 | } 119 | } 120 | 121 | impl Graph { 122 | pub fn with_base_id(root_id: git2::Oid) -> Self { 123 | let mut graph = petgraph::graphmap::DiGraphMap::new(); 124 | graph.add_node(root_id); 125 | let commits = BTreeMap::new(); 126 | let branches = BranchSet::new(); 127 | Self { 128 | graph, 129 | root_id, 130 | commits, 131 | branches, 132 | } 133 | } 134 | 135 | pub fn root_id(&self) -> git2::Oid { 136 | self.root_id 137 | } 138 | 139 | pub fn contains_id(&self, id: git2::Oid) -> bool { 140 | self.graph.contains_node(id) 141 | } 142 | 143 | pub fn primary_parent_of(&self, root_id: git2::Oid) -> Option { 144 | self.graph 145 | .edges_directed(root_id, petgraph::Direction::Outgoing) 146 | .filter_map(|(_child, parent, weight)| (*weight == 0).then_some(parent)) 147 | .next() 148 | } 149 | 150 | pub fn parents_of( 151 | &self, 152 | root_id: git2::Oid, 153 | ) -> petgraph::graphmap::NeighborsDirected<'_, git2::Oid, petgraph::Directed> { 154 | self.graph 155 | .neighbors_directed(root_id, petgraph::Direction::Outgoing) 156 | } 157 | 158 | pub fn children_of( 159 | &self, 160 | root_id: git2::Oid, 161 | ) -> petgraph::graphmap::NeighborsDirected<'_, git2::Oid, petgraph::Directed> { 162 | self.graph 163 | .neighbors_directed(root_id, petgraph::Direction::Incoming) 164 | } 165 | 166 | pub fn primary_children_of(&self, root_id: git2::Oid) -> impl Iterator + '_ { 167 | self.graph 168 | .edges_directed(root_id, petgraph::Direction::Incoming) 169 | .filter_map(|(child, _parent, weight)| (*weight == 0).then_some(child)) 170 | } 171 | 172 | pub fn ancestors_of(&self, root_id: git2::Oid) -> AncestorsIter { 173 | let cursor = AncestorsCursor::new(self, root_id); 174 | AncestorsIter { 175 | cursor, 176 | graph: self, 177 | } 178 | } 179 | 180 | pub fn descendants(&self) -> DescendantsIter { 181 | self.descendants_of(self.root_id) 182 | } 183 | 184 | pub fn descendants_of(&self, root_id: git2::Oid) -> DescendantsIter { 185 | let cursor = DescendantsCursor::new(self, root_id); 186 | DescendantsIter { 187 | cursor, 188 | graph: self, 189 | } 190 | } 191 | 192 | pub fn commit_get(&self, id: git2::Oid) -> Option<&R> { 193 | let commit = self.commits.get(&id)?; 194 | let boxed_resource = commit.get(&AnyId::of::())?; 195 | let resource = boxed_resource.as_ref::(); 196 | Some(resource) 197 | } 198 | 199 | pub fn commit_get_mut(&mut self, id: git2::Oid) -> Option<&mut R> { 200 | let commit = self.commits.get_mut(&id)?; 201 | let boxed_resource = commit.get_mut(&AnyId::of::())?; 202 | let resource = boxed_resource.as_mut::(); 203 | Some(resource) 204 | } 205 | 206 | pub fn commit_set>(&mut self, id: git2::Oid, r: R) -> bool { 207 | let BoxedEntry { id: key, value } = r.into(); 208 | self.commits 209 | .entry(id) 210 | .or_default() 211 | .insert(key, value) 212 | .is_some() 213 | } 214 | } 215 | 216 | #[derive(Debug)] 217 | pub struct Node { 218 | id: git2::Oid, 219 | commit: Option>, 220 | branches: Option>, 221 | } 222 | 223 | impl Node { 224 | pub fn new(id: git2::Oid) -> Self { 225 | Self { 226 | id, 227 | commit: None, 228 | branches: None, 229 | } 230 | } 231 | } 232 | 233 | #[derive(Debug)] 234 | pub struct AncestorsIter<'g> { 235 | cursor: AncestorsCursor, 236 | graph: &'g Graph, 237 | } 238 | 239 | impl<'g> AncestorsIter<'g> { 240 | pub fn into_cursor(self) -> AncestorsCursor { 241 | self.cursor 242 | } 243 | } 244 | 245 | impl<'g> Iterator for AncestorsIter<'g> { 246 | type Item = git2::Oid; 247 | 248 | fn next(&mut self) -> Option { 249 | self.cursor.next(self.graph) 250 | } 251 | } 252 | 253 | #[derive(Debug)] 254 | pub struct AncestorsCursor { 255 | node_queue: VecDeque, 256 | primary_parents: bool, 257 | prior: Option, 258 | seen: std::collections::HashSet, 259 | } 260 | 261 | impl AncestorsCursor { 262 | fn new(graph: &Graph, root_id: git2::Oid) -> Self { 263 | let mut node_queue = VecDeque::new(); 264 | if graph.graph.contains_node(root_id) { 265 | node_queue.push_back(root_id); 266 | } 267 | Self { 268 | node_queue, 269 | primary_parents: false, 270 | prior: None, 271 | seen: Default::default(), 272 | } 273 | } 274 | 275 | pub fn primary_parents(mut self, yes: bool) -> Self { 276 | self.primary_parents = yes; 277 | self 278 | } 279 | } 280 | 281 | impl AncestorsCursor { 282 | pub fn next(&mut self, graph: &Graph) -> Option { 283 | if let Some(prior) = self.prior { 284 | if self.primary_parents { 285 | // Single path, no chance for duplicating paths 286 | self.node_queue.extend(graph.primary_parent_of(prior)); 287 | } else { 288 | for parent_id in graph.parents_of(prior) { 289 | if self.seen.insert(parent_id) { 290 | self.node_queue.push_back(parent_id); 291 | } 292 | } 293 | } 294 | } 295 | let next = self.node_queue.pop_front()?; 296 | self.prior = Some(next); 297 | Some(next) 298 | } 299 | 300 | pub fn stop(&mut self) { 301 | self.prior = None; 302 | } 303 | } 304 | 305 | #[derive(Debug)] 306 | pub struct DescendantsIter<'g> { 307 | cursor: DescendantsCursor, 308 | graph: &'g Graph, 309 | } 310 | 311 | impl<'g> DescendantsIter<'g> { 312 | pub fn into_cursor(self) -> DescendantsCursor { 313 | self.cursor 314 | } 315 | } 316 | 317 | impl<'g> Iterator for DescendantsIter<'g> { 318 | type Item = git2::Oid; 319 | 320 | fn next(&mut self) -> Option { 321 | self.cursor.next(self.graph) 322 | } 323 | } 324 | 325 | #[derive(Debug)] 326 | pub struct DescendantsCursor { 327 | node_queue: VecDeque, 328 | prior: Option, 329 | } 330 | 331 | impl DescendantsCursor { 332 | fn new(graph: &Graph, root_id: git2::Oid) -> Self { 333 | let mut node_queue = VecDeque::new(); 334 | if graph.graph.contains_node(root_id) { 335 | node_queue.push_back(root_id); 336 | } 337 | Self { 338 | node_queue, 339 | prior: None, 340 | } 341 | } 342 | } 343 | 344 | impl DescendantsCursor { 345 | pub fn next(&mut self, graph: &Graph) -> Option { 346 | if let Some(prior) = self.prior { 347 | self.node_queue.extend(graph.primary_children_of(prior)); 348 | } 349 | let next = self.node_queue.pop_front()?; 350 | self.prior = Some(next); 351 | Some(next) 352 | } 353 | 354 | pub fn stop(&mut self) { 355 | self.prior = None; 356 | } 357 | } 358 | -------------------------------------------------------------------------------- /src/legacy/git/branches.rs: -------------------------------------------------------------------------------- 1 | #[derive(Clone, Default, Debug, PartialEq, Eq)] 2 | pub struct Branches { 3 | branches: std::collections::BTreeMap>, 4 | } 5 | 6 | impl Branches { 7 | pub fn new(branches: impl IntoIterator) -> Self { 8 | let mut grouped_branches = std::collections::BTreeMap::new(); 9 | for branch in branches { 10 | grouped_branches 11 | .entry(branch.id) 12 | .or_insert_with(Vec::new) 13 | .push(branch); 14 | } 15 | Self { 16 | branches: grouped_branches, 17 | } 18 | } 19 | 20 | pub fn update(&mut self, repo: &dyn crate::legacy::git::Repo) { 21 | let mut new = Self::new(self.branches.values().flatten().filter_map(|b| { 22 | if let Some(remote) = b.remote.as_deref() { 23 | repo.find_remote_branch(remote, &b.name) 24 | } else { 25 | repo.find_local_branch(&b.name) 26 | } 27 | })); 28 | std::mem::swap(&mut new, self); 29 | } 30 | 31 | pub fn insert(&mut self, branch: crate::legacy::git::Branch) { 32 | let branches = self.branches.entry(branch.id).or_insert_with(Vec::new); 33 | if !branches 34 | .iter() 35 | .any(|b| b.remote == branch.remote && b.name == branch.name) 36 | { 37 | branches.push(branch); 38 | } 39 | } 40 | 41 | pub fn extend(&mut self, branches: impl Iterator) { 42 | for branch in branches { 43 | self.insert(branch); 44 | } 45 | } 46 | 47 | pub fn contains_oid(&self, oid: git2::Oid) -> bool { 48 | self.branches.contains_key(&oid) 49 | } 50 | 51 | pub fn get(&self, oid: git2::Oid) -> Option<&[crate::legacy::git::Branch]> { 52 | self.branches.get(&oid).map(|v| v.as_slice()) 53 | } 54 | 55 | pub fn remove(&mut self, oid: git2::Oid) -> Option> { 56 | self.branches.remove(&oid) 57 | } 58 | 59 | pub fn oids(&self) -> impl Iterator + '_ { 60 | self.branches.keys().copied() 61 | } 62 | 63 | pub fn iter(&self) -> impl Iterator + '_ { 64 | self.branches 65 | .iter() 66 | .map(|(oid, branch)| (*oid, branch.as_slice())) 67 | } 68 | 69 | pub fn is_empty(&self) -> bool { 70 | self.branches.is_empty() 71 | } 72 | 73 | pub fn len(&self) -> usize { 74 | self.branches.len() 75 | } 76 | 77 | pub fn all(&self) -> Self { 78 | self.clone() 79 | } 80 | 81 | pub fn descendants(&self, repo: &dyn crate::legacy::git::Repo, base_oid: git2::Oid) -> Self { 82 | let branches = self 83 | .branches 84 | .iter() 85 | .filter(|(branch_oid, branch)| { 86 | let is_base_descendant = repo 87 | .merge_base(**branch_oid, base_oid) 88 | .map(|merge_oid| merge_oid == base_oid) 89 | .unwrap_or(false); 90 | if is_base_descendant { 91 | true 92 | } else { 93 | let first_branch = &branch.first().expect("we always have at least one branch"); 94 | log::trace!( 95 | "Branch {} is not on the branch of {}", 96 | first_branch, 97 | base_oid 98 | ); 99 | false 100 | } 101 | }) 102 | .map(|(oid, branches)| { 103 | let branches: Vec<_> = branches.to_vec(); 104 | (*oid, branches) 105 | }) 106 | .collect(); 107 | Self { branches } 108 | } 109 | 110 | pub fn dependents( 111 | &self, 112 | repo: &dyn crate::legacy::git::Repo, 113 | base_oid: git2::Oid, 114 | head_oid: git2::Oid, 115 | ) -> Self { 116 | let branches = self 117 | .branches 118 | .iter() 119 | .filter(|(branch_oid, branch)| { 120 | let is_shared_base = repo 121 | .merge_base(**branch_oid, head_oid) 122 | .map(|merge_oid| merge_oid == base_oid && **branch_oid != base_oid) 123 | .unwrap_or(false); 124 | let is_base_descendant = repo 125 | .merge_base(**branch_oid, base_oid) 126 | .map(|merge_oid| merge_oid == base_oid) 127 | .unwrap_or(false); 128 | if is_shared_base { 129 | let first_branch = &branch.first().expect("we always have at least one branch"); 130 | log::trace!( 131 | "Branch {} is not on the branch of HEAD ({})", 132 | first_branch, 133 | head_oid 134 | ); 135 | false 136 | } else if !is_base_descendant { 137 | let first_branch = &branch.first().expect("we always have at least one branch"); 138 | log::trace!( 139 | "Branch {} is not on the branch of {}", 140 | first_branch, 141 | base_oid 142 | ); 143 | false 144 | } else { 145 | true 146 | } 147 | }) 148 | .map(|(oid, branches)| { 149 | let branches: Vec<_> = branches.to_vec(); 150 | (*oid, branches) 151 | }) 152 | .collect(); 153 | Self { branches } 154 | } 155 | 156 | pub fn branch( 157 | &self, 158 | repo: &dyn crate::legacy::git::Repo, 159 | base_oid: git2::Oid, 160 | head_oid: git2::Oid, 161 | ) -> Self { 162 | let branches = self 163 | .branches 164 | .iter() 165 | .filter(|(branch_oid, branch)| { 166 | let is_head_ancestor = repo 167 | .merge_base(**branch_oid, head_oid) 168 | .map(|merge_oid| **branch_oid == merge_oid) 169 | .unwrap_or(false); 170 | let is_base_descendant = repo 171 | .merge_base(**branch_oid, base_oid) 172 | .map(|merge_oid| merge_oid == base_oid) 173 | .unwrap_or(false); 174 | if !is_head_ancestor { 175 | let first_branch = &branch.first().expect("we always have at least one branch"); 176 | log::trace!( 177 | "Branch {} is not on the branch of HEAD ({})", 178 | first_branch, 179 | head_oid 180 | ); 181 | false 182 | } else if !is_base_descendant { 183 | let first_branch = &branch.first().expect("we always have at least one branch"); 184 | log::trace!( 185 | "Branch {} is not on the branch of {}", 186 | first_branch, 187 | base_oid 188 | ); 189 | false 190 | } else { 191 | true 192 | } 193 | }) 194 | .map(|(oid, branches)| { 195 | let branches: Vec<_> = branches.to_vec(); 196 | (*oid, branches) 197 | }) 198 | .collect(); 199 | Self { branches } 200 | } 201 | } 202 | 203 | impl IntoIterator for Branches { 204 | type Item = (git2::Oid, Vec); 205 | type IntoIter = 206 | std::collections::btree_map::IntoIter>; 207 | 208 | fn into_iter(self) -> Self::IntoIter { 209 | self.branches.into_iter() 210 | } 211 | } 212 | 213 | pub fn find_protected_base<'b>( 214 | repo: &dyn crate::legacy::git::Repo, 215 | protected_branches: &'b Branches, 216 | head_oid: git2::Oid, 217 | ) -> Option<&'b crate::legacy::git::Branch> { 218 | // We're being asked about a protected branch 219 | if let Some(head_branches) = protected_branches.get(head_oid) { 220 | return head_branches.first(); 221 | } 222 | 223 | let protected_base_oids = protected_branches 224 | .oids() 225 | .filter_map(|oid| { 226 | let merge_oid = repo.merge_base(head_oid, oid)?; 227 | Some((merge_oid, oid)) 228 | }) 229 | .collect::>(); 230 | 231 | // Not much choice for applicable base 232 | match protected_base_oids.len() { 233 | 0 => { 234 | return None; 235 | } 236 | 1 => { 237 | let (_, protected_oid) = protected_base_oids[0]; 238 | return protected_branches 239 | .get(protected_oid) 240 | .expect("protected_oid came from protected_branches") 241 | .first(); 242 | } 243 | _ => {} 244 | } 245 | 246 | // Prefer protected branch from first parent 247 | let mut next_oid = Some(head_oid); 248 | while let Some(parent_oid) = next_oid { 249 | if let Some((_, closest_common_oid)) = protected_base_oids 250 | .iter() 251 | .filter(|(base, _)| *base == parent_oid) 252 | .min_by_key(|(base, branch)| { 253 | ( 254 | repo.commit_count(*base, head_oid), 255 | repo.commit_count(*base, *branch), 256 | ) 257 | }) 258 | { 259 | return protected_branches 260 | .get(*closest_common_oid) 261 | .expect("protected_oid came from protected_branches") 262 | .first(); 263 | } 264 | next_oid = repo 265 | .parent_ids(parent_oid) 266 | .expect("child_oid came from verified source") 267 | .first() 268 | .copied(); 269 | } 270 | 271 | // Prefer most direct ancestors 272 | if let Some((_, closest_common_oid)) = 273 | protected_base_oids.iter().min_by_key(|(base, protected)| { 274 | let to_protected = repo.commit_count(*base, *protected); 275 | let to_head = repo.commit_count(*base, head_oid); 276 | (to_protected, to_head) 277 | }) 278 | { 279 | return protected_branches 280 | .get(*closest_common_oid) 281 | .expect("protected_oid came from protected_branches") 282 | .first(); 283 | } 284 | 285 | None 286 | } 287 | 288 | pub fn infer_base(repo: &dyn crate::legacy::git::Repo, head_oid: git2::Oid) -> Option { 289 | let head_commit = repo.find_commit(head_oid)?; 290 | let head_committer = head_commit.committer.clone(); 291 | 292 | let mut next_oid = head_oid; 293 | loop { 294 | let next_commit = repo.find_commit(next_oid)?; 295 | if next_commit.committer != head_committer { 296 | return Some(next_oid); 297 | } 298 | let parent_ids = repo.parent_ids(next_oid).ok()?; 299 | match parent_ids.len() { 300 | 1 => { 301 | next_oid = parent_ids[0]; 302 | } 303 | _ => { 304 | // Assume merge-commits are topic branches being merged into the upstream 305 | return Some(next_oid); 306 | } 307 | } 308 | } 309 | } 310 | -------------------------------------------------------------------------------- /src/legacy/git/mod.rs: -------------------------------------------------------------------------------- 1 | mod branches; 2 | mod commands; 3 | mod protect; 4 | mod repo; 5 | 6 | pub use branches::*; 7 | pub use commands::*; 8 | pub use protect::*; 9 | pub use repo::*; 10 | -------------------------------------------------------------------------------- /src/legacy/git/protect.rs: -------------------------------------------------------------------------------- 1 | #[derive(Clone, Debug)] 2 | pub struct ProtectedBranches { 3 | ignores: ignore::gitignore::Gitignore, 4 | } 5 | 6 | impl ProtectedBranches { 7 | pub fn new<'p>(patterns: impl IntoIterator) -> eyre::Result { 8 | let mut ignores = ignore::gitignore::GitignoreBuilder::new(""); 9 | for pattern in patterns { 10 | ignores.add_line(None, pattern)?; 11 | } 12 | let ignores = ignores.build()?; 13 | Ok(Self { ignores }) 14 | } 15 | 16 | pub fn is_protected(&self, name: &str) -> bool { 17 | let name_match = self.ignores.matched_path_or_any_parents(name, false); 18 | match name_match { 19 | ignore::Match::None => false, 20 | ignore::Match::Ignore(glob) => { 21 | log::trace!("{}: ignored {:?}", name, glob.original()); 22 | true 23 | } 24 | ignore::Match::Whitelist(glob) => { 25 | log::trace!("{}: allowed {:?}", name, glob.original()); 26 | false 27 | } 28 | } 29 | } 30 | } 31 | 32 | #[cfg(test)] 33 | mod test { 34 | use super::*; 35 | 36 | #[test] 37 | fn empty_allows_all() { 38 | let protect = ProtectedBranches::new(None).unwrap(); 39 | assert!(!protect.is_protected("main")); 40 | } 41 | 42 | #[test] 43 | fn protect_branch() { 44 | let protect = ProtectedBranches::new(Some("main")).unwrap(); 45 | assert!(protect.is_protected("main")); 46 | assert!(!protect.is_protected("feature")); 47 | } 48 | 49 | #[test] 50 | fn negation_patterns() { 51 | let protect = ProtectedBranches::new(vec!["v*", "!very"]).unwrap(); 52 | assert!(protect.is_protected("v1.0.0")); 53 | assert!(!protect.is_protected("very")); 54 | assert!(!protect.is_protected("feature")); 55 | } 56 | 57 | #[test] 58 | fn folders() { 59 | let protect = ProtectedBranches::new(vec!["release/"]).unwrap(); 60 | assert!(!protect.is_protected("release")); 61 | assert!(protect.is_protected("release/v1.0.0")); 62 | assert!(!protect.is_protected("feature")); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/legacy/graph/actions.rs: -------------------------------------------------------------------------------- 1 | #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] 2 | pub enum Action { 3 | Pick, 4 | Fixup, 5 | Protected, 6 | Delete, 7 | } 8 | 9 | impl Action { 10 | pub fn is_pick(&self) -> bool { 11 | matches!(self, Action::Pick) 12 | } 13 | 14 | pub fn is_fixup(&self) -> bool { 15 | matches!(self, Action::Fixup) 16 | } 17 | 18 | pub fn is_protected(&self) -> bool { 19 | matches!(self, Action::Protected) 20 | } 21 | 22 | pub fn is_delete(&self) -> bool { 23 | matches!(self, Action::Delete) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/legacy/graph/mod.rs: -------------------------------------------------------------------------------- 1 | mod actions; 2 | mod node; 3 | mod ops; 4 | 5 | pub use actions::*; 6 | pub use node::*; 7 | pub use ops::*; 8 | 9 | use std::collections::btree_map::Entry; 10 | use std::collections::BTreeMap; 11 | use std::collections::VecDeque; 12 | 13 | #[derive(Clone, Debug)] 14 | pub struct Graph { 15 | root_id: git2::Oid, 16 | nodes: BTreeMap, 17 | } 18 | 19 | impl Graph { 20 | pub fn new(node: Node) -> Self { 21 | let root_id = node.commit.id; 22 | let mut nodes = BTreeMap::new(); 23 | nodes.insert(root_id, node); 24 | Self { root_id, nodes } 25 | } 26 | 27 | pub fn from_branches( 28 | repo: &dyn crate::legacy::git::Repo, 29 | mut branches: crate::legacy::git::Branches, 30 | ) -> eyre::Result { 31 | if branches.is_empty() { 32 | eyre::bail!("No branches to graph"); 33 | } 34 | 35 | let mut branch_ids: Vec<_> = branches.oids().collect(); 36 | // Be more reproducible to make it easier to debug 37 | branch_ids.sort_by_key(|id| { 38 | let first_branch = &branches.get(*id).unwrap()[0]; 39 | (first_branch.remote.as_deref(), first_branch.name.as_str()) 40 | }); 41 | 42 | let branch_id = branch_ids.remove(0); 43 | let branch_commit = repo.find_commit(branch_id).unwrap(); 44 | let root = Node::new(branch_commit).with_branches(&mut branches); 45 | let mut graph = Self::new(root); 46 | 47 | for branch_id in branch_ids { 48 | let branch_commit = repo.find_commit(branch_id).unwrap(); 49 | let node = Node::new(branch_commit).with_branches(&mut branches); 50 | graph.insert(repo, node)?; 51 | } 52 | 53 | Ok(graph) 54 | } 55 | 56 | pub fn insert(&mut self, repo: &dyn crate::legacy::git::Repo, node: Node) -> eyre::Result<()> { 57 | let node_id = node.commit.id; 58 | if let Some(local) = self.get_mut(node_id) { 59 | local.update(node); 60 | } else { 61 | let merge_base_id = repo 62 | .merge_base(self.root_id, node_id) 63 | .ok_or_else(|| eyre::eyre!("Could not find merge base"))?; 64 | if merge_base_id != self.root_id { 65 | let root_action = self.root().action; 66 | self.populate(repo, merge_base_id, self.root_id, root_action)?; 67 | self.root_id = merge_base_id; 68 | } 69 | if merge_base_id != node_id { 70 | self.populate(repo, merge_base_id, node_id, node.action)?; 71 | } 72 | self.get_mut(node_id) 73 | .expect("populate added node_id") 74 | .update(node); 75 | } 76 | Ok(()) 77 | } 78 | 79 | pub fn extend(&mut self, repo: &dyn crate::legacy::git::Repo, other: Self) -> eyre::Result<()> { 80 | if self.get(other.root_id).is_none() { 81 | self.insert(repo, other.root().clone())?; 82 | } 83 | for node in other.nodes.into_values() { 84 | match self.nodes.entry(node.commit.id) { 85 | Entry::Occupied(mut o) => o.get_mut().update(node), 86 | Entry::Vacant(v) => { 87 | v.insert(node); 88 | } 89 | } 90 | } 91 | 92 | Ok(()) 93 | } 94 | 95 | pub fn remove_child(&mut self, parent_id: git2::Oid, child_id: git2::Oid) -> Option { 96 | let parent = self.get_mut(parent_id)?; 97 | if !parent.children.remove(&child_id) { 98 | return None; 99 | } 100 | 101 | let child = self.nodes.remove(&child_id)?; 102 | let mut node_queue = VecDeque::new(); 103 | node_queue.extend(child.children.iter().copied()); 104 | let mut removed = Self::new(child); 105 | while let Some(current_id) = node_queue.pop_front() { 106 | let current = self.nodes.remove(¤t_id).expect("all children exist"); 107 | node_queue.extend(current.children.iter().copied()); 108 | removed.nodes.insert(current_id, current); 109 | } 110 | 111 | Some(removed) 112 | } 113 | 114 | pub fn root(&self) -> &Node { 115 | self.nodes.get(&self.root_id).expect("root always exists") 116 | } 117 | 118 | pub fn root_id(&self) -> git2::Oid { 119 | self.root_id 120 | } 121 | 122 | pub fn get(&self, id: git2::Oid) -> Option<&Node> { 123 | self.nodes.get(&id) 124 | } 125 | 126 | pub fn get_mut(&mut self, id: git2::Oid) -> Option<&mut Node> { 127 | self.nodes.get_mut(&id) 128 | } 129 | 130 | pub fn breadth_first_iter(&self) -> BreadthFirstIter<'_> { 131 | BreadthFirstIter::new(self, self.root_id()) 132 | } 133 | 134 | fn populate( 135 | &mut self, 136 | repo: &dyn crate::legacy::git::Repo, 137 | base_oid: git2::Oid, 138 | head_oid: git2::Oid, 139 | default_action: crate::legacy::graph::Action, 140 | ) -> Result<(), git2::Error> { 141 | log::trace!("Populating data for {}..{}", base_oid, head_oid); 142 | debug_assert_eq!( 143 | repo.merge_base(base_oid, head_oid), 144 | Some(base_oid), 145 | "HEAD must be a descendant of base" 146 | ); 147 | 148 | let mut child_id = None; 149 | for commit_id in crate::legacy::git::commit_range(repo, head_oid..=base_oid)? { 150 | match self.nodes.entry(commit_id) { 151 | Entry::Occupied(mut o) => { 152 | let current = o.get_mut(); 153 | if let Some(child_id) = child_id { 154 | current.children.insert(child_id); 155 | // Tapped into previous entries, don't bother going further 156 | break; 157 | } 158 | // `head_oid` might already exist but none of its parents, so keep going 159 | child_id = Some(current.commit.id); 160 | } 161 | Entry::Vacant(v) => { 162 | let commit = repo 163 | .find_commit(commit_id) 164 | .expect("commit_range always returns valid ids"); 165 | let current = v.insert(Node::new(commit)); 166 | current.action = default_action; 167 | if let Some(child_id) = child_id { 168 | current.children.insert(child_id); 169 | } 170 | 171 | child_id = Some(current.commit.id); 172 | } 173 | } 174 | } 175 | 176 | Ok(()) 177 | } 178 | } 179 | 180 | pub struct BreadthFirstIter<'g> { 181 | graph: &'g Graph, 182 | node_queue: VecDeque, 183 | } 184 | 185 | impl<'g> BreadthFirstIter<'g> { 186 | pub fn new(graph: &'g Graph, root_id: git2::Oid) -> Self { 187 | let mut node_queue = VecDeque::new(); 188 | if graph.nodes.contains_key(&root_id) { 189 | node_queue.push_back(root_id); 190 | } 191 | Self { graph, node_queue } 192 | } 193 | } 194 | 195 | impl<'g> Iterator for BreadthFirstIter<'g> { 196 | type Item = &'g Node; 197 | fn next(&mut self) -> Option { 198 | let next_id = self.node_queue.pop_front()?; 199 | let next = self.graph.get(next_id)?; 200 | self.node_queue.extend(next.children.iter().copied()); 201 | Some(next) 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /src/legacy/graph/node.rs: -------------------------------------------------------------------------------- 1 | use std::collections::BTreeSet; 2 | 3 | #[derive(Clone, Debug, PartialEq, Eq)] 4 | pub struct Node { 5 | pub commit: std::rc::Rc, 6 | pub branches: Vec, 7 | pub action: crate::legacy::graph::Action, 8 | pub pushable: bool, 9 | pub children: BTreeSet, 10 | } 11 | 12 | impl Node { 13 | pub fn new(commit: std::rc::Rc) -> Self { 14 | let branches = Vec::new(); 15 | let children = BTreeSet::new(); 16 | Self { 17 | commit, 18 | branches, 19 | action: crate::legacy::graph::Action::Pick, 20 | pushable: false, 21 | children, 22 | } 23 | } 24 | 25 | pub fn with_branches(mut self, possible_branches: &mut crate::legacy::git::Branches) -> Self { 26 | self.branches = possible_branches.remove(self.commit.id).unwrap_or_default(); 27 | self 28 | } 29 | 30 | pub fn update(&mut self, mut other: Self) { 31 | assert_eq!(self.commit.id, other.commit.id); 32 | 33 | let mut branches = Vec::new(); 34 | std::mem::swap(&mut other.branches, &mut branches); 35 | self.branches.extend(branches); 36 | 37 | if other.action != crate::legacy::graph::Action::Pick { 38 | self.action = other.action; 39 | } 40 | 41 | if other.pushable { 42 | self.pushable = true; 43 | } 44 | 45 | self.children.extend(other.children); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/legacy/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod git; 2 | pub mod graph; 3 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(docsrs, feature(doc_auto_cfg))] 2 | #![allow(clippy::collapsible_else_if)] 3 | #![allow(clippy::bool_to_int_with_if)] 4 | #![allow(clippy::if_same_then_else)] 5 | 6 | #[macro_use] 7 | mod any; 8 | 9 | pub mod config; 10 | pub mod git; 11 | pub mod graph; 12 | pub mod rewrite; 13 | 14 | pub mod legacy; 15 | -------------------------------------------------------------------------------- /tests/fixtures/branches.yml: -------------------------------------------------------------------------------- 1 | init: true 2 | commands: 3 | - tree: 4 | files: 5 | "file_a.txt": "1" 6 | message: "1" 7 | - branch: initial 8 | - tree: 9 | files: 10 | "file_a.txt": "2" 11 | message: "2" 12 | - tree: 13 | files: 14 | "file_a.txt": "3" 15 | message: "3" 16 | - branch: base 17 | - label: base 18 | 19 | - reset: base 20 | - tree: 21 | files: 22 | "file_a.txt": "3" 23 | "file_b.txt": "1" 24 | message: "4" 25 | - tree: 26 | files: 27 | "file_a.txt": "3" 28 | "file_b.txt": "2" 29 | message: "5" 30 | - branch: master 31 | - tree: 32 | files: 33 | "file_a.txt": "3" 34 | "file_b.txt": "3" 35 | message: "6" 36 | - branch: off_master 37 | 38 | - reset: base 39 | - tree: 40 | files: 41 | "file_a.txt": "3" 42 | "file_c.txt": "1" 43 | message: "7" 44 | - branch: feature1 45 | - tree: 46 | files: 47 | "file_a.txt": "3" 48 | "file_c.txt": "2" 49 | message: "8" 50 | - tree: 51 | files: 52 | "file_a.txt": "3" 53 | "file_c.txt": "3" 54 | message: "9" 55 | - tree: 56 | files: 57 | "file_a.txt": "3" 58 | "file_c.txt": "4" 59 | message: "10" 60 | - branch: feature2 61 | - head: 62 | -------------------------------------------------------------------------------- /tests/fixtures/conflict.yml: -------------------------------------------------------------------------------- 1 | init: true 2 | commands: 3 | - tree: 4 | files: 5 | "file_a.txt": "1" 6 | message: "1" 7 | - branch: initial 8 | - tree: 9 | files: 10 | "file_a.txt": "2" 11 | message: "2" 12 | - tree: 13 | files: 14 | "file_a.txt": "3" 15 | message: "3" 16 | - branch: base 17 | - label: base 18 | 19 | - reset: base 20 | - tree: 21 | files: 22 | "file_a.txt": "4" 23 | message: "4" 24 | - tree: 25 | files: 26 | "file_a.txt": "5" 27 | message: "5" 28 | - branch: master 29 | 30 | - reset: base 31 | - tree: 32 | files: 33 | "file_a.txt": "6" 34 | message: "7" 35 | - branch: feature1 36 | -------------------------------------------------------------------------------- /tests/fixtures/fixup.yml: -------------------------------------------------------------------------------- 1 | init: true 2 | commands: 3 | - tree: 4 | files: 5 | "file_a.txt": "3" 6 | message: "commit 1" 7 | - branch: base 8 | - tree: 9 | files: 10 | "file_a.txt": "3" 11 | "file_b.txt": "1" 12 | message: "commit 2" 13 | - tree: 14 | files: 15 | "file_a.txt": "3" 16 | "file_b.txt": "2" 17 | message: "master commit" 18 | - branch: master 19 | - tree: 20 | files: 21 | "file_a.txt": "3" 22 | "file_b.txt": "3" 23 | message: "feature1 commit 1" 24 | - tree: 25 | files: 26 | "file_a.txt": "3" 27 | "file_b.txt": "5" 28 | message: "feature1 commit 2" 29 | - branch: feature1 30 | - tree: 31 | files: 32 | "file_a.txt": "3" 33 | "file_c.txt": "1" 34 | message: "fixup! feature1 commit 1" 35 | - tree: 36 | files: 37 | "file_a.txt": "3" 38 | "file_c.txt": "4" 39 | message: "feature1 commit 3" 40 | - tree: 41 | files: 42 | "file_a.txt": "3" 43 | "file_c.txt": "1" 44 | message: "fixup! feature1 commit 1" 45 | - tree: 46 | files: 47 | "file_a.txt": "3" 48 | "file_c.txt": "2" 49 | message: "fixup! feature1 commit 2" 50 | - tree: 51 | files: 52 | "file_a.txt": "3" 53 | "file_c.txt": "4" 54 | message: "feature2 commit" 55 | - tree: 56 | files: 57 | "file_a.txt": "3" 58 | "file_c.txt": "1" 59 | message: "fixup! feature1 commit 1" 60 | - branch: feature2 61 | -------------------------------------------------------------------------------- /tests/fixtures/git_rebase_existing.yml: -------------------------------------------------------------------------------- 1 | init: true 2 | commands: 3 | - tree: 4 | files: 5 | "file_a.txt": "1" 6 | message: "1" 7 | - branch: initial 8 | - tree: 9 | files: 10 | "file_a.txt": "2" 11 | message: "2" 12 | - tree: 13 | files: 14 | "file_a.txt": "3" 15 | message: "3" 16 | - branch: master 17 | - label: master 18 | 19 | - reset: master 20 | - tree: 21 | files: 22 | "file_a.txt": "3" 23 | "file_b.txt": "1" 24 | message: "7" 25 | - tree: 26 | files: 27 | "file_a.txt": "3" 28 | "file_b.txt": "2" 29 | message: "8" 30 | - branch: feature2 31 | 32 | # `git rebase master` caused the history to split 33 | - reset: master 34 | - tree: 35 | files: 36 | "file_a.txt": "3" 37 | "file_b.txt": "1" 38 | message: "7" 39 | - branch: feature1 40 | -------------------------------------------------------------------------------- /tests/fixtures/git_rebase_new.yml: -------------------------------------------------------------------------------- 1 | init: true 2 | commands: 3 | - tree: 4 | files: 5 | "file_a.txt": "1" 6 | message: "1" 7 | - branch: initial 8 | - tree: 9 | files: 10 | "file_a.txt": "2" 11 | message: "2" 12 | - tree: 13 | files: 14 | "file_a.txt": "3" 15 | message: "3" 16 | - branch: master 17 | - label: master 18 | 19 | - reset: master 20 | - tree: 21 | files: 22 | "file_a.txt": "3" 23 | "file_b.txt": "1" 24 | message: "7" 25 | - tree: 26 | files: 27 | "file_a.txt": "3" 28 | "file_b.txt": "1" 29 | "file_c.txt": "1" 30 | message: "8" 31 | - branch: feature2 32 | 33 | # `git rebase master` caused the history to split 34 | - reset: master 35 | - tree: 36 | files: 37 | "file_a.txt": "3" 38 | "file_b.txt": "1" 39 | message: "7" 40 | - branch: feature1 41 | -------------------------------------------------------------------------------- /tests/fixtures/pr-semi-linear-merge.yml: -------------------------------------------------------------------------------- 1 | init: true 2 | commands: 3 | - tree: 4 | files: 5 | "file_a.txt": "1" 6 | message: "1" 7 | - branch: initial 8 | - tree: 9 | files: 10 | "file_a.txt": "2" 11 | message: "2" 12 | - tree: 13 | files: 14 | "file_a.txt": "3" 15 | message: "3" 16 | - branch: base 17 | - label: base 18 | 19 | - reset: base 20 | - tree: 21 | files: 22 | "file_a.txt": "3" 23 | "file_b.txt": "1" 24 | message: "4" 25 | - tree: 26 | files: 27 | "file_a.txt": "3" 28 | "file_b.txt": "2" 29 | message: "5" 30 | - tree: 31 | files: 32 | "file_a.txt": "3" 33 | "file_b.txt": "3" 34 | message: "6" 35 | - branch: old_master 36 | # AzDO has the "semi-linear merge" type which rebases the branch before merging 37 | - tree: 38 | files: 39 | "file_a.txt": "3" 40 | "file_b.txt": "3" 41 | "file_c.txt": "1" 42 | message: "7" 43 | - tree: 44 | files: 45 | "file_a.txt": "3" 46 | "file_b.txt": "3" 47 | "file_c.txt": "2" 48 | message: "8" 49 | - tree: 50 | files: 51 | "file_a.txt": "3" 52 | "file_b.txt": "3" 53 | "file_c.txt": "3" 54 | message: "9" 55 | - tree: 56 | files: 57 | "file_a.txt": "3" 58 | "file_b.txt": "3" 59 | "file_c.txt": "4" 60 | message: "10" 61 | - branch: master 62 | 63 | - reset: base 64 | - tree: 65 | files: 66 | "file_a.txt": "3" 67 | "file_c.txt": "1" 68 | message: "7" 69 | - branch: feature1 70 | - tree: 71 | files: 72 | "file_a.txt": "3" 73 | "file_c.txt": "2" 74 | message: "8" 75 | - tree: 76 | files: 77 | "file_a.txt": "3" 78 | "file_c.txt": "3" 79 | message: "9" 80 | - tree: 81 | files: 82 | "file_a.txt": "3" 83 | "file_c.txt": "4" 84 | message: "10" 85 | - branch: feature2 86 | -------------------------------------------------------------------------------- /tests/fixtures/pr-squash.yml: -------------------------------------------------------------------------------- 1 | init: true 2 | commands: 3 | - tree: 4 | files: 5 | "file_a.txt": "1" 6 | message: "1" 7 | - branch: initial 8 | - tree: 9 | files: 10 | "file_a.txt": "2" 11 | message: "2" 12 | - tree: 13 | files: 14 | "file_a.txt": "3" 15 | message: "3" 16 | - branch: base 17 | - label: base 18 | 19 | - reset: base 20 | - tree: 21 | files: 22 | "file_a.txt": "3" 23 | "file_b.txt": "1" 24 | message: "4" 25 | - tree: 26 | files: 27 | "file_a.txt": "3" 28 | "file_b.txt": "2" 29 | message: "5" 30 | - tree: 31 | files: 32 | "file_a.txt": "3" 33 | "file_b.txt": "3" 34 | message: "6" 35 | - branch: old_master 36 | # Squashed "feature2" into a single commit and committed it onto master 37 | - tree: 38 | files: 39 | "file_a.txt": "3" 40 | "file_b.txt": "3" 41 | "file_c.txt": "4" 42 | message: "Merged #10" 43 | - branch: master 44 | 45 | - reset: base 46 | - tree: 47 | files: 48 | "file_a.txt": "3" 49 | "file_c.txt": "1" 50 | message: "7" 51 | - branch: feature1 52 | - tree: 53 | files: 54 | "file_a.txt": "3" 55 | "file_c.txt": "2" 56 | message: "8" 57 | - tree: 58 | files: 59 | "file_a.txt": "3" 60 | "file_c.txt": "3" 61 | message: "9" 62 | - tree: 63 | files: 64 | "file_a.txt": "3" 65 | "file_c.txt": "4" 66 | message: "10" 67 | - branch: feature2 68 | -------------------------------------------------------------------------------- /tests/legacy/branches.rs: -------------------------------------------------------------------------------- 1 | use git_stack::legacy::git::*; 2 | 3 | use crate::fixture; 4 | 5 | fn no_protect() -> git_stack::legacy::git::ProtectedBranches { 6 | git_stack::legacy::git::ProtectedBranches::new(vec![]).unwrap() 7 | } 8 | 9 | fn protect() -> git_stack::legacy::git::ProtectedBranches { 10 | git_stack::legacy::git::ProtectedBranches::new(vec!["master"]).unwrap() 11 | } 12 | 13 | mod test_branches { 14 | use super::*; 15 | 16 | #[test] 17 | fn test_all() { 18 | let mut repo = git_stack::legacy::git::InMemoryRepo::new(); 19 | let plan = git_fixture::TodoList::load(std::path::Path::new("tests/fixtures/branches.yml")) 20 | .unwrap(); 21 | fixture::populate_repo(&mut repo, plan); 22 | 23 | let branches = Branches::new(repo.local_branches()); 24 | let result = branches.all(); 25 | let mut names: Vec<_> = result 26 | .iter() 27 | .flat_map(|(_, b)| b.iter().map(|b| b.to_string())) 28 | .collect(); 29 | names.sort_unstable(); 30 | 31 | assert_eq!( 32 | names, 33 | [ 34 | "base", 35 | "feature1", 36 | "feature2", 37 | "initial", 38 | "master", 39 | "off_master" 40 | ] 41 | ); 42 | } 43 | 44 | #[test] 45 | fn test_descendants() { 46 | let mut repo = git_stack::legacy::git::InMemoryRepo::new(); 47 | let plan = git_fixture::TodoList::load(std::path::Path::new("tests/fixtures/branches.yml")) 48 | .unwrap(); 49 | fixture::populate_repo(&mut repo, plan); 50 | 51 | let base_oid = repo.resolve("base").unwrap().id; 52 | 53 | let branches = Branches::new(repo.local_branches()); 54 | let result = branches.descendants(&repo, base_oid); 55 | let mut names: Vec<_> = result 56 | .iter() 57 | .flat_map(|(_, b)| b.iter().map(|b| b.to_string())) 58 | .collect(); 59 | names.sort_unstable(); 60 | 61 | // Should pick up master (branches off base) 62 | assert_eq!( 63 | names, 64 | ["base", "feature1", "feature2", "master", "off_master"] 65 | ); 66 | } 67 | 68 | #[test] 69 | fn test_dependents() { 70 | let mut repo = git_stack::legacy::git::InMemoryRepo::new(); 71 | let plan = git_fixture::TodoList::load(std::path::Path::new("tests/fixtures/branches.yml")) 72 | .unwrap(); 73 | fixture::populate_repo(&mut repo, plan); 74 | 75 | let base_oid = repo.resolve("base").unwrap().id; 76 | let head_oid = repo.resolve("feature1").unwrap().id; 77 | 78 | let branches = Branches::new(repo.local_branches()); 79 | let result = branches.dependents(&repo, base_oid, head_oid); 80 | let mut names: Vec<_> = result 81 | .iter() 82 | .flat_map(|(_, b)| b.iter().map(|b| b.to_string())) 83 | .collect(); 84 | names.sort_unstable(); 85 | 86 | // Shouldn't pick up master (branches off base) 87 | assert_eq!(names, ["base", "feature1", "feature2"]); 88 | } 89 | 90 | #[test] 91 | fn test_branch() { 92 | let mut repo = git_stack::legacy::git::InMemoryRepo::new(); 93 | let plan = git_fixture::TodoList::load(std::path::Path::new("tests/fixtures/branches.yml")) 94 | .unwrap(); 95 | fixture::populate_repo(&mut repo, plan); 96 | 97 | let base_oid = repo.resolve("base").unwrap().id; 98 | let head_oid = repo.resolve("feature1").unwrap().id; 99 | 100 | let branches = Branches::new(repo.local_branches()); 101 | let result = branches.branch(&repo, base_oid, head_oid); 102 | let mut names: Vec<_> = result 103 | .iter() 104 | .flat_map(|(_, b)| b.iter().map(|b| b.to_string())) 105 | .collect(); 106 | names.sort_unstable(); 107 | 108 | // Shouldn't pick up feature1 (dependent) or master (branches off base) 109 | assert_eq!(names, ["base", "feature1"]); 110 | } 111 | } 112 | 113 | mod test_find_protected_base { 114 | use super::*; 115 | 116 | #[test] 117 | fn test_no_protected() { 118 | let mut repo = git_stack::legacy::git::InMemoryRepo::new(); 119 | let plan = git_fixture::TodoList::load(std::path::Path::new("tests/fixtures/branches.yml")) 120 | .unwrap(); 121 | fixture::populate_repo(&mut repo, plan); 122 | 123 | let protect = no_protect(); 124 | let protected = Branches::new( 125 | repo.local_branches() 126 | .filter(|b| protect.is_protected(&b.name)), 127 | ); 128 | 129 | let head_oid = repo.resolve("base").unwrap().id; 130 | 131 | let branch = find_protected_base(&repo, &protected, head_oid); 132 | assert!(branch.is_none()); 133 | } 134 | 135 | #[test] 136 | fn test_protected_branch() { 137 | let mut repo = git_stack::legacy::git::InMemoryRepo::new(); 138 | let plan = git_fixture::TodoList::load(std::path::Path::new("tests/fixtures/branches.yml")) 139 | .unwrap(); 140 | fixture::populate_repo(&mut repo, plan); 141 | 142 | let protect = protect(); 143 | let protected = Branches::new( 144 | repo.local_branches() 145 | .filter(|b| protect.is_protected(&b.name)), 146 | ); 147 | 148 | let head_oid = repo.resolve("off_master").unwrap().id; 149 | 150 | let branch = find_protected_base(&repo, &protected, head_oid); 151 | assert!(branch.is_some()); 152 | } 153 | 154 | #[test] 155 | fn test_protected_base() { 156 | let mut repo = git_stack::legacy::git::InMemoryRepo::new(); 157 | let plan = git_fixture::TodoList::load(std::path::Path::new("tests/fixtures/branches.yml")) 158 | .unwrap(); 159 | fixture::populate_repo(&mut repo, plan); 160 | 161 | let protect = protect(); 162 | let protected = Branches::new( 163 | repo.local_branches() 164 | .filter(|b| protect.is_protected(&b.name)), 165 | ); 166 | 167 | let head_oid = repo.resolve("base").unwrap().id; 168 | 169 | let branch = find_protected_base(&repo, &protected, head_oid); 170 | assert!(branch.is_some()); 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /tests/legacy/fixture.rs: -------------------------------------------------------------------------------- 1 | use bstr::ByteSlice; 2 | 3 | pub fn populate_repo( 4 | repo: &mut git_stack::legacy::git::InMemoryRepo, 5 | fixture: git_fixture::TodoList, 6 | ) { 7 | if fixture.init { 8 | repo.clear(); 9 | } 10 | 11 | let mut last_oid = None; 12 | let mut labels: std::collections::HashMap = Default::default(); 13 | for command in fixture.commands.into_iter() { 14 | match command { 15 | git_fixture::Command::Label(label) => { 16 | let current_oid = last_oid.unwrap(); 17 | labels.insert(label.clone(), current_oid); 18 | } 19 | git_fixture::Command::Reset(label) => { 20 | let current_oid = *labels.get(label.as_str()).unwrap(); 21 | last_oid = Some(current_oid); 22 | } 23 | git_fixture::Command::Tree(tree) => { 24 | let parent_id = last_oid; 25 | let commit_id = repo.gen_id(); 26 | let message = bstr::BString::from(tree.message.as_deref().unwrap_or("Automated")); 27 | let summary = message.lines().next().unwrap().to_owned(); 28 | let commit = git_stack::legacy::git::Commit { 29 | id: commit_id, 30 | tree_id: commit_id, 31 | summary: bstr::BString::from(summary), 32 | time: std::time::SystemTime::now(), 33 | author: Some(std::rc::Rc::from( 34 | tree.author.as_deref().unwrap_or("fixture"), 35 | )), 36 | committer: Some(std::rc::Rc::from( 37 | tree.author.as_deref().unwrap_or("fixture"), 38 | )), 39 | }; 40 | repo.push_commit(parent_id, commit); 41 | last_oid = Some(commit_id); 42 | } 43 | git_fixture::Command::Merge(_) => { 44 | unimplemented!("merges aren't handled atm"); 45 | } 46 | git_fixture::Command::Branch(branch) => { 47 | let current_oid = last_oid.unwrap(); 48 | let branch = git_stack::legacy::git::Branch { 49 | remote: None, 50 | name: branch.as_str().to_owned(), 51 | id: current_oid, 52 | push_id: None, 53 | pull_id: None, 54 | }; 55 | repo.mark_branch(branch); 56 | } 57 | git_fixture::Command::Tag(_) => { 58 | unimplemented!("tags aren't handled atm"); 59 | } 60 | git_fixture::Command::Head => { 61 | let current_oid = last_oid.unwrap(); 62 | repo.set_head(current_oid); 63 | } 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /tests/legacy/main.rs: -------------------------------------------------------------------------------- 1 | mod branches; 2 | mod fixture; 3 | mod graph; 4 | mod repo; 5 | -------------------------------------------------------------------------------- /tests/testsuite/alias.rs: -------------------------------------------------------------------------------- 1 | // Not correctly overriding on Windows 2 | #![cfg(target_os = "linux")] 3 | 4 | #[test] 5 | fn list_no_config() { 6 | let root = snapbox::path::PathFixture::mutable_temp().unwrap(); 7 | let root_path = root.path().unwrap(); 8 | 9 | let home_root = root_path.join("home"); 10 | std::fs::create_dir_all(&home_root).unwrap(); 11 | 12 | let repo_root = root_path.join("repo"); 13 | git2::Repository::init(&repo_root).unwrap(); 14 | 15 | snapbox::cmd::Command::new(snapbox::cmd::cargo_bin!("git-stack")) 16 | .arg("alias") 17 | .current_dir(&repo_root) 18 | .env("HOME", &home_root) 19 | .assert() 20 | .success() 21 | .stdout_eq( 22 | "\ 23 | [alias] 24 | # next = stack next # unregistered 25 | # prev = stack previous # unregistered 26 | # reword = stack reword # unregistered 27 | # amend = stack amend # unregistered 28 | # sync = stack sync # unregistered 29 | # run = stack run # unregistered 30 | ", 31 | ) 32 | .stderr_matches( 33 | "\ 34 | note: To register, pass `--register` 35 | ", 36 | ); 37 | 38 | root.close().unwrap(); 39 | } 40 | 41 | #[test] 42 | fn list_global_config() { 43 | let root = snapbox::path::PathFixture::mutable_temp().unwrap(); 44 | let root_path = root.path().unwrap(); 45 | 46 | let home_root = root_path.join("home"); 47 | std::fs::create_dir_all(&home_root).unwrap(); 48 | std::fs::write( 49 | home_root.join(".gitconfig"), 50 | " 51 | [alias] 52 | next = foo 53 | ", 54 | ) 55 | .unwrap(); 56 | 57 | let repo_root = root_path.join("repo"); 58 | git2::Repository::init(&repo_root).unwrap(); 59 | 60 | snapbox::cmd::Command::new(snapbox::cmd::cargo_bin!("git-stack")) 61 | .arg("alias") 62 | .current_dir(&repo_root) 63 | .env("HOME", &home_root) 64 | .assert() 65 | .success() 66 | .stdout_eq( 67 | "\ 68 | [alias] 69 | next = foo # instead of `stack next` 70 | # prev = stack previous # unregistered 71 | # reword = stack reword # unregistered 72 | # amend = stack amend # unregistered 73 | # sync = stack sync # unregistered 74 | # run = stack run # unregistered 75 | ", 76 | ) 77 | .stderr_matches( 78 | "\ 79 | note: To register, pass `--register` 80 | ", 81 | ); 82 | 83 | root.close().unwrap(); 84 | } 85 | 86 | #[test] 87 | fn register_no_config() { 88 | let root = snapbox::path::PathFixture::mutable_temp().unwrap(); 89 | let root_path = root.path().unwrap(); 90 | 91 | let home_root = root_path.join("home"); 92 | std::fs::create_dir_all(&home_root).unwrap(); 93 | 94 | let repo_root = root_path.join("repo"); 95 | git2::Repository::init(&repo_root).unwrap(); 96 | 97 | snapbox::cmd::Command::new(snapbox::cmd::cargo_bin!("git-stack")) 98 | .arg("alias") 99 | .arg("--register") 100 | .current_dir(&repo_root) 101 | .env("HOME", &home_root) 102 | .assert() 103 | .success() 104 | .stdout_eq( 105 | "\ 106 | ", 107 | ) 108 | .stderr_matches( 109 | r#"Registering: next="stack next" 110 | Registering: prev="stack previous" 111 | Registering: reword="stack reword" 112 | Registering: amend="stack amend" 113 | Registering: sync="stack sync" 114 | Registering: run="stack run" 115 | "#, 116 | ); 117 | 118 | snapbox::cmd::Command::new(snapbox::cmd::cargo_bin!("git-stack")) 119 | .arg("alias") 120 | .current_dir(&repo_root) 121 | .env("HOME", &home_root) 122 | .assert() 123 | .success() 124 | .stdout_eq( 125 | "\ 126 | [alias] 127 | next = stack next # registered 128 | prev = stack previous # registered 129 | reword = stack reword # registered 130 | amend = stack amend # registered 131 | sync = stack sync # registered 132 | run = stack run # registered 133 | ", 134 | ) 135 | .stderr_matches( 136 | "\ 137 | note: To unregister, pass `--unregister` 138 | ", 139 | ); 140 | 141 | root.close().unwrap(); 142 | } 143 | 144 | #[test] 145 | fn register_no_overwrite_alias() { 146 | let root = snapbox::path::PathFixture::mutable_temp().unwrap(); 147 | let root_path = root.path().unwrap(); 148 | 149 | let home_root = root_path.join("home"); 150 | std::fs::create_dir_all(&home_root).unwrap(); 151 | std::fs::write( 152 | home_root.join(".gitconfig"), 153 | " 154 | [alias] 155 | next = foo 156 | prev = stack previous -v 157 | ", 158 | ) 159 | .unwrap(); 160 | 161 | let repo_root = root_path.join("repo"); 162 | git2::Repository::init(&repo_root).unwrap(); 163 | 164 | snapbox::cmd::Command::new(snapbox::cmd::cargo_bin!("git-stack")) 165 | .arg("alias") 166 | .arg("--register") 167 | .current_dir(&repo_root) 168 | .env("HOME", &home_root) 169 | .assert() 170 | .failure() 171 | .stdout_eq( 172 | "\ 173 | ", 174 | ) 175 | .stderr_matches( 176 | r#"error: next="foo" is registered, not overwriting with "stack next" 177 | Registering: reword="stack reword" 178 | Registering: amend="stack amend" 179 | Registering: sync="stack sync" 180 | Registering: run="stack run" 181 | "#, 182 | ); 183 | 184 | snapbox::cmd::Command::new(snapbox::cmd::cargo_bin!("git-stack")) 185 | .arg("alias") 186 | .current_dir(&repo_root) 187 | .env("HOME", &home_root) 188 | .assert() 189 | .success() 190 | .stdout_eq( 191 | r#"[alias] 192 | next = foo # instead of `stack next` 193 | prev = stack previous -v # diverged from "stack previous" 194 | reword = stack reword # registered 195 | amend = stack amend # registered 196 | sync = stack sync # registered 197 | run = stack run # registered 198 | "#, 199 | ) 200 | .stderr_matches( 201 | "\ 202 | note: To unregister, pass `--unregister` 203 | ", 204 | ); 205 | 206 | root.close().unwrap(); 207 | } 208 | 209 | #[test] 210 | fn register_unregister() { 211 | let root = snapbox::path::PathFixture::mutable_temp().unwrap(); 212 | let root_path = root.path().unwrap(); 213 | 214 | let home_root = root_path.join("home"); 215 | std::fs::create_dir_all(&home_root).unwrap(); 216 | 217 | let repo_root = root_path.join("repo"); 218 | git2::Repository::init(&repo_root).unwrap(); 219 | 220 | snapbox::cmd::Command::new(snapbox::cmd::cargo_bin!("git-stack")) 221 | .arg("alias") 222 | .arg("--register") 223 | .current_dir(&repo_root) 224 | .env("HOME", &home_root) 225 | .assert() 226 | .success(); 227 | 228 | snapbox::cmd::Command::new(snapbox::cmd::cargo_bin!("git-stack")) 229 | .arg("alias") 230 | .arg("--unregister") 231 | .current_dir(&repo_root) 232 | .env("HOME", &home_root) 233 | .assert() 234 | .success() 235 | .stdout_matches("") 236 | .stderr_matches( 237 | r#"Unregistering: next="stack next" 238 | Unregistering: prev="stack previous" 239 | Unregistering: reword="stack reword" 240 | Unregistering: amend="stack amend" 241 | Unregistering: sync="stack sync" 242 | Unregistering: run="stack run" 243 | "#, 244 | ); 245 | 246 | root.close().unwrap(); 247 | } 248 | 249 | #[test] 250 | fn reregister() { 251 | let root = snapbox::path::PathFixture::mutable_temp().unwrap(); 252 | let root_path = root.path().unwrap(); 253 | 254 | let home_root = root_path.join("home"); 255 | std::fs::create_dir_all(&home_root).unwrap(); 256 | 257 | let repo_root = root_path.join("repo"); 258 | git2::Repository::init(&repo_root).unwrap(); 259 | 260 | snapbox::cmd::Command::new(snapbox::cmd::cargo_bin!("git-stack")) 261 | .arg("alias") 262 | .arg("--register") 263 | .current_dir(&repo_root) 264 | .env("HOME", &home_root) 265 | .assert() 266 | .success(); 267 | 268 | snapbox::cmd::Command::new(snapbox::cmd::cargo_bin!("git-stack")) 269 | .arg("alias") 270 | .arg("--register") 271 | .current_dir(&repo_root) 272 | .env("HOME", &home_root) 273 | .assert() 274 | .success() 275 | .stdout_eq( 276 | "\ 277 | ", 278 | ) 279 | .stderr_matches(r#""#); 280 | 281 | root.close().unwrap(); 282 | } 283 | 284 | #[test] 285 | fn unregister_no_config() { 286 | let root = snapbox::path::PathFixture::mutable_temp().unwrap(); 287 | let root_path = root.path().unwrap(); 288 | 289 | let home_root = root_path.join("home"); 290 | std::fs::create_dir_all(&home_root).unwrap(); 291 | 292 | let repo_root = root_path.join("repo"); 293 | git2::Repository::init(&repo_root).unwrap(); 294 | 295 | snapbox::cmd::Command::new(snapbox::cmd::cargo_bin!("git-stack")) 296 | .arg("alias") 297 | .arg("--unregister") 298 | .current_dir(&repo_root) 299 | .env("HOME", &home_root) 300 | .assert() 301 | .success() 302 | .stdout_eq( 303 | "\ 304 | ", 305 | ) 306 | .stderr_matches(r#""#); 307 | 308 | root.close().unwrap(); 309 | } 310 | 311 | #[test] 312 | fn unregister_existing_config() { 313 | let root = snapbox::path::PathFixture::mutable_temp().unwrap(); 314 | let root_path = root.path().unwrap(); 315 | 316 | let home_root = root_path.join("home"); 317 | std::fs::create_dir_all(&home_root).unwrap(); 318 | std::fs::write( 319 | home_root.join(".gitconfig"), 320 | " 321 | [alias] 322 | next = foo 323 | prev = stack previous -v 324 | reword = stack reword 325 | ", 326 | ) 327 | .unwrap(); 328 | 329 | let repo_root = root_path.join("repo"); 330 | git2::Repository::init(&repo_root).unwrap(); 331 | 332 | snapbox::cmd::Command::new(snapbox::cmd::cargo_bin!("git-stack")) 333 | .arg("alias") 334 | .arg("--unregister") 335 | .current_dir(&repo_root) 336 | .env("HOME", &home_root) 337 | .assert() 338 | .success() 339 | .stdout_eq( 340 | "\ 341 | ", 342 | ) 343 | .stderr_matches( 344 | r#"Unregistering: prev="stack previous -v" 345 | Unregistering: reword="stack reword" 346 | "#, 347 | ); 348 | 349 | snapbox::cmd::Command::new(snapbox::cmd::cargo_bin!("git-stack")) 350 | .arg("alias") 351 | .current_dir(&repo_root) 352 | .env("HOME", &home_root) 353 | .assert() 354 | .success() 355 | .stdout_eq( 356 | "\ 357 | [alias] 358 | next = foo # instead of `stack next` 359 | # prev = stack previous # unregistered 360 | # reword = stack reword # unregistered 361 | # amend = stack amend # unregistered 362 | # sync = stack sync # unregistered 363 | # run = stack run # unregistered 364 | ", 365 | ) 366 | .stderr_matches( 367 | "\ 368 | note: To register, pass `--register` 369 | ", 370 | ); 371 | 372 | root.close().unwrap(); 373 | } 374 | -------------------------------------------------------------------------------- /tests/testsuite/branches.rs: -------------------------------------------------------------------------------- 1 | use git_stack::graph::*; 2 | 3 | use crate::fixture; 4 | 5 | fn no_protect() -> git_stack::git::ProtectedBranches { 6 | git_stack::git::ProtectedBranches::new(vec![]).unwrap() 7 | } 8 | 9 | fn protect() -> git_stack::git::ProtectedBranches { 10 | git_stack::git::ProtectedBranches::new(vec!["master"]).unwrap() 11 | } 12 | 13 | mod test_branches { 14 | use super::*; 15 | 16 | #[test] 17 | fn test_all() { 18 | let mut repo = git_stack::git::InMemoryRepo::new(); 19 | let plan = git_fixture::TodoList::load(std::path::Path::new("tests/fixtures/branches.yml")) 20 | .unwrap(); 21 | fixture::populate_repo(&mut repo, plan); 22 | 23 | let protect = protect(); 24 | let branches = BranchSet::from_repo(&repo, &protect).unwrap(); 25 | let result = branches; 26 | let mut names: Vec<_> = result 27 | .iter() 28 | .flat_map(|(_, b)| b.iter().map(|b| b.name())) 29 | .collect(); 30 | names.sort_unstable(); 31 | 32 | assert_eq!( 33 | names, 34 | [ 35 | "base", 36 | "feature1", 37 | "feature2", 38 | "initial", 39 | "master", 40 | "off_master" 41 | ] 42 | ); 43 | } 44 | 45 | #[test] 46 | fn test_descendants() { 47 | let mut repo = git_stack::git::InMemoryRepo::new(); 48 | let plan = git_fixture::TodoList::load(std::path::Path::new("tests/fixtures/branches.yml")) 49 | .unwrap(); 50 | fixture::populate_repo(&mut repo, plan); 51 | 52 | let base_oid = repo.resolve("base").unwrap().id; 53 | 54 | let protect = protect(); 55 | let branches = BranchSet::from_repo(&repo, &protect).unwrap(); 56 | let result = branches.descendants(&repo, base_oid); 57 | let mut names: Vec<_> = result 58 | .iter() 59 | .flat_map(|(_, b)| b.iter().map(|b| b.name())) 60 | .collect(); 61 | names.sort_unstable(); 62 | 63 | // Should pick up master (branches off base) 64 | assert_eq!( 65 | names, 66 | ["base", "feature1", "feature2", "master", "off_master"] 67 | ); 68 | } 69 | 70 | #[test] 71 | fn test_dependents() { 72 | let mut repo = git_stack::git::InMemoryRepo::new(); 73 | let plan = git_fixture::TodoList::load(std::path::Path::new("tests/fixtures/branches.yml")) 74 | .unwrap(); 75 | fixture::populate_repo(&mut repo, plan); 76 | 77 | let base_oid = repo.resolve("base").unwrap().id; 78 | let head_oid = repo.resolve("feature1").unwrap().id; 79 | 80 | let protect = protect(); 81 | let branches = BranchSet::from_repo(&repo, &protect).unwrap(); 82 | let result = branches.dependents(&repo, base_oid, head_oid); 83 | let mut names: Vec<_> = result 84 | .iter() 85 | .flat_map(|(_, b)| b.iter().map(|b| b.name())) 86 | .collect(); 87 | names.sort_unstable(); 88 | 89 | // Shouldn't pick up master (branches off base) 90 | assert_eq!(names, ["base", "feature1", "feature2"]); 91 | } 92 | 93 | #[test] 94 | fn test_branch() { 95 | let mut repo = git_stack::git::InMemoryRepo::new(); 96 | let plan = git_fixture::TodoList::load(std::path::Path::new("tests/fixtures/branches.yml")) 97 | .unwrap(); 98 | fixture::populate_repo(&mut repo, plan); 99 | 100 | let base_oid = repo.resolve("base").unwrap().id; 101 | let head_oid = repo.resolve("feature1").unwrap().id; 102 | 103 | let protect = protect(); 104 | let branches = BranchSet::from_repo(&repo, &protect).unwrap(); 105 | let result = branches.branch(&repo, base_oid, head_oid); 106 | let mut names: Vec<_> = result 107 | .iter() 108 | .flat_map(|(_, b)| b.iter().map(|b| b.name())) 109 | .collect(); 110 | names.sort_unstable(); 111 | 112 | // Shouldn't pick up feature1 (dependent) or master (branches off base) 113 | assert_eq!(names, ["base", "feature1"]); 114 | } 115 | 116 | #[test] 117 | fn test_update() { 118 | let mut repo = git_stack::git::InMemoryRepo::new(); 119 | let plan = git_fixture::TodoList::load(std::path::Path::new("tests/fixtures/branches.yml")) 120 | .unwrap(); 121 | fixture::populate_repo(&mut repo, plan); 122 | 123 | let protect = protect(); 124 | let mut branches = BranchSet::from_repo(&repo, &protect).unwrap(); 125 | 126 | let mut repo = git_stack::git::InMemoryRepo::new(); 127 | let plan = git_fixture::TodoList::load(std::path::Path::new("tests/fixtures/conflict.yml")) 128 | .unwrap(); 129 | fixture::populate_repo(&mut repo, plan); 130 | branches.update(&repo).unwrap(); 131 | 132 | let mut names: Vec<_> = branches 133 | .iter() 134 | .flat_map(|(_, b)| b.iter().map(|b| (b.name(), b.kind()))) 135 | .collect(); 136 | names.sort_unstable(); 137 | 138 | assert_eq!( 139 | names, 140 | [ 141 | ("base".to_owned(), git_stack::graph::BranchKind::Mutable), 142 | ("feature1".to_owned(), git_stack::graph::BranchKind::Mutable), 143 | ("feature2".to_owned(), git_stack::graph::BranchKind::Deleted), 144 | ("initial".to_owned(), git_stack::graph::BranchKind::Mutable), 145 | ("master".to_owned(), git_stack::graph::BranchKind::Protected), 146 | ( 147 | "off_master".to_owned(), 148 | git_stack::graph::BranchKind::Deleted 149 | ), 150 | ] 151 | ); 152 | } 153 | } 154 | 155 | mod test_find_protected_base { 156 | use super::*; 157 | 158 | #[test] 159 | fn test_no_protected() { 160 | let mut repo = git_stack::git::InMemoryRepo::new(); 161 | let plan = git_fixture::TodoList::load(std::path::Path::new("tests/fixtures/branches.yml")) 162 | .unwrap(); 163 | fixture::populate_repo(&mut repo, plan); 164 | 165 | let protect = no_protect(); 166 | let branches = BranchSet::from_repo(&repo, &protect).unwrap(); 167 | 168 | let head_oid = repo.resolve("base").unwrap().id; 169 | 170 | let branch = find_protected_base(&repo, &branches, head_oid); 171 | assert!(branch.is_none()); 172 | } 173 | 174 | #[test] 175 | fn test_protected_branch() { 176 | let mut repo = git_stack::git::InMemoryRepo::new(); 177 | let plan = git_fixture::TodoList::load(std::path::Path::new("tests/fixtures/branches.yml")) 178 | .unwrap(); 179 | fixture::populate_repo(&mut repo, plan); 180 | 181 | let protect = protect(); 182 | let branches = BranchSet::from_repo(&repo, &protect).unwrap(); 183 | 184 | let head_oid = repo.resolve("off_master").unwrap().id; 185 | 186 | let branch = find_protected_base(&repo, &branches, head_oid); 187 | assert!(branch.is_some()); 188 | } 189 | 190 | #[test] 191 | fn test_protected_base() { 192 | let mut repo = git_stack::git::InMemoryRepo::new(); 193 | let plan = git_fixture::TodoList::load(std::path::Path::new("tests/fixtures/branches.yml")) 194 | .unwrap(); 195 | fixture::populate_repo(&mut repo, plan); 196 | 197 | let protect = protect(); 198 | let branches = BranchSet::from_repo(&repo, &protect).unwrap(); 199 | 200 | let head_oid = repo.resolve("base").unwrap().id; 201 | 202 | let branch = find_protected_base(&repo, &branches, head_oid); 203 | assert!(branch.is_some()); 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /tests/testsuite/fixture.rs: -------------------------------------------------------------------------------- 1 | use bstr::ByteSlice; 2 | 3 | pub fn populate_repo(repo: &mut git_stack::git::InMemoryRepo, fixture: git_fixture::TodoList) { 4 | if fixture.init { 5 | repo.clear(); 6 | } 7 | 8 | let mut last_oid = None; 9 | let mut labels: std::collections::HashMap = Default::default(); 10 | for command in fixture.commands.into_iter() { 11 | match command { 12 | git_fixture::Command::Label(label) => { 13 | let current_oid = last_oid.unwrap(); 14 | labels.insert(label.clone(), current_oid); 15 | } 16 | git_fixture::Command::Reset(label) => { 17 | let current_oid = *labels.get(label.as_str()).unwrap(); 18 | last_oid = Some(current_oid); 19 | } 20 | git_fixture::Command::Tree(tree) => { 21 | let parent_id = last_oid; 22 | let commit_id = repo.gen_id(); 23 | let message = bstr::BString::from(tree.message.as_deref().unwrap_or("Automated")); 24 | let summary = message.lines().next().unwrap().to_owned(); 25 | let commit = git_stack::git::Commit { 26 | id: commit_id, 27 | tree_id: commit_id, 28 | summary: bstr::BString::from(summary), 29 | time: std::time::SystemTime::now(), 30 | author: Some(std::rc::Rc::from( 31 | tree.author.as_deref().unwrap_or("fixture"), 32 | )), 33 | committer: Some(std::rc::Rc::from( 34 | tree.author.as_deref().unwrap_or("fixture"), 35 | )), 36 | }; 37 | repo.push_commit(parent_id, commit); 38 | last_oid = Some(commit_id); 39 | } 40 | git_fixture::Command::Merge(_) => { 41 | unimplemented!("merges aren't handled atm"); 42 | } 43 | git_fixture::Command::Branch(branch) => { 44 | let current_oid = last_oid.unwrap(); 45 | let branch = git_stack::git::Branch { 46 | remote: None, 47 | name: branch.as_str().to_owned(), 48 | id: current_oid, 49 | }; 50 | repo.mark_branch(branch); 51 | } 52 | git_fixture::Command::Tag(_) => { 53 | unimplemented!("tags aren't handled atm"); 54 | } 55 | git_fixture::Command::Head => { 56 | let current_oid = last_oid.unwrap(); 57 | repo.set_head(current_oid); 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tests/testsuite/main.rs: -------------------------------------------------------------------------------- 1 | mod alias; 2 | mod amend; 3 | mod branches; 4 | mod fixture; 5 | mod graph; 6 | mod ops; 7 | mod repo; 8 | mod reword; 9 | -------------------------------------------------------------------------------- /tests/testsuite/reword.rs: -------------------------------------------------------------------------------- 1 | use bstr::ByteSlice; 2 | 3 | #[test] 4 | fn reword_protected_fails() { 5 | let root = snapbox::path::PathFixture::mutable_temp().unwrap(); 6 | let root_path = root.path().unwrap(); 7 | let plan = git_fixture::TodoList { 8 | commands: vec![ 9 | git_fixture::Command::Tree(git_fixture::Tree { 10 | files: [("a", "a")] 11 | .into_iter() 12 | .map(|(p, c)| (p.into(), c.into())) 13 | .collect::>(), 14 | message: Some("A".to_owned()), 15 | author: None, 16 | }), 17 | git_fixture::Command::Branch("main".into()), 18 | ], 19 | ..Default::default() 20 | }; 21 | plan.run(root_path).unwrap(); 22 | 23 | let repo = git2::Repository::discover(root_path).unwrap(); 24 | let repo = git_stack::git::GitRepo::new(repo); 25 | 26 | let old_head_id = repo.head_commit().id; 27 | 28 | snapbox::cmd::Command::new(snapbox::cmd::cargo_bin!("git-stack")) 29 | .arg("reword") 30 | .arg("--message=hahahaha") 31 | .current_dir(root_path) 32 | .assert() 33 | .failure() 34 | .stdout_eq( 35 | "\ 36 | ", 37 | ) 38 | .stderr_eq( 39 | "\ 40 | cannot reword protected commits 41 | ", 42 | ); 43 | 44 | let new_head_id = repo.head_commit().id; 45 | assert_eq!(old_head_id, new_head_id); 46 | } 47 | 48 | #[test] 49 | fn reword_implicit_head() { 50 | let root = snapbox::path::PathFixture::mutable_temp().unwrap(); 51 | let root_path = root.path().unwrap(); 52 | let plan = git_fixture::TodoList { 53 | commands: vec![ 54 | git_fixture::Command::Tree(git_fixture::Tree { 55 | files: [("a", "a")] 56 | .into_iter() 57 | .map(|(p, c)| (p.into(), c.into())) 58 | .collect::>(), 59 | message: Some("A".to_owned()), 60 | author: None, 61 | }), 62 | git_fixture::Command::Branch("main".into()), 63 | git_fixture::Command::Tree(git_fixture::Tree { 64 | files: [("a", "a"), ("b", "b")] 65 | .into_iter() 66 | .map(|(p, c)| (p.into(), c.into())) 67 | .collect::>(), 68 | message: Some("B".to_owned()), 69 | author: None, 70 | }), 71 | git_fixture::Command::Tree(git_fixture::Tree { 72 | files: [("a", "a"), ("b", "b"), ("c", "c")] 73 | .into_iter() 74 | .map(|(p, c)| (p.into(), c.into())) 75 | .collect::>(), 76 | message: Some("C".to_owned()), 77 | author: None, 78 | }), 79 | git_fixture::Command::Branch("target".into()), 80 | ], 81 | ..Default::default() 82 | }; 83 | plan.run(root_path).unwrap(); 84 | 85 | let repo = git2::Repository::discover(root_path).unwrap(); 86 | let repo = git_stack::git::GitRepo::new(repo); 87 | 88 | let branch = repo.find_local_branch("target").unwrap(); 89 | let commit = repo.find_commit(branch.id).unwrap(); 90 | snapbox::assert_eq(commit.summary.to_str().unwrap(), "C"); 91 | 92 | let old_head_id = repo.head_commit().id; 93 | 94 | snapbox::cmd::Command::new(snapbox::cmd::cargo_bin!("git-stack")) 95 | .arg("reword") 96 | .arg("--message=new C") 97 | .current_dir(root_path) 98 | .assert() 99 | .success() 100 | .stdout_eq( 101 | "\ 102 | ", 103 | ) 104 | .stderr_eq( 105 | "\ 106 | note: to undo, run `git branch-stash pop git-stack` 107 | ", 108 | ); 109 | 110 | let branch = repo.find_local_branch("target").unwrap(); 111 | let commit = repo.find_commit(branch.id).unwrap(); 112 | snapbox::assert_eq(commit.summary.to_str().unwrap(), "new C"); 113 | 114 | let new_head_id = repo.head_commit().id; 115 | assert_ne!(old_head_id, new_head_id); 116 | 117 | root.close().unwrap(); 118 | } 119 | 120 | #[test] 121 | fn reword_explicit_head() { 122 | let root = snapbox::path::PathFixture::mutable_temp().unwrap(); 123 | let root_path = root.path().unwrap(); 124 | let plan = git_fixture::TodoList { 125 | commands: vec![ 126 | git_fixture::Command::Tree(git_fixture::Tree { 127 | files: [("a", "a")] 128 | .into_iter() 129 | .map(|(p, c)| (p.into(), c.into())) 130 | .collect::>(), 131 | message: Some("A".to_owned()), 132 | author: None, 133 | }), 134 | git_fixture::Command::Branch("main".into()), 135 | git_fixture::Command::Tree(git_fixture::Tree { 136 | files: [("a", "a"), ("b", "b")] 137 | .into_iter() 138 | .map(|(p, c)| (p.into(), c.into())) 139 | .collect::>(), 140 | message: Some("B".to_owned()), 141 | author: None, 142 | }), 143 | git_fixture::Command::Tree(git_fixture::Tree { 144 | files: [("a", "a"), ("b", "b"), ("c", "c")] 145 | .into_iter() 146 | .map(|(p, c)| (p.into(), c.into())) 147 | .collect::>(), 148 | message: Some("C".to_owned()), 149 | author: None, 150 | }), 151 | git_fixture::Command::Branch("target".into()), 152 | ], 153 | ..Default::default() 154 | }; 155 | plan.run(root_path).unwrap(); 156 | 157 | let repo = git2::Repository::discover(root_path).unwrap(); 158 | let repo = git_stack::git::GitRepo::new(repo); 159 | 160 | let branch = repo.find_local_branch("target").unwrap(); 161 | let commit = repo.find_commit(branch.id).unwrap(); 162 | snapbox::assert_eq(commit.summary.to_str().unwrap(), "C"); 163 | 164 | let old_head_id = repo.head_commit().id; 165 | 166 | snapbox::cmd::Command::new(snapbox::cmd::cargo_bin!("git-stack")) 167 | .arg("reword") 168 | .arg("--message=new C") 169 | .arg("HEAD") 170 | .current_dir(root_path) 171 | .assert() 172 | .success() 173 | .stdout_eq( 174 | "\ 175 | ", 176 | ) 177 | .stderr_eq( 178 | "\ 179 | note: to undo, run `git branch-stash pop git-stack` 180 | ", 181 | ); 182 | 183 | let branch = repo.find_local_branch("target").unwrap(); 184 | let commit = repo.find_commit(branch.id).unwrap(); 185 | snapbox::assert_eq(commit.summary.to_str().unwrap(), "new C"); 186 | 187 | let new_head_id = repo.head_commit().id; 188 | assert_ne!(old_head_id, new_head_id); 189 | 190 | root.close().unwrap(); 191 | } 192 | 193 | #[test] 194 | fn reword_branch() { 195 | let root = snapbox::path::PathFixture::mutable_temp().unwrap(); 196 | let root_path = root.path().unwrap(); 197 | let plan = git_fixture::TodoList { 198 | commands: vec![ 199 | git_fixture::Command::Tree(git_fixture::Tree { 200 | files: [("a", "a")] 201 | .into_iter() 202 | .map(|(p, c)| (p.into(), c.into())) 203 | .collect::>(), 204 | message: Some("A".to_owned()), 205 | author: None, 206 | }), 207 | git_fixture::Command::Branch("main".into()), 208 | git_fixture::Command::Tree(git_fixture::Tree { 209 | files: [("a", "a"), ("b", "b")] 210 | .into_iter() 211 | .map(|(p, c)| (p.into(), c.into())) 212 | .collect::>(), 213 | message: Some("B".to_owned()), 214 | author: None, 215 | }), 216 | git_fixture::Command::Branch("target".into()), 217 | git_fixture::Command::Tree(git_fixture::Tree { 218 | files: [("a", "a"), ("b", "b"), ("c", "c")] 219 | .into_iter() 220 | .map(|(p, c)| (p.into(), c.into())) 221 | .collect::>(), 222 | message: Some("C".to_owned()), 223 | author: None, 224 | }), 225 | git_fixture::Command::Branch("local".into()), 226 | ], 227 | ..Default::default() 228 | }; 229 | plan.run(root_path).unwrap(); 230 | 231 | let repo = git2::Repository::discover(root_path).unwrap(); 232 | let repo = git_stack::git::GitRepo::new(repo); 233 | 234 | let branch = repo.find_local_branch("target").unwrap(); 235 | let commit = repo.find_commit(branch.id).unwrap(); 236 | snapbox::assert_eq(commit.summary.to_str().unwrap(), "B"); 237 | 238 | let old_head_id = repo.head_commit().id; 239 | 240 | std::fs::write(root_path.join("a"), "unstaged a").unwrap(); 241 | 242 | snapbox::cmd::Command::new(snapbox::cmd::cargo_bin!("git-stack")) 243 | .arg("reword") 244 | .arg("--message=new B") 245 | .arg("target") 246 | .current_dir(root_path) 247 | .assert() 248 | .success() 249 | .stdout_eq( 250 | "\ 251 | ", 252 | ) 253 | .stderr_matches( 254 | "\ 255 | Saved working directory and index state WIP on local (reword): [..] 256 | Dropped refs/stash [..] 257 | note: to undo, run `git branch-stash pop git-stack` 258 | ", 259 | ); 260 | 261 | let branch = repo.find_local_branch("target").unwrap(); 262 | let commit = repo.find_commit(branch.id).unwrap(); 263 | snapbox::assert_eq(commit.summary.to_str().unwrap(), "new B"); 264 | 265 | let local_branch = repo.find_local_branch("local").unwrap(); 266 | let local_commit = repo.find_commit(local_branch.id).unwrap(); 267 | snapbox::assert_eq(local_commit.summary.to_str_lossy().into_owned(), "C"); 268 | 269 | let new_head_id = repo.head_commit().id; 270 | assert_ne!(old_head_id, new_head_id); 271 | 272 | snapbox::assert_eq(std::fs::read(root_path.join("a")).unwrap(), "unstaged a"); 273 | 274 | root.close().unwrap(); 275 | } 276 | --------------------------------------------------------------------------------