Merge branch 'master' into feature/more-space-view-diff

This commit is contained in:
Nguyễn Hồng Quân 2026-05-20 00:02:46 +07:00
commit 766f160f84
90 changed files with 3965 additions and 1210 deletions

View file

@ -1,2 +1,2 @@
msrv = "1.82.0"
msrv = "1.88.0"
cognitive-complexity-threshold = 18

View file

@ -2,10 +2,13 @@ name: CD
on:
push:
tags:
- '*'
tags:
- "*"
workflow_dispatch:
permissions:
contents: write
jobs:
release:
strategy:
@ -15,116 +18,117 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
- name: Get version
id: get_version
run: echo "version=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_OUTPUT
- name: Get version
id: get_version
run: echo "version=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_OUTPUT
- name: Restore cargo cache
uses: Swatinem/rust-cache@v2
env:
cache-name: ci
with:
shared-key: ${{ matrix.os }}-${{ env.cache-name }}-stable
- name: Restore cargo cache
uses: Swatinem/rust-cache@v2
env:
cache-name: ci
with:
shared-key: ${{ matrix.os }}-${{ env.cache-name }}-stable
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- name: Build
if: matrix.os != 'ubuntu-22.04'
env:
GITUI_RELEASE: 1
run: cargo build
- name: Run tests
if: matrix.os != 'ubuntu-22.04'
run: make test
- name: Run clippy
if: matrix.os != 'ubuntu-22.04'
run: |
cargo clean
make clippy
- uses: taiki-e/install-action@nextest
- name: Setup MUSL
if: matrix.os == 'ubuntu-latest'
run: |
rustup target add x86_64-unknown-linux-musl
sudo apt-get -qq install musl-tools
- name: Build
if: matrix.os != 'ubuntu-22.04'
env:
GITUI_RELEASE: 1
run: cargo build
- name: Run tests
if: matrix.os != 'ubuntu-22.04'
run: make test
- name: Run clippy
if: matrix.os != 'ubuntu-22.04'
run: |
cargo clean
make clippy
- name: Setup ARM toolchain
if: matrix.os == 'ubuntu-22.04'
run: |
rustup target add aarch64-unknown-linux-gnu
rustup target add armv7-unknown-linux-gnueabihf
rustup target add arm-unknown-linux-gnueabihf
- name: Setup MUSL
if: matrix.os == 'ubuntu-latest'
run: |
rustup target add x86_64-unknown-linux-musl
sudo apt-get -qq install musl-tools
curl -o $GITHUB_WORKSPACE/aarch64.tar.xz https://armkeil.blob.core.windows.net/developer/Files/downloads/gnu-a/8.2-2018.08/gcc-arm-8.2-2018.08-x86_64-aarch64-linux-gnu.tar.xz
curl -o $GITHUB_WORKSPACE/arm.tar.xz https://armkeil.blob.core.windows.net/developer/Files/downloads/gnu-a/8.2-2018.08/gcc-arm-8.2-2018.08-x86_64-arm-linux-gnueabihf.tar.xz
- name: Setup ARM toolchain
if: matrix.os == 'ubuntu-22.04'
run: |
rustup target add aarch64-unknown-linux-gnu
rustup target add armv7-unknown-linux-gnueabihf
rustup target add arm-unknown-linux-gnueabihf
tar xf $GITHUB_WORKSPACE/aarch64.tar.xz
tar xf $GITHUB_WORKSPACE/arm.tar.xz
curl -o $GITHUB_WORKSPACE/aarch64.tar.xz https://armkeil.blob.core.windows.net/developer/Files/downloads/gnu-a/8.2-2018.08/gcc-arm-8.2-2018.08-x86_64-aarch64-linux-gnu.tar.xz
curl -o $GITHUB_WORKSPACE/arm.tar.xz https://armkeil.blob.core.windows.net/developer/Files/downloads/gnu-a/8.2-2018.08/gcc-arm-8.2-2018.08-x86_64-arm-linux-gnueabihf.tar.xz
echo "$GITHUB_WORKSPACE/gcc-arm-8.2-2018.08-x86_64-aarch64-linux-gnu/bin" >> $GITHUB_PATH
echo "$GITHUB_WORKSPACE/gcc-arm-8.2-2018.08-x86_64-arm-linux-gnueabihf/bin" >> $GITHUB_PATH
tar xf $GITHUB_WORKSPACE/aarch64.tar.xz
tar xf $GITHUB_WORKSPACE/arm.tar.xz
- name: Build Release Mac
if: matrix.os == 'macos-latest'
env:
GITUI_RELEASE: 1
run: make release-mac
- name: Build Release Mac x86
if: matrix.os == 'macos-latest'
env:
GITUI_RELEASE: 1
run: |
rustup target add x86_64-apple-darwin
make release-mac-x86
- name: Build Release Linux
if: matrix.os == 'ubuntu-latest'
env:
GITUI_RELEASE: 1
run: make release-linux-musl
- name: Build Release Win
if: matrix.os == 'windows-latest'
env:
GITUI_RELEASE: 1
run: make release-win
- name: Build Release Linux ARM
if: matrix.os == 'ubuntu-22.04'
env:
GITUI_RELEASE: 1
run: make release-linux-arm
echo "$GITHUB_WORKSPACE/gcc-arm-8.2-2018.08-x86_64-aarch64-linux-gnu/bin" >> $GITHUB_PATH
echo "$GITHUB_WORKSPACE/gcc-arm-8.2-2018.08-x86_64-arm-linux-gnueabihf/bin" >> $GITHUB_PATH
- name: Set SHA
if: matrix.os == 'macos-latest'
id: shasum
run: |
echo sha="$(shasum -a 256 ./release/gitui-mac.tar.gz | awk '{printf $1}')" >> $GITHUB_OUTPUT
- name: Build Release Mac
if: matrix.os == 'macos-latest'
env:
GITUI_RELEASE: 1
run: make release-mac
- name: Build Release Mac x86
if: matrix.os == 'macos-latest'
env:
GITUI_RELEASE: 1
run: |
rustup target add x86_64-apple-darwin
make release-mac-x86
- name: Build Release Linux
if: matrix.os == 'ubuntu-latest'
env:
GITUI_RELEASE: 1
run: make release-linux-musl
- name: Build Release Win
if: matrix.os == 'windows-latest'
env:
GITUI_RELEASE: 1
run: make release-win
- name: Build Release Linux ARM
if: matrix.os == 'ubuntu-22.04'
env:
GITUI_RELEASE: 1
run: make release-linux-arm
- name: Extract release notes
if: matrix.os == 'ubuntu-latest'
id: release_notes
uses: ffurrer2/extract-release-notes@v2
- name: Release
uses: softprops/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
body: ${{ steps.release_notes.outputs.release_notes }}
prerelease: ${{ contains(github.ref, '-') }}
files: |
./release/*.tar.gz
./release/*.zip
./release/*.msi
- name: Set SHA
if: matrix.os == 'macos-latest'
id: shasum
run: |
echo sha="$(shasum -a 256 ./release/gitui-mac.tar.gz | awk '{printf $1}')" >> $GITHUB_OUTPUT
- name: Bump homebrew-core formula
uses: mislav/bump-homebrew-formula-action@v3
if: "matrix.os == 'macos-latest' && !contains(github.ref, '-')" # skip prereleases
env:
COMMITTER_TOKEN: ${{ secrets.BREW_TOKEN }}
with:
formula-name: gitui
# https://github.com/mislav/bump-homebrew-formula-action/issues/58
formula-path: Formula/g/gitui.rb
- name: Extract release notes
if: matrix.os == 'ubuntu-latest'
id: release_notes
uses: ffurrer2/extract-release-notes@v2
- name: Release
uses: softprops/action-gh-release@v2
with:
body: ${{ steps.release_notes.outputs.release_notes }}
prerelease: ${{ contains(github.ref, '-') }}
files: |
./release/*.tar.gz
./release/*.zip
./release/*.msi
- name: Bump homebrew-core formula
uses: mislav/bump-homebrew-formula-action@v3
if: "matrix.os == 'macos-latest' && !contains(github.ref, '-')" # skip prereleases
env:
COMMITTER_TOKEN: ${{ secrets.BREW_TOKEN }}
with:
formula-name: gitui
# https://github.com/mislav/bump-homebrew-formula-action/issues/58
formula-path: Formula/g/gitui.rb

View file

@ -17,7 +17,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
rust: [nightly, stable, "1.82"]
rust: [nightly, stable, "1.88"]
runs-on: ${{ matrix.os }}
continue-on-error: ${{ matrix.rust == 'nightly' }}
@ -47,6 +47,8 @@ jobs:
- name: Rustup Show
run: rustup show
- uses: taiki-e/install-action@nextest
- name: Build Debug
run: |
cargo build
@ -92,7 +94,7 @@ jobs:
strategy:
fail-fast: false
matrix:
rust: [nightly, stable, "1.82"]
rust: [nightly, stable, "1.88"]
continue-on-error: ${{ matrix.rust == 'nightly' }}
steps:
- uses: actions/checkout@v4
@ -121,6 +123,8 @@ jobs:
- name: Rustup Show
run: rustup show
- uses: taiki-e/install-action@nextest
- name: Setup MUSL
run: |
sudo apt-get -qq install musl-tools
@ -144,7 +148,7 @@ jobs:
strategy:
fail-fast: false
matrix:
rust: [nightly, stable, "1.82"]
rust: [nightly, stable, "1.88"]
continue-on-error: ${{ matrix.rust == 'nightly' }}
steps:
- uses: actions/checkout@v4
@ -195,7 +199,7 @@ jobs:
strategy:
fail-fast: false
matrix:
rust: [nightly, stable, "1.82"]
rust: [nightly, stable, "1.88"]
continue-on-error: ${{ matrix.rust == 'nightly' }}
steps:
- uses: actions/checkout@v4
@ -249,10 +253,16 @@ jobs:
- run: cargo fmt -- --check
- name: cargo-sort
- name: tombi install
uses: tombi-toml/setup-tombi@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
version: '0.9.0'
- name: tombi check
run: |
cargo install cargo-sort --force
cargo sort -c -w
tombi format --check
- name: cargo-deny install
run: |
@ -303,26 +313,13 @@ jobs:
name: Test Homebrew Formula (macOS)
runs-on: macos-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Homebrew
uses: Homebrew/actions/setup-homebrew@master
- name: Install stable Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
- name: Install Homebrew
run: /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
- name: Set up Homebrew in PATH
run: |
echo "$HOMEBREW_PREFIX/bin:$HOMEBREW_PREFIX/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" >> $GITHUB_PATH
- name: Update Homebrew
run: brew update
- name: Let Homebrew build gitui from source
run: brew install --head --build-from-source gitui
- name: Run Homebrew test
run: brew test gitui
run: brew install --build-from-source gitui

View file

@ -2,7 +2,7 @@ name: Build Nightly Releases
on:
schedule:
- cron: '0 3 * * *'
- cron: "0 3 * * *"
workflow_dispatch:
env:
@ -14,112 +14,112 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [
ubuntu-latest, macos-latest, windows-latest, ubuntu-22.04
]
os: [ubuntu-latest, macos-latest, windows-latest, ubuntu-22.04]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
- name: Restore cargo cache
uses: Swatinem/rust-cache@v2
env:
cache-name: ci
with:
shared-key: ${{ matrix.os }}-${{ env.cache-name }}-stable
- name: Restore cargo cache
uses: Swatinem/rust-cache@v2
env:
cache-name: ci
with:
shared-key: ${{ matrix.os }}-${{ env.cache-name }}-stable
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
components: clippy
# ideally we trigger the nightly build/deploy only if the normal nightly CI finished successfully
- name: Run tests
if: matrix.os != 'ubuntu-22.04'
run: make test
- name: Run clippy
if: matrix.os != 'ubuntu-22.04'
run: |
cargo clean
make clippy
- uses: taiki-e/install-action@nextest
- name: Setup MUSL
if: matrix.os == 'ubuntu-latest'
run: |
rustup target add x86_64-unknown-linux-musl
sudo apt-get -qq install musl-tools
# ideally we trigger the nightly build/deploy only if the normal nightly CI finished successfully
- name: Run tests
if: matrix.os != 'ubuntu-22.04'
run: make test
- name: Run clippy
if: matrix.os != 'ubuntu-22.04'
run: |
cargo clean
make clippy
- name: Setup ARM toolchain
if: matrix.os == 'ubuntu-22.04'
run: |
rustup target add aarch64-unknown-linux-gnu
rustup target add armv7-unknown-linux-gnueabihf
rustup target add arm-unknown-linux-gnueabihf
- name: Setup MUSL
if: matrix.os == 'ubuntu-latest'
run: |
rustup target add x86_64-unknown-linux-musl
sudo apt-get -qq install musl-tools
curl -o $GITHUB_WORKSPACE/aarch64.tar.xz https://armkeil.blob.core.windows.net/developer/Files/downloads/gnu-a/8.2-2018.08/gcc-arm-8.2-2018.08-x86_64-aarch64-linux-gnu.tar.xz
curl -o $GITHUB_WORKSPACE/arm.tar.xz https://armkeil.blob.core.windows.net/developer/Files/downloads/gnu-a/8.2-2018.08/gcc-arm-8.2-2018.08-x86_64-arm-linux-gnueabihf.tar.xz
- name: Setup ARM toolchain
if: matrix.os == 'ubuntu-22.04'
run: |
rustup target add aarch64-unknown-linux-gnu
rustup target add armv7-unknown-linux-gnueabihf
rustup target add arm-unknown-linux-gnueabihf
tar xf $GITHUB_WORKSPACE/aarch64.tar.xz
tar xf $GITHUB_WORKSPACE/arm.tar.xz
curl -o $GITHUB_WORKSPACE/aarch64.tar.xz https://armkeil.blob.core.windows.net/developer/Files/downloads/gnu-a/8.2-2018.08/gcc-arm-8.2-2018.08-x86_64-aarch64-linux-gnu.tar.xz
curl -o $GITHUB_WORKSPACE/arm.tar.xz https://armkeil.blob.core.windows.net/developer/Files/downloads/gnu-a/8.2-2018.08/gcc-arm-8.2-2018.08-x86_64-arm-linux-gnueabihf.tar.xz
echo "$GITHUB_WORKSPACE/gcc-arm-8.2-2018.08-x86_64-aarch64-linux-gnu/bin" >> $GITHUB_PATH
echo "$GITHUB_WORKSPACE/gcc-arm-8.2-2018.08-x86_64-arm-linux-gnueabihf/bin" >> $GITHUB_PATH
tar xf $GITHUB_WORKSPACE/aarch64.tar.xz
tar xf $GITHUB_WORKSPACE/arm.tar.xz
- name: Build Release Mac
if: matrix.os == 'macos-latest'
run: make release-mac
- name: Build Release Mac x86
if: matrix.os == 'macos-latest'
run: |
rustup target add x86_64-apple-darwin
make release-mac-x86
- name: Build Release Linux
if: matrix.os == 'ubuntu-latest'
run: make release-linux-musl
- name: Build Release Win
if: matrix.os == 'windows-latest'
run: make release-win
- name: Build Release Linux ARM
if: matrix.os == 'ubuntu-22.04'
run: make release-linux-arm
echo "$GITHUB_WORKSPACE/gcc-arm-8.2-2018.08-x86_64-aarch64-linux-gnu/bin" >> $GITHUB_PATH
echo "$GITHUB_WORKSPACE/gcc-arm-8.2-2018.08-x86_64-arm-linux-gnueabihf/bin" >> $GITHUB_PATH
- name: Ubuntu 22.04 Upload Artifact
if: matrix.os == 'ubuntu-22.04'
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_KEY_SECRET }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
run: |
aws s3 cp ./release/gitui-linux-armv7.tar.gz $AWS_BUCKET_NAME
aws s3 cp ./release/gitui-linux-arm.tar.gz $AWS_BUCKET_NAME
aws s3 cp ./release/gitui-linux-aarch64.tar.gz $AWS_BUCKET_NAME
- name: Build Release Mac
if: matrix.os == 'macos-latest'
run: make release-mac
- name: Build Release Mac x86
if: matrix.os == 'macos-latest'
run: |
rustup target add x86_64-apple-darwin
make release-mac-x86
- name: Build Release Linux
if: matrix.os == 'ubuntu-latest'
run: make release-linux-musl
- name: Build Release Win
if: matrix.os == 'windows-latest'
run: make release-win
- name: Build Release Linux ARM
if: matrix.os == 'ubuntu-22.04'
run: make release-linux-arm
- name: Ubuntu Latest Upload Artifact
if: matrix.os == 'ubuntu-latest'
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_KEY_SECRET }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
run: |
aws s3 cp ./release/gitui-linux-x86_64.tar.gz $AWS_BUCKET_NAME
- name: Ubuntu 22.04 Upload Artifact
if: matrix.os == 'ubuntu-22.04'
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_KEY_SECRET }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
run: |
aws s3 cp ./release/gitui-linux-armv7.tar.gz $AWS_BUCKET_NAME
aws s3 cp ./release/gitui-linux-arm.tar.gz $AWS_BUCKET_NAME
aws s3 cp ./release/gitui-linux-aarch64.tar.gz $AWS_BUCKET_NAME
- name: MacOS Upload Artifact
if: matrix.os == 'macos-latest'
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_KEY_SECRET }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
run: |
aws s3 cp ./release/gitui-mac.tar.gz $AWS_BUCKET_NAME
aws s3 cp ./release/gitui-mac-x86.tar.gz $AWS_BUCKET_NAME
- name: Ubuntu Latest Upload Artifact
if: matrix.os == 'ubuntu-latest'
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_KEY_SECRET }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
run: |
aws s3 cp ./release/gitui-linux-x86_64.tar.gz $AWS_BUCKET_NAME
- name: Windows Upload Artifact
if: matrix.os == 'windows-latest'
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_KEY_SECRET }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
run: |
aws s3 cp ./release/gitui-win.msi $env:AWS_BUCKET_NAME
aws s3 cp ./release/gitui-win.tar.gz $env:AWS_BUCKET_NAME
- name: MacOS Upload Artifact
if: matrix.os == 'macos-latest'
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_KEY_SECRET }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
run: |
aws s3 cp ./release/gitui-mac.tar.gz $AWS_BUCKET_NAME
aws s3 cp ./release/gitui-mac-x86.tar.gz $AWS_BUCKET_NAME
- name: Windows Upload Artifact
if: matrix.os == 'windows-latest'
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_KEY_SECRET }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
run: |
aws s3 cp ./release/gitui-win.msi $env:AWS_BUCKET_NAME
aws s3 cp ./release/gitui-win.tar.gz $env:AWS_BUCKET_NAME

View file

@ -6,40 +6,73 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
* execute git-hooks directly if possible (on *nix) else use sh instead of bash (without reading SHELL variable) [[@Joshix](https://github.com/Joshix-1)] ([#2483](https://github.com/extrawurst/gitui/pull/2483))
* increase MSRV from 1.81 to 1.82 [[@cruessler](https://github.com/cruessler)]
### Changed
* Give more space to right-side diff view [[@hongquan](https://github.com/hongquan)] ([#2772](https://github.com/gitui-org/gitui/pull/2772))
* use [tombi](https://github.com/tombi-toml/tombi) for all toml file formatting
* open the external editor from the status diff view [[@WaterWhisperer](https://github.com/WaterWhisperer)] ([#2805](https://github.com/gitui-org/gitui/issues/2805))
### Fixes
* crash when opening submodule ([#2895](https://github.com/gitui-org/gitui/issues/2895))
* when staging the last file in a directory, the first item after the directory is no longer skipped [[@Tillerino](https://github.com/Tillerino)] ([#2748](https://github.com/gitui-org/gitui/issues/2748))
## [0.28.1] - 2026-03-21
### Changed
* support proper pre-push hook ([#2809](https://github.com/gitui-org/gitui/issues/2809))
* improve `gitui --version` message [[@hlsxx](https://github.com/hlsxx)] ([#2838](https://github.com/gitui-org/gitui/issues/2838))
* rust msrv bumped to `1.88`
### Fixed
* fix extremely slow status loading in large repositories by replacing time-based cache invalidation with generation counter [[@DannyStoll1](https://github.com/DannyStoll1)] ([#2823](https://github.com/gitui-org/gitui/issues/2823))
* fix panic when renaming or updating remote URL with no remotes configured [[@xvchris](https://github.com/xvchris)] ([#2868](https://github.com/gitui-org/gitui/issues/2868))
## [0.28.0] - 2025-12-14
**discard changes on checkout**
![discard-changes-on-checkout](assets/discard-changes-on-checkout.png)
**go to line in blame**
![blame-goto-line](assets/blame-goto-line.png)
### Added
* Give more space to right-side diff view [[@hongquan](https://github.com/hongquan)] ([#2772](https://github.com/gitui-org/gitui/pull/2772))
* Support pre-push hook [[@xlai89](https://github.com/xlai89)] ([#1933](https://github.com/extrawurst/gitui/issues/1933))
* Message tab supports pageUp and pageDown [[@xlai89](https://github.com/xlai89)] ([#2623](https://github.com/extrawurst/gitui/issues/2623))
* Files and status tab support pageUp and pageDown [[@fatpandac](https://github.com/fatpandac)] ([#1951](https://github.com/extrawurst/gitui/issues/1951))
* support choosing checkout branch method when status is not empty [[@fatpandac](https://github.com/fatpandac)] ([#2404](https://github.com/extrawurst/gitui/issues/2404))
* support pre-push hook [[@xlai89](https://github.com/xlai89)] ([#1933](https://github.com/extrawurst/gitui/issues/1933))
* message tab supports pageUp and pageDown [[@xlai89](https://github.com/xlai89)] ([#2623](https://github.com/extrawurst/gitui/issues/2623))
* files and status tab support pageUp and pageDown [[@fatpandac](https://github.com/fatpandac)] ([#1951](https://github.com/extrawurst/gitui/issues/1951))
* support loading custom syntax highlighting themes from a file [[@acuteenvy](https://github.com/acuteenvy)] ([#2565](https://github.com/gitui-org/gitui/pull/2565))
* Select syntax highlighting theme out of the defaults from syntect [[@vasilismanol](https://github.com/vasilismanol)] ([#1931](https://github.com/extrawurst/gitui/issues/1931))
* select syntax highlighting theme out of the defaults from syntect [[@vasilismanol](https://github.com/vasilismanol)] ([#1931](https://github.com/extrawurst/gitui/issues/1931))
* new command-line option to override the default log file path (`--logfile`) [[@acuteenvy](https://github.com/acuteenvy)] ([#2539](https://github.com/gitui-org/gitui/pull/2539))
* dx: `make check` checks Cargo.toml dependency ordering using `cargo sort` [[@naseschwarz](https://github.com/naseschwarz)]
* add `use_selection_fg` to theme file to allow customizing selection foreground color [[@Upsylonbare](https://github.com/Upsylonbare)] ([#2515](https://github.com/gitui-org/gitui/pull/2515))
* add "go to line" command for the blame view [[@andrea-berling](https://github.com/andrea-berling)] ([#2262](https://github.com/extrawurst/gitui/pull/2262))
* add `--file` cli flag to open the files tab with the given file already selected [[@laktak](https://github.com/laktak)] ([#2510](https://github.com/gitui-org/gitui/issues/2510))
* add the ability to specify a custom keybinding/symbols file via the cli [[@0x61nas](https://github.com/0x61nas)] ([#2731](https://github.com/gitui-org/gitui/pull/2731))
* add mise alternative method installation [[@jylenhof](https://github.com/jylenhof)] ([#2817](https://github.com/gitui-org/gitui/pull/2817))
### Changed
* execute git-hooks directly if possible (on *nix) else use sh instead of bash (without reading SHELL variable) [[@Joshix](https://github.com/Joshix-1)] ([#2483](https://github.com/extrawurst/gitui/pull/2483))
* improve error messages [[@acuteenvy](https://github.com/acuteenvy)] ([#2617](https://github.com/gitui-org/gitui/pull/2617))
* increase MSRV from 1.70 to 1.81 [[@naseschwarz](https://github.com/naseschwarz)] ([#2094](https://github.com/gitui-org/gitui/issues/2094))
* improve syntax highlighting file detection [[@acuteenvy](https://github.com/acuteenvy)] ([#2524](https://github.com/extrawurst/gitui/pull/2524))
* Updated project links to point to `gitui-org` instead of `extrawurst` [[@vasleymus](https://github.com/vasleymus)] ([#2538](https://github.com/gitui-org/gitui/pull/2538))
* After commit: jump back to unstaged area [[@tommady](https://github.com/tommady)] ([#2476](https://github.com/extrawurst/gitui/issues/2476))
* The default key to close the commit error message popup is now the Escape key [[@wessamfathi](https://github.com/wessamfathi)] ([#2552](https://github.com/extrawurst/gitui/issues/2552))
* after commit: jump back to unstaged area [[@tommady](https://github.com/tommady)] ([#2476](https://github.com/extrawurst/gitui/issues/2476))
* the default key to close the commit error message popup is now the Escape key [[@wessamfathi](https://github.com/wessamfathi)] ([#2552](https://github.com/extrawurst/gitui/issues/2552))
* use OSC52 copying in case other methods fail [[@naseschwarz](https://github.com/naseschwarz)] ([#2366](https://github.com/gitui-org/gitui/issues/2366))
* push: respect `branch.*.merge` when push default is upstream [[@vlad-anger](https://github.com/vlad-anger)] ([#2542](https://github.com/gitui-org/gitui/pull/2542))
* set the terminal title to `gitui ({repo_path})` [[@acuteenvy](https://github.com/acuteenvy)] ([#2462](https://github.com/gitui-org/gitui/issues/2462))
* respect `.mailmap` [[@acuteenvy](https://github.com/acuteenvy)] ([#2406](https://github.com/gitui-org/gitui/issues/2406))
* use `gitoxide` for `get_tags` [[@cruessler](https://github.com/cruessler)] ([#2664](https://github.com/gitui-org/gitui/issues/2664))
* increase MSRV to 1.82
### Fixes
* resolve `core.hooksPath` relative to `GIT_WORK_TREE` [[@naseschwarz](https://github.com/naseschwarz)] ([#2571](https://github.com/gitui-org/gitui/issues/2571))
* yanking commit ranges no longer generates incorrect dotted range notations, but lists each individual commit [[@naseschwarz](https://github.com/naseschwarz)] (https://github.com/gitui-org/gitui/issues/2576)
* print slightly nicer errors when failing to create a directory [[@linkmauve](https://github.com/linkmauve)] (https://github.com/gitui-org/gitui/pull/2728)
* When the terminal is insufficient to display all the commands, the cmdbar_bg configuration color does not fully take effect. ([#2347](https://github.com/extrawurst/gitui/issues/2347))
* yanking commit ranges no longer generates incorrect dotted range notations, but lists each individual commit [[@naseschwarz](https://github.com/naseschwarz)] ([#2576](https://github.com/gitui-org/gitui/issues/2576))
* print slightly nicer errors when failing to create a directory [[@linkmauve](https://github.com/linkmauve)] ([#2728](https://github.com/gitui-org/gitui/pull/2728))
* when the terminal is insufficient to display all the commands, the cmdbar_bg configuration color does not fully take effect. ([#2347](https://github.com/extrawurst/gitui/issues/2347))
* disable blame and history popup keybinds for untracked files [[@kpbaks](https://github.com/kpbaks)] ([#2489](https://github.com/gitui-org/gitui/pull/2489))
* overwrites committer on amend of unsigned commits [[@cruessler](https://github.com/cruessler)] ([#2784](https://github.com/gitui-org/gitui/issues/2784))
* Updated project links to point to `gitui-org` instead of `extrawurst` [[@vasleymus](https://github.com/vasleymus)] ([#2538](https://github.com/gitui-org/gitui/pull/2538))
## [0.27.0] - 2024-01-14
## [0.27.0] - 2025-01-14
**new: manage remotes**

1614
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,28 +1,92 @@
[package]
name = "gitui"
version = "0.27.0"
version = "0.28.1"
authors = ["extrawurst <mail@rusticorn.com>"]
description = "blazing fast terminal-ui for git"
edition = "2021"
rust-version = "1.82"
rust-version = "1.88"
exclude = [".github/*", ".vscode/*", "assets/*"]
homepage = "https://github.com/gitui-org/gitui"
repository = "https://github.com/gitui-org/gitui"
readme = "README.md"
license = "MIT"
categories = ["command-line-utilities"]
keywords = ["git", "gui", "cli", "terminal", "ui"]
keywords = ["cli", "git", "gui", "terminal", "ui"]
build = "build.rs"
[workspace]
members = [
"asyncgit",
"filetreelist",
"git2-hooks",
"git2-testing",
"scopetime",
"asyncgit",
"filetreelist",
"git2-hooks",
"git2-testing",
"scopetime",
]
[dependencies]
anyhow = "1.0"
asyncgit = { path = "./asyncgit", version = "0.28.1", default-features = false }
backtrace = "0.3"
base64 = "0.22"
bitflags = "2.10"
bugreport = "0.5.1"
bwrap = { version = "1.3", features = ["use_std"] }
bytesize = { version = "2.3", default-features = false }
chrono = { version = "0.4", default-features = false, features = ["clock"] }
clap = { version = "4.5", features = ["cargo", "env"] }
crossbeam-channel = "0.5"
crossterm = { version = "0.29", features = ["serde"] }
dirs = "6.0"
easy-cast = "0.5"
filetreelist = { path = "./filetreelist", version = ">=0.6" }
fuzzy-matcher = "0.3"
gh-emoji = { version = "1.0", optional = true }
indexmap = "2"
itertools = "0.14"
log = "0.4"
notify = "8"
notify-debouncer-mini = "0.7"
once_cell = "1"
parking_lot_core = "0.9"
ratatui = { version = "0.30", default-features = false, features = [
"crossterm",
"serde",
] }
ratatui-textarea = "0.8"
rayon-core = "1.13"
ron = "0.12"
scopeguard = "1.2"
scopetime = { path = "./scopetime", version = "0.1" }
serde = "1.0"
shellexpand = "3.1"
simplelog = { version = "0.12", default-features = false }
struct-patch = "0.10"
syntect = { version = "5.3", default-features = false, features = [
"default-syntaxes",
"default-themes",
"html",
"parsing",
"plist-load",
] }
two-face = { version = "0.4.4", default-features = false }
unicode-segmentation = "1.12"
unicode-truncate = "2.0"
unicode-width = "0.2"
which = "8.0"
[dev-dependencies]
env_logger = "0.11"
git2-testing = { path = "./git2-testing" }
insta = { version = "1.41.0", features = ["filters"] }
pretty_assertions = "1.4"
tempfile = "3"
[build-dependencies]
chrono = { version = "0.4", default-features = false, features = ["clock"] }
[badges]
maintenance = { status = "actively-developed" }
[features]
default = ["ghemoji", "regex-fancy", "trace-libgit", "vendor-openssl"]
ghemoji = ["gh-emoji"]
@ -33,76 +97,14 @@ timing = ["scopetime/enabled"]
trace-libgit = ["asyncgit/trace-libgit"]
vendor-openssl = ["asyncgit/vendor-openssl"]
[dependencies]
anyhow = "1.0"
asyncgit = { path = "./asyncgit", version = "0.27.0", default-features = false }
backtrace = "0.3"
base64 = "0.22"
bitflags = "2.10"
bugreport = "0.5.1"
bwrap = { version = "1.3", features = ["use_std"] }
bytesize = { version = "2.1", default-features = false }
chrono = { version = "0.4", default-features = false, features = ["clock"] }
clap = { version = "4.5", features = ["env", "cargo"] }
crossbeam-channel = "0.5"
crossterm = { version = "0.28", features = ["serde"] }
dirs = "6.0"
easy-cast = "0.5"
filetreelist = { path = "./filetreelist", version = "0.5" }
fuzzy-matcher = "0.3"
gh-emoji = { version = "1.0", optional = true }
indexmap = "2"
itertools = "0.14"
log = "0.4"
notify = "8"
notify-debouncer-mini = "0.7"
once_cell = "1"
parking_lot_core = "0.9"
ratatui = { version = "0.29", default-features = false, features = [
'crossterm',
'serde',
] }
rayon-core = "1.13"
ron = "0.11"
scopeguard = "1.2"
scopetime = { path = "./scopetime", version = "0.1" }
serde = "1.0"
shellexpand = "3.1"
simplelog = { version = "0.12", default-features = false }
struct-patch = "0.10"
syntect = { version = "5.3", default-features = false, features = [
"parsing",
"default-syntaxes",
"default-themes",
"plist-load",
"html",
] }
tui-textarea = "0.7"
two-face = { version = "0.4.4", default-features = false }
unicode-segmentation = "1.12"
unicode-truncate = "2.0"
unicode-width = "0.2"
which = "8.0"
[build-dependencies]
chrono = { version = "0.4", default-features = false, features = ["clock"] }
[dev-dependencies]
env_logger = "0.11"
pretty_assertions = "1.4"
tempfile = "3"
[badges]
maintenance = { status = "actively-developed" }
[profile.release]
lto = true
opt-level = 'z' # Optimize for size.
codegen-units = 1
strip = "debuginfo"
# make debug build as fast as release
# usage of utf8 encoding inside tui
# makes their debug profile slow
[profile.dev.package."ratatui"]
opt-level = 3
[profile.release]
opt-level = "z" # Optimize for size.
strip = "debuginfo"
lto = true
codegen-units = 1

View file

@ -59,7 +59,7 @@ build-linux-musl-release:
cargo build --release --target=x86_64-unknown-linux-musl --locked
test-linux-musl:
cargo test --workspace --target=x86_64-unknown-linux-musl
cargo nextest run --workspace --target=x86_64-unknown-linux-musl
release-linux-arm: build-linux-arm-release
mkdir -p release
@ -83,7 +83,7 @@ build-linux-arm-release:
cargo build --release --target=arm-unknown-linux-gnueabihf --locked
test:
cargo test --workspace
cargo nextest run --workspace
fmt:
cargo fmt -- --check
@ -94,7 +94,7 @@ clippy:
clippy-nightly:
cargo +nightly clippy --workspace --all-features
check: fmt clippy test sort
check: fmt clippy test sort deny
check-nightly:
cargo +nightly c
@ -105,7 +105,7 @@ deny:
cargo deny check
sort:
cargo sort -c -w "."
tombi format --check
install:
cargo install --path "." --offline --locked

View file

@ -80,11 +80,11 @@ These are the high level goals before calling out `1.0`:
* visualize branching structure in log tab ([#81](https://github.com/gitui-org/gitui/issues/81))
* interactive rebase ([#32](https://github.com/gitui-org/gitui/issues/32))
- no git-lfs support (see [#2812](https://github.com/gitui-org/gitui/issues/2812))
## 5. <a name="limitations"></a> Known Limitations <small><sup>[Top ▲](#table-of-contents)</sup></small>
- no sparse repo support (see [#1226](https://github.com/gitui-org/gitui/issues/1226))
- no git-lfs support (see [#1089](https://github.com/gitui-org/gitui/discussions/1089))
- *credential.helper* for https needs to be **explicitly** configured (see [#800](https://github.com/gitui-org/gitui/issues/800))
Currently, this tool does not fully substitute the _git shell_, however both tools work well in tandem.
@ -163,6 +163,12 @@ scoop install gitui
choco install gitui
```
##### [Mise](https://github.com/jdx/mise)
```shell
mise use -g gitui@latest
```
##### [Nix](https://search.nixos.org/packages?channel=unstable&show=gitui&from=0&size=50&sort=relevance&query=gitui) (Nix/NixOS)
Nixpkg
@ -220,7 +226,7 @@ see [NIGHTLIES.md](./NIGHTLIES.md)
### Requirements
- Minimum supported `rust`/`cargo` version: `1.82`
- Minimum supported `rust`/`cargo` version: `1.88`
- See [Install Rust](https://www.rust-lang.org/tools/install)
- To build openssl dependency (see https://docs.rs/openssl/latest/openssl/)

BIN
assets/blame-goto-line.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

1
assets/logo.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="600" fill="none"><g clip-path="url(#a)"><mask id="c" width="438" height="484" x="38" y="57" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#D9D9D9" d="m340 541 76-39 60-52-76-43-35-8-13 42H231l-13-38-46-7-24-106 20-29v-44l85-47 101-6 48 26 67-40-60-67-150-25-189 78-31 201 116 182 185 22Z"/></mask><g stroke="#000" filter="url(#b)" mask="url(#c)"><path stroke-width="38.4" d="M290 483a183 183 0 1 0 0-366 183 183 0 0 0 0 366Z"/><path fill="#000" stroke-linejoin="round" stroke-width="12.8" d="m480 351 23-9-18-16-5 25Zm-14 36 25-4-15-20-10 24Zm-20 33 25 1-11-23-14 22Zm-26 28 24 6-6-24-18 18Zm-32 22 23 11-1-25-22 14Zm-35 16 20 15 4-25-24 10Zm-37 9 16 18 9-23-25 5Zm-39 1 13 21 13-21h-26Zm-38-6 9 23 16-18-25-5Zm-36-14 4 25 20-15-24-10Zm-33-20-1 25 23-11-22-14Zm-28-26-6 24 24-6-18-18Zm-22-32-11 23 25-1-14-22Zm-16-35-15 20 25 4-10-24Zm-9-37-18 16 23 9-5-25Zm-1-39-21 13 21 13v-26Zm6-38-23 9 18 16 5-25Zm14-36-25 4 15 20 10-24Zm20-33-25-1 11 23 14-22Zm26-28-24-6 6 24 18-18Zm32-22-23-11 1 25 22-14Zm35-16-20-15-4 25 24-10Zm37-9-16-18-9 23 25-5Zm39-1-13-21-13 21h26Zm38 6-9-23-16 18 25 5Zm36 14-4-25-20 15 24 10Zm33 20 1-25-23 11 22 14Zm28 26 6-24-24 6 18 18Zm22 32 11-23-25 1 14 22Zm16 36 15-20-25-4 10 24Zm9 36 18-16-23-9 5 25Z"/><path fill="#000" stroke-linejoin="round" stroke-width="25.6" d="m260 121 30 30 30-30h-60Zm191 95-19 38 38 19-19-57Zm-32 211-41-6-7 41 48-35Zm-210 35-7-41-41 6 48 35Zm-99-189 38-19-19-38-19 57Z"/></g></g><g filter="url(#d)"><path fill="#000" d="M305 307h81v7h-18v71l-35-6a98 98 0 0 1-92-5c-24-16-36-41-36-75 0-26 8-47 23-63 16-16 37-24 63-24 15 0 28 3 41 8l27-5 1 60h-9c-7-36-24-54-51-54h-7c-29 4-44 30-44 80 0 16 1 30 4 42 7 24 22 36 45 36 12 0 22-4 31-10v-55h-24v-7Zm157 71h3c2 0 4 3 4 7h-70v-7l6-1c7-1 10-6 10-14v-92h-16v-8h52v115h11Zm-8-152c0 4-1 7-3 10-4 7-10 11-18 11-4 0-7-1-10-3-8-4-11-10-11-18l2-10c4-7 10-11 19-11 3 0 7 1 10 3 7 4 11 10 11 18Zm24 46v-7h20v-27l36-6v33h37v7h-37v86l1 7c1 10 5 15 12 15l4-1c7-1 13-10 16-25l8 1c-1 8-4 14-6 19-6 9-16 14-30 14h-7c-23-3-34-18-34-45v-71h-20Zm101-49v-8h85v8h-22v97c0 11 0 20 2 28 5 21 16 31 36 31 30 0 45-19 45-59v-97h-22v-8h55v8h-22v96c0 13-2 25-5 34-8 23-27 35-57 35-48 0-72-22-72-65V223h-23Zm271 162h-85v-8h23V223h-23v-8h85v8h-22v154h15c4 1 6 3 7 8Z"/></g><defs><filter id="b" width="455.7" height="455.7" x="62" y="72" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset/><feGaussianBlur stdDeviation="2"/><feComposite in2="hardAlpha" operator="out"/><feColorMatrix values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/><feBlend in2="BackgroundImageFix" result="effect1_dropShadow_1_2"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_1_2" result="shape"/></filter><filter id="d" width="653.4" height="191.2" x="201" y="201" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset/><feGaussianBlur stdDeviation="2"/><feComposite in2="hardAlpha" operator="out"/><feColorMatrix values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/><feBlend in2="BackgroundImageFix" result="effect1_dropShadow_1_2"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_1_2" result="shape"/></filter><clipPath id="a"><path fill="#fff" d="M64 74h452v452H64z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

View file

@ -1,6 +1,6 @@
[package]
name = "asyncgit"
version = "0.27.0"
version = "0.28.1"
authors = ["extrawurst <mail@rusticorn.com>"]
edition = "2021"
description = "allow using git2 in a asynchronous context"
@ -8,14 +8,9 @@ homepage = "https://github.com/gitui-org/gitui"
repository = "https://github.com/gitui-org/gitui"
readme = "README.md"
license = "MIT"
categories = ["concurrency", "asynchronous"]
categories = ["asynchronous", "concurrency"]
keywords = ["git"]
[features]
default = ["trace-libgit"]
trace-libgit = []
vendor-openssl = ["openssl-sys"]
[dependencies]
bitflags = "2"
crossbeam-channel = "0.5"
@ -23,18 +18,18 @@ dirs = "6.0"
easy-cast = "0.5"
fuzzy-matcher = "0.3"
git2 = "0.20"
git2-hooks = { path = "../git2-hooks", version = ">=0.5" }
gix = { version = "0.74.1", default-features = false, features = [
"max-performance",
"revision",
"mailmap",
"status",
git2-hooks = { path = "../git2-hooks", version = "0.7" }
gix = { version = "0.78.0", default-features = false, features = [
"mailmap",
"max-performance",
"revision",
"status",
] }
log = "0.4"
# git2 = { path = "../../extern/git2-rs", features = ["vendored-openssl"]}
# git2 = { git="https://github.com/extrawurst/git2-rs.git", rev="fc13dcc", features = ["vendored-openssl"]}
# pinning to vendored openssl, using the git2 feature this gets lost with new resolver
openssl-sys = { version = '0.9', features = ["vendored"], optional = true }
openssl-sys = { version = "0.9", features = ["vendored"], optional = true }
rayon = "1.11"
rayon-core = "1.13"
scopetime = { path = "../scopetime", version = "0.1" }
@ -48,5 +43,10 @@ url = "2.5"
env_logger = "0.11"
invalidstring = { path = "../invalidstring", version = "0.1" }
pretty_assertions = "1.4"
serial_test = "3.2"
serial_test = "3.3"
tempfile = "3"
[features]
default = ["trace-libgit"]
trace-libgit = []
vendor-openssl = ["openssl-sys"]

View file

@ -164,7 +164,7 @@ impl AsyncPush {
*last_res = match res {
Ok(()) => None,
Err(e) => {
log::error!("push error: {e}",);
log::error!("push error: {e}");
Some(e.to_string())
}
};

View file

@ -49,7 +49,7 @@ pub struct AsyncLog {
static LIMIT_COUNT: usize = 3000;
static SLEEP_FOREGROUND: Duration = Duration::from_millis(2);
static SLEEP_BACKGROUND: Duration = Duration::from_millis(1000);
static SLEEP_BACKGROUND: Duration = Duration::from_secs(1);
impl AsyncLog {
///

View file

@ -10,19 +10,11 @@ use crossbeam_channel::Sender;
use std::{
hash::Hash,
sync::{
atomic::{AtomicUsize, Ordering},
atomic::{AtomicU64, AtomicUsize, Ordering},
Arc, Mutex,
},
time::{SystemTime, UNIX_EPOCH},
};
fn current_tick() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time before unix epoch!")
.as_millis()
}
#[derive(Default, Hash, Clone)]
pub struct Status {
pub items: Vec<StatusItem>,
@ -31,19 +23,17 @@ pub struct Status {
///
#[derive(Default, Hash, Copy, Clone, PartialEq, Eq)]
pub struct StatusParams {
tick: u128,
status_type: StatusType,
config: Option<ShowUntrackedFilesConfig>,
}
impl StatusParams {
///
pub fn new(
pub const fn new(
status_type: StatusType,
config: Option<ShowUntrackedFilesConfig>,
) -> Self {
Self {
tick: current_tick(),
status_type,
config,
}
@ -59,6 +49,8 @@ pub struct AsyncStatus {
sender: Sender<AsyncGitNotification>,
pending: Arc<AtomicUsize>,
repo: RepoPath,
/// Counter that increments after each completed fetch.
generation: Arc<AtomicU64>,
}
impl AsyncStatus {
@ -73,6 +65,7 @@ impl AsyncStatus {
last: Arc::new(Mutex::new(Status::default())),
sender,
pending: Arc::new(AtomicUsize::new(0)),
generation: Arc::new(AtomicU64::new(0)),
}
}
@ -97,12 +90,14 @@ impl AsyncStatus {
return Ok(None);
}
let hash_request = hash(&params);
let generation = self.generation.load(Ordering::Relaxed);
let hash_request = hash(&(params, generation));
log::trace!(
"request: [hash: {}] (type: {:?})",
"request: [hash: {}] (type: {:?}, gen: {})",
hash_request,
params.status_type,
generation,
);
{
@ -118,6 +113,7 @@ impl AsyncStatus {
let arc_current = Arc::clone(&self.current);
let arc_last = Arc::clone(&self.last);
let arc_generation = Arc::clone(&self.generation);
let sender = self.sender.clone();
let arc_pending = Arc::clone(&self.pending);
let status_type = params.status_type;
@ -138,11 +134,14 @@ impl AsyncStatus {
log::error!("fetch_helper: {e}");
}
// Increment generation to invalidate cache for next request
arc_generation.fetch_add(1, Ordering::Relaxed);
arc_pending.fetch_sub(1, Ordering::Relaxed);
sender
.send(AsyncGitNotification::Status)
.expect("error sending status");
if let Err(e) = sender.send(AsyncGitNotification::Status)
{
log::error!("send status error: {e}");
}
});
Ok(None)

View file

@ -104,6 +104,11 @@ impl BranchInfo {
None
}
/// returns whether branch is local
pub const fn is_local(&self) -> bool {
matches!(self.details, BranchDetails::Local(_))
}
}
///

View file

@ -40,10 +40,12 @@ pub fn amend(
return Err(Error::SignAmendNonLastCommit);
}
let committer = signature_allow_undefined_name(&repo)?;
let new_id = commit.amend(
Some("HEAD"),
None,
None,
Some(&committer), // Passing a value will overwrite the committer.
None,
Some(msg),
Some(&tree),
@ -307,6 +309,55 @@ mod tests {
Ok(())
}
#[test]
fn test_amend_with_different_user() {
let file_path1 = Path::new("foo");
let file_path2 = Path::new("foo2");
let (_td, repo) = repo_init_empty().unwrap();
let root = repo.path().parent().unwrap();
let repo_path: &RepoPath =
&root.as_os_str().to_str().unwrap().into();
File::create(root.join(file_path1))
.unwrap()
.write_all(b"test1")
.unwrap();
stage_add_file(repo_path, file_path1).unwrap();
let id = commit(repo_path, "commit msg").unwrap();
let amended_details =
get_commit_details(repo_path, id).unwrap();
assert_eq!(amended_details.committer, None);
File::create(root.join(file_path2))
.unwrap()
.write_all(b"test2")
.unwrap();
stage_add_file(repo_path, file_path2).unwrap();
repo.config()
.unwrap()
.set_str("user.name", "changed name")
.unwrap();
repo.config()
.unwrap()
.set_str("user.email", "changed@example.com")
.unwrap();
let new_id = amend(repo_path, id, "amended").unwrap();
let amended_details =
get_commit_details(repo_path, new_id).unwrap();
assert_eq!(amended_details.author.name, "name");
assert_eq!(amended_details.author.email, "email");
let committer = amended_details.committer.unwrap();
assert_eq!(committer.name, "changed name");
assert_eq!(committer.email, "changed@example.com");
}
#[test]
fn test_tag() -> Result<()> {
let file_path = Path::new("foo");

View file

@ -177,7 +177,7 @@ pub fn get_commit_info(
let message = gix_get_message(&commit_ref, None);
let author = commit_ref.author();
let author = commit_ref.author()?;
let author = mailmap.try_resolve(author).map_or_else(
|| author.name.into(),
@ -187,7 +187,7 @@ pub fn get_commit_info(
Ok(CommitInfo {
message,
author: author.to_string(),
time: commit_ref.time().seconds,
time: commit_ref.time()?.seconds,
id: commit.id().detach().into(),
})
}

View file

@ -1,7 +1,19 @@
use super::{repository::repo, RepoPath};
use crate::error::Result;
pub use git2_hooks::PrepareCommitMsgSource;
use crate::{
error::Result,
sync::{
branch::get_branch_upstream_merge,
config::{
push_default_strategy_config_repo,
PushDefaultStrategyConfig,
},
remotes::{proxy_auto, tags::tags_missing_remote, Callbacks},
},
};
use git2::{BranchType, Direction, Oid};
pub use git2_hooks::{PrePushRef, PrepareCommitMsgSource};
use scopetime::scope_time;
use std::collections::HashMap;
///
#[derive(Debug, PartialEq, Eq)]
@ -15,17 +27,91 @@ pub enum HookResult {
impl From<git2_hooks::HookResult> for HookResult {
fn from(v: git2_hooks::HookResult) -> Self {
match v {
git2_hooks::HookResult::Ok { .. }
| git2_hooks::HookResult::NoHookFound => Self::Ok,
git2_hooks::HookResult::RunNotSuccessful {
stdout,
stderr,
..
} => Self::NotOk(format!("{stdout}{stderr}")),
git2_hooks::HookResult::NoHookFound => Self::Ok,
git2_hooks::HookResult::Run(response) => {
if response.is_successful() {
Self::Ok
} else {
Self::NotOk(if response.stderr.is_empty() {
response.stdout
} else if response.stdout.is_empty() {
response.stderr
} else {
format!(
"{}\n{}",
response.stdout, response.stderr
)
})
}
}
}
}
}
/// Retrieve advertised refs from the remote for the upcoming push.
fn advertised_remote_refs(
repo_path: &RepoPath,
remote: Option<&str>,
url: &str,
basic_credential: Option<crate::sync::cred::BasicAuthCredential>,
) -> Result<HashMap<String, Oid>> {
let repo = repo(repo_path)?;
let mut remote_handle = if let Some(name) = remote {
repo.find_remote(name)?
} else {
repo.remote_anonymous(url)?
};
let callbacks = Callbacks::new(None, basic_credential);
let conn = remote_handle.connect_auth(
Direction::Push,
Some(callbacks.callbacks()),
Some(proxy_auto()),
)?;
let mut map = HashMap::new();
for head in conn.list()? {
map.insert(head.name().to_string(), head.oid());
}
Ok(map)
}
/// Determine the remote ref name for a branch push.
///
/// Respects `push.default=upstream` config when set and upstream is configured.
/// Otherwise defaults to `refs/heads/{branch}`. Delete operations always use
/// the simple ref name.
fn get_remote_ref_for_push(
repo_path: &RepoPath,
branch: &str,
delete: bool,
) -> Result<String> {
// For delete operations, always use the simple ref name
// regardless of push.default configuration
if delete {
return Ok(format!("refs/heads/{branch}"));
}
let repo = repo(repo_path)?;
let push_default_strategy =
push_default_strategy_config_repo(&repo)?;
// When push.default=upstream, use the configured upstream ref if available
if push_default_strategy == PushDefaultStrategyConfig::Upstream {
if let Ok(Some(upstream_ref)) =
get_branch_upstream_merge(repo_path, branch)
{
return Ok(upstream_ref);
}
// If upstream strategy is set but no upstream is configured,
// fall through to default behavior
}
// Default: push to remote branch with same name as local
Ok(format!("refs/heads/{branch}"))
}
/// see `git2_hooks::hooks_commit_msg`
pub fn hooks_commit_msg(
repo_path: &RepoPath,
@ -73,12 +159,133 @@ pub fn hooks_prepare_commit_msg(
}
/// see `git2_hooks::hooks_pre_push`
pub fn hooks_pre_push(repo_path: &RepoPath) -> Result<HookResult> {
pub fn hooks_pre_push(
repo_path: &RepoPath,
remote: &str,
push: &PrePushTarget<'_>,
basic_credential: Option<crate::sync::cred::BasicAuthCredential>,
) -> Result<HookResult> {
scope_time!("hooks_pre_push");
let repo = repo(repo_path)?;
if !git2_hooks::hook_available(
&repo,
None,
git2_hooks::HOOK_PRE_PUSH,
)? {
return Ok(HookResult::Ok);
}
Ok(git2_hooks::hooks_pre_push(&repo, None)?.into())
let git_remote = repo.find_remote(remote)?;
let url = git_remote
.pushurl()
.or_else(|| git_remote.url())
.ok_or_else(|| {
crate::error::Error::Generic(format!(
"remote '{remote}' has no URL configured"
))
})?
.to_string();
let advertised = advertised_remote_refs(
repo_path,
Some(remote),
&url,
basic_credential,
)?;
let updates = match push {
PrePushTarget::Branch { branch, delete } => {
let remote_ref =
get_remote_ref_for_push(repo_path, branch, *delete)?;
vec![pre_push_branch_update(
repo_path,
branch,
&remote_ref,
*delete,
&advertised,
)?]
}
PrePushTarget::Tags => {
pre_push_tag_updates(repo_path, remote, &advertised)?
}
};
Ok(git2_hooks::hooks_pre_push(
&repo,
None,
Some(remote),
&url,
&updates,
)?
.into())
}
/// Build a single pre-push update line for a branch.
fn pre_push_branch_update(
repo_path: &RepoPath,
branch_name: &str,
remote_ref: &str,
delete: bool,
advertised: &HashMap<String, Oid>,
) -> Result<PrePushRef> {
let repo = repo(repo_path)?;
let local_ref = format!("refs/heads/{branch_name}");
let local_oid = (!delete)
.then(|| {
repo.find_branch(branch_name, BranchType::Local)
.ok()
.and_then(|branch| branch.get().peel_to_commit().ok())
.map(|commit| commit.id())
})
.flatten();
let remote_oid = advertised.get(remote_ref).copied();
Ok(PrePushRef::new(
local_ref, local_oid, remote_ref, remote_oid,
))
}
/// Build pre-push updates for tags that are missing on the remote.
fn pre_push_tag_updates(
repo_path: &RepoPath,
remote: &str,
advertised: &HashMap<String, Oid>,
) -> Result<Vec<PrePushRef>> {
let repo = repo(repo_path)?;
let tags = tags_missing_remote(repo_path, remote, None)?;
let mut updates = Vec::with_capacity(tags.len());
for tag_ref in tags {
if let Ok(reference) = repo.find_reference(&tag_ref) {
let tag_oid = reference.target().or_else(|| {
reference.peel_to_commit().ok().map(|c| c.id())
});
let remote_ref = tag_ref.clone();
let advertised_oid = advertised.get(&remote_ref).copied();
updates.push(PrePushRef::new(
tag_ref.clone(),
tag_oid,
remote_ref,
advertised_oid,
));
}
}
Ok(updates)
}
/// What is being pushed.
pub enum PrePushTarget<'a> {
/// Push a single branch.
Branch {
/// Local branch name being pushed.
branch: &'a str,
/// Whether this is a delete push.
delete: bool,
},
/// Push tags.
Tags,
}
#[cfg(test)]
@ -248,4 +455,47 @@ mod tests {
assert_eq!(msg, String::from("msg\n"));
}
#[test]
fn test_pre_push_hook_rejects_based_on_stdin() {
let (_td, repo) = repo_init().unwrap();
let hook = b"#!/bin/sh
cat
exit 1
";
git2_hooks::create_hook(
&repo,
git2_hooks::HOOK_PRE_PUSH,
hook,
);
let commit_id = repo.head().unwrap().target().unwrap();
let update = git2_hooks::PrePushRef::new(
"refs/heads/master",
Some(commit_id),
"refs/heads/master",
None,
);
let expected_stdin =
git2_hooks::PrePushRef::to_stdin(&[update.clone()]);
let res = git2_hooks::hooks_pre_push(
&repo,
None,
Some("origin"),
"https://github.com/test/repo.git",
&[update],
)
.unwrap();
let git2_hooks::HookResult::Run(response) = res else {
panic!("Expected Run result");
};
assert!(!response.is_successful());
assert_eq!(response.stdout, expected_stdin);
assert!(expected_stdin.contains("refs/heads/master"));
}
}

View file

@ -68,7 +68,7 @@ pub use git2::BranchType;
pub use hooks::{
hooks_commit_msg, hooks_post_commit, hooks_pre_commit,
hooks_pre_push, hooks_prepare_commit_msg, HookResult,
PrepareCommitMsgSource,
PrePushTarget, PrepareCommitMsgSource,
};
pub use hunks::{reset_hunk, stage_hunk, unstage_hunk};
pub use ignore::add_to_ignore;

View file

@ -107,7 +107,7 @@ impl Callbacks {
reference: &str,
msg: Option<&str>,
) {
log::debug!("push_update_reference: '{reference}' {msg:?}",);
log::debug!("push_update_reference: '{reference}' {msg:?}");
if let Ok(mut stats) = self.stats.lock() {
stats.push_rejected_msg = msg
@ -162,7 +162,7 @@ impl Callbacks {
total: usize,
bytes: usize,
) {
log::debug!("progress: {current}/{total} ({bytes} B)",);
log::debug!("progress: {current}/{total} ({bytes} B)");
self.sender.clone().map(|sender| {
sender.send(ProgressNotification::PushTransfer {
current,

View file

@ -69,10 +69,14 @@ pub fn repo(repo_path: &RepoPath) -> Result<Repository> {
}
pub fn gix_repo(repo_path: &RepoPath) -> Result<gix::Repository> {
let repo = gix::ThreadSafeRepository::discover_with_environment_overrides(
let mut repo: gix::Repository = gix::ThreadSafeRepository::discover_with_environment_overrides(
repo_path.gitpath(),
)
.map(Into::into)?;
if let Some(workdir) = repo_path.workdir() {
repo.set_workdir(Some(workdir.into()))?;
}
Ok(repo)
}

View file

@ -34,7 +34,7 @@ impl NewFromOldContent {
Ok(())
}
fn skip_old_line(&mut self) {
const fn skip_old_line(&mut self) {
self.old_index += 1;
}

View file

@ -202,7 +202,11 @@ pub fn get_status(
let iter = status.into_index_worktree_iter(Vec::new())?;
for item in iter {
let item = item?;
let Ok(item) = item else {
log::warn!("[status] the status iter returned an error for an item: {item:?}");
continue;
};
let status = item.summary().map(Into::into);
@ -239,7 +243,7 @@ pub fn get_status(
res.push(StatusItem { path, status });
Ok(gix::diff::index::Action::Continue)
Ok(gix::diff::index::Action::Continue(()))
};
repo.tree_index_status(
@ -280,3 +284,88 @@ pub fn get_status(
Ok(res)
}
/// discard all changes in the working directory
pub fn discard_status(repo_path: &RepoPath) -> Result<bool> {
let repo = repo(repo_path)?;
let commit = repo.head()?.peel_to_commit()?;
repo.reset(commit.as_object(), git2::ResetType::Hard, None)?;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
sync::{
commit, stage_add_file,
status::{get_status, StatusType},
tests::{repo_init, repo_init_bare},
RepoPath,
},
StatusItem, StatusItemType,
};
use std::{fs::File, io::Write, path::Path};
use tempfile::TempDir;
#[test]
fn test_discard_status() {
let file_path = Path::new("README.md");
let (_td, repo) = repo_init().unwrap();
let root = repo.path().parent().unwrap();
let repo_path: &RepoPath =
&root.as_os_str().to_str().unwrap().into();
let mut file = File::create(root.join(file_path)).unwrap();
// initial commit
stage_add_file(repo_path, file_path).unwrap();
commit(repo_path, "commit msg").unwrap();
writeln!(file, "Test for discard_status").unwrap();
let statuses =
get_status(repo_path, StatusType::WorkingDir, None)
.unwrap();
assert_eq!(statuses.len(), 1);
discard_status(repo_path).unwrap();
let statuses =
get_status(repo_path, StatusType::WorkingDir, None)
.unwrap();
assert_eq!(statuses.len(), 0);
}
#[test]
fn test_get_status_with_workdir() {
let (git_dir, _repo) = repo_init_bare().unwrap();
let separate_workdir = TempDir::new().unwrap();
let file_path = Path::new("foo");
File::create(separate_workdir.path().join(file_path))
.unwrap()
.write_all(b"a")
.unwrap();
let repo_path = RepoPath::Workdir {
gitdir: git_dir.path().into(),
workdir: separate_workdir.path().into(),
};
let status =
get_status(&repo_path, StatusType::WorkingDir, None)
.unwrap();
assert_eq!(
status,
vec![StatusItem {
path: "foo".into(),
status: StatusItemType::New
}]
);
}
}

View file

@ -1,5 +1,8 @@
use super::{get_commits_info, CommitId, RepoPath};
use crate::{error::Result, sync::repository::repo};
use crate::{
error::Result,
sync::{gix_repo, repository::repo},
};
use scopetime::scope_time;
use std::collections::{BTreeMap, HashMap, HashSet};
@ -58,10 +61,8 @@ pub fn get_tags(repo_path: &RepoPath) -> Result<Tags> {
}
};
let gix_repo: gix::Repository =
gix::ThreadSafeRepository::discover_with_environment_overrides(repo_path.gitpath())
.map(Into::into)?;
let platform = gix_repo.references()?;
let repo: gix::Repository = gix_repo(repo_path)?;
let platform = repo.references()?;
for mut reference in (platform.tags()?).flatten() {
let commit = reference.peel_to_commit();
let tag = reference.peel_to_tag();
@ -140,7 +141,7 @@ pub fn get_tags_with_metadata(
})
.collect();
tags.sort_unstable_by(|a, b| b.time.cmp(&a.time));
tags.sort_unstable_by_key(|b| std::cmp::Reverse(b.time));
Ok(tags)
}

View file

@ -34,14 +34,14 @@ fn main() {
let build_date = now.date_naive();
let build_name = if std::env::var("GITUI_RELEASE").is_ok() {
env!("CARGO_PKG_VERSION").to_string()
} else {
format!(
"{} {} ({})",
"{}-nightly {} ({})",
env!("CARGO_PKG_VERSION"),
build_date,
get_git_hash()
)
} else {
format!("nightly {} ({})", build_date, get_git_hash())
};
println!("cargo:warning=buildname '{build_name}'");

102
deny.toml
View file

@ -1,65 +1,63 @@
[licenses]
allow = [
"MIT",
"Apache-2.0",
"BSD-2-Clause",
"BSD-3-Clause",
"CC0-1.0",
"ISC",
"MPL-2.0",
"Unicode-3.0",
"Zlib",
"MIT",
"Apache-2.0",
"BSD-2-Clause",
"BSD-3-Clause",
"CC0-1.0",
"ISC",
"MPL-2.0",
"Unicode-3.0",
"Zlib",
]
[advisories]
version = 2
ignore = [
# No fix for RSA, and this is a dependency from ssh_key crate to handle rsa ssh key.
# https://rustsec.org/advisories/RUSTSEC-2023-0071
"RUSTSEC-2023-0071",
# Crate paste is unmaintained. The dependency is already removed in
# ratatui:master. Until a new release is available, ignore this in
# order to pass CI. (https://github.com/gitui-org/gitui/issues/2554)
{ id = "RUSTSEC-2024-0436", reason = "The paste dependency is already removed from ratatui." }
# No fix for RSA, and this is a dependency from ssh_key crate to handle rsa ssh key.
# https://rustsec.org/advisories/RUSTSEC-2023-0071
"RUSTSEC-2023-0071",
# See https://github.com/trishume/syntect/issues/606
{ id = "RUSTSEC-2025-0141", reason = "Only brought in via syntect" },
]
[bans]
multiple-versions = "deny"
skip-tree = [
# currently needed due to:
# * `dirs-sys v0.4.1` (https://github.com/dirs-dev/dirs-sys-rs/issues/29)
{ name = "windows-sys" },
# this is needed for:
# `bwrap v1.3.0` (https://github.com/micl2e2/bwrap/pull/4)
{ name = "unicode-width" },
# currently needed due to `ratatui v0.29.0`
{ name = "unicode-truncate" },
# currently needed due to:
# * `redox_users v0.4.6`
# * `syntect v5.2.0`
{ name = "thiserror" },
# currently needed due to:
# * `windows v0.57.0`
# * `iana-time-zone v0.1.60`
{ name = "windows-core" },
# currently needed due to:
# * `parking_lot_core v0.9.10`
# * `filetime v0.2.23`
{ name = "redox_syscall" },
# currently needed due to:
# * `gix-hashtable v0.6.0`
{ name = "hashbrown" },
# 2022-10-26 `getrandom` and `rustix` were added when `gitoxide` was
# upgraded from 0.71.0 to 0.74.1.
# currently needed due to:
# * `tempfile v3.23.0`
# * `rand_core v0.6.4`
# * `redox_users v0.5.0`
{ name = "getrandom" },
# currently needed due to:
# * `crossterm v0.28.1`
# * `which v7.0.2`
# * `gix-index v0.42.1`
# * `tempfile v3.23.0`
{ name = "rustix" },
# currently needed due to:
# * `dirs-sys v0.4.1` (https://github.com/dirs-dev/dirs-sys-rs/issues/29)
{ name = "windows-sys" },
# this is needed for:
# `bwrap v1.3.0` (https://github.com/micl2e2/bwrap/pull/4)
{ name = "unicode-width" },
# currently needed due to `ratatui v0.29.0`
{ name = "unicode-truncate" },
# currently needed due to:
# * `redox_users v0.4.6`
# * `syntect v5.2.0`
{ name = "thiserror" },
# currently needed due to:
# * `windows v0.57.0`
# * `iana-time-zone v0.1.60`
{ name = "windows-core" },
# currently needed due to:
# * `parking_lot_core v0.9.10`
# * `filetime v0.2.23`
{ name = "redox_syscall" },
# currently needed due to:
# * `gix-hashtable v0.6.0`
{ name = "hashbrown" },
# 2022-10-26 `getrandom` and `rustix` were added when `gitoxide` was
# upgraded from 0.71.0 to 0.74.1.
# currently needed due to:
# * `tempfile v3.23.0`
# * `rand_core v0.6.4`
# * `redox_users v0.5.0`
{ name = "getrandom" },
# currently needed due to:
# * `crossterm v0.28.1`
# * `which v7.0.2`
# * `gix-index v0.42.1`
# * `tempfile v3.23.0`
{ name = "rustix" },
]

View file

@ -1,6 +1,6 @@
[package]
name = "filetreelist"
version = "0.5.2"
version = "0.6.0"
authors = ["extrawurst <mail@rusticorn.com>"]
edition = "2021"
description = "filetree abstraction based on a sorted path list, supports key based navigation events, folding, scrolling and more"
@ -9,7 +9,7 @@ repository = "https://github.com/gitui-org/gitui"
readme = "README.md"
license = "MIT"
categories = ["command-line-utilities"]
keywords = ["gui", "cli", "terminal", "ui", "tui"]
keywords = ["cli", "gui", "terminal", "tui", "ui"]
exclude = ["/demo.gif"]
[dependencies]

View file

@ -59,7 +59,7 @@ impl FileTreeItems {
}
/// how many individual items (files/paths) are in the list
pub fn len(&self) -> usize {
pub const fn len(&self) -> usize {
self.tree_items.len()
}

View file

@ -70,11 +70,11 @@ impl TreeItemInfo {
}
///
pub fn unindent(&mut self) {
pub const fn unindent(&mut self) {
self.indent = self.indent.saturating_sub(1);
}
pub fn set_visible(&mut self, visible: bool) {
pub const fn set_visible(&mut self, visible: bool) {
self.visible = visible;
}
}
@ -152,7 +152,7 @@ impl FileTreeItem {
}
///
pub fn info_mut(&mut self) -> &mut TreeItemInfo {
pub const fn info_mut(&mut self) -> &mut TreeItemInfo {
&mut self.info
}
@ -176,12 +176,12 @@ impl FileTreeItem {
}
///
pub fn hide(&mut self) {
pub const fn hide(&mut self) {
self.info.visible = false;
}
///
pub fn show(&mut self) {
pub const fn show(&mut self) {
self.info.visible = true;
}
}

View file

@ -1,6 +1,6 @@
[package]
name = "git2-hooks"
version = "0.5.0"
version = "0.7.0"
authors = ["extrawurst <mail@rusticorn.com>"]
edition = "2021"
description = "adds git hooks support based on git2-rs"
@ -14,7 +14,7 @@ keywords = ["git"]
[dependencies]
git2 = ">=0.17"
gix-path = "0.10"
gix-path = "0.11"
log = "0.4"
shellexpand = "3.1"
thiserror = "2.0"

View file

@ -14,6 +14,9 @@ pub enum HooksError {
#[error("shellexpand error:{0}")]
ShellExpand(#[from] shellexpand::LookupError<std::env::VarError>),
#[error("hook process terminated by signal without exit code")]
NoExitCode,
}
/// crate specific `Result` type

View file

@ -141,6 +141,20 @@ impl HookPaths {
/// this function calls hook scripts based on conventions documented here
/// see <https://git-scm.com/docs/githooks>
pub fn run_hook_os_str<I, S>(&self, args: I) -> Result<HookResult>
where
I: IntoIterator<Item = S> + Copy,
S: AsRef<OsStr>,
{
self.run_hook_os_str_with_stdin(args, None)
}
/// this function calls hook scripts with stdin input based on conventions documented here
/// see <https://git-scm.com/docs/githooks>
pub fn run_hook_os_str_with_stdin<I, S>(
&self,
args: I,
stdin: Option<&[u8]>,
) -> Result<HookResult>
where
I: IntoIterator<Item = S> + Copy,
S: AsRef<OsStr>,
@ -153,11 +167,42 @@ impl HookPaths {
);
let run_command = |command: &mut Command| {
command
let mut child = command
.args(args)
.current_dir(&self.pwd)
.with_no_window()
.output()
.stdin(if stdin.is_some() {
std::process::Stdio::piped()
} else {
std::process::Stdio::null()
})
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()?;
if let (Some(mut stdin_handle), Some(input)) =
(child.stdin.take(), stdin)
{
use std::io::{ErrorKind, Write};
// Write stdin to hook process
// Ignore broken pipe - hook may exit early without reading all input
let _ =
stdin_handle.write_all(input).inspect_err(|e| {
match e.kind() {
ErrorKind::BrokenPipe => {
log::debug!(
"Hook closed stdin early"
);
}
_ => log::warn!(
"Failed to write stdin to hook: {e}"
),
}
});
}
child.wait_with_output()
};
let output = if cfg!(windows) {
@ -210,21 +255,21 @@ impl HookPaths {
}
}?;
if output.status.success() {
Ok(HookResult::Ok { hook })
} else {
let stderr =
String::from_utf8_lossy(&output.stderr).to_string();
let stdout =
String::from_utf8_lossy(&output.stdout).to_string();
let stderr =
String::from_utf8_lossy(&output.stderr).to_string();
let stdout =
String::from_utf8_lossy(&output.stdout).to_string();
Ok(HookResult::RunNotSuccessful {
code: output.status.code(),
stdout,
stderr,
hook,
})
}
// Get exit code, or fail if process was killed by signal
let code =
output.status.code().ok_or(HooksError::NoExitCode)?;
Ok(HookResult::Run(crate::HookRunResponse {
hook,
stdout,
stderr,
code,
}))
}
}

View file

@ -38,7 +38,7 @@ pub use error::HooksError;
use error::Result;
use hookspath::HookPaths;
use git2::Repository;
use git2::{Oid, Repository};
pub const HOOK_POST_COMMIT: &str = "post-commit";
pub const HOOK_PRE_COMMIT: &str = "pre-commit";
@ -48,37 +48,98 @@ pub const HOOK_PRE_PUSH: &str = "pre-push";
const HOOK_COMMIT_MSG_TEMP_FILE: &str = "COMMIT_EDITMSG";
/// Check if a given hook is present considering config/paths and optional extra paths.
pub fn hook_available(
repo: &Repository,
other_paths: Option<&[&str]>,
hook: &str,
) -> Result<bool> {
let hook = HookPaths::new(repo, other_paths, hook)?;
Ok(hook.found())
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PrePushRef {
pub local_ref: String,
pub local_oid: Option<Oid>,
pub remote_ref: String,
pub remote_oid: Option<Oid>,
}
impl PrePushRef {
pub fn new(
local_ref: impl Into<String>,
local_oid: Option<Oid>,
remote_ref: impl Into<String>,
remote_oid: Option<Oid>,
) -> Self {
Self {
local_ref: local_ref.into(),
local_oid,
remote_ref: remote_ref.into(),
remote_oid,
}
}
fn format_oid(oid: Option<Oid>) -> String {
// "If the foreign ref does not yet exist the <remote-object-name> will be the all-zeroes object name"
// see https://git-scm.com/docs/githooks#_pre_push
oid.map_or_else(|| "0".repeat(40), |id| id.to_string())
}
pub fn to_line(&self) -> String {
format!(
"{} {} {} {}",
self.local_ref,
Self::format_oid(self.local_oid),
self.remote_ref,
Self::format_oid(self.remote_oid)
)
}
/// Build stdin content from a slice of updates (for pre-push hook)
pub fn to_stdin(updates: &[Self]) -> String {
let mut stdin = String::new();
for update in updates {
stdin.push_str(&update.to_line());
stdin.push('\n');
}
stdin
}
}
/// Response from running a hook
#[derive(Debug, PartialEq, Eq)]
pub struct HookRunResponse {
/// path of the hook that was run
pub hook: PathBuf,
/// stdout output emitted by hook
pub stdout: String,
/// stderr output emitted by hook
pub stderr: String,
/// exit code as reported back from process calling the hook (0 = success)
pub code: i32,
}
#[derive(Debug, PartialEq, Eq)]
pub enum HookResult {
/// No hook found
NoHookFound,
/// Hook executed with non error return code
Ok {
/// path of the hook that was run
hook: PathBuf,
},
/// Hook executed and returned an error code
RunNotSuccessful {
/// exit code as reported back from process calling the hook
code: Option<i32>,
/// stderr output emitted by hook
stdout: String,
/// stderr output emitted by hook
stderr: String,
/// path of the hook that was run
hook: PathBuf,
},
/// Hook executed (check `HookRunResponse.code` for success/failure)
Run(HookRunResponse),
}
impl HookResult {
/// helper to check if result is ok
pub const fn is_ok(&self) -> bool {
matches!(self, Self::Ok { .. })
/// helper to check if hook ran successfully (found and exit code 0)
pub const fn is_successful(&self) -> bool {
matches!(self, Self::Run(response) if response.is_successful())
}
}
/// helper to check if result was run and not rejected
pub const fn is_not_successful(&self) -> bool {
matches!(self, Self::RunNotSuccessful { .. })
impl HookRunResponse {
/// Check if the hook succeeded (exit code 0)
pub const fn is_successful(&self) -> bool {
self.code == 0
}
}
@ -172,9 +233,23 @@ pub fn hooks_post_commit(
}
/// this hook is documented here <https://git-scm.com/docs/githooks#_pre_push>
///
/// According to git documentation, pre-push hook receives:
/// - remote name as first argument (or URL if remote is not named)
/// - remote URL as second argument
/// - information about refs being pushed via stdin in format:
/// `<local-ref> SP <local-object-name> SP <remote-ref> SP <remote-object-name> LF`
///
/// If `remote` is `None` or empty, the `url` is used for both arguments as per Git spec.
///
/// Note: The hook is called even when `updates` is empty (matching Git's behavior).
/// This can occur when pushing tags that already exist on the remote.
pub fn hooks_pre_push(
repo: &Repository,
other_paths: Option<&[&str]>,
remote: Option<&str>,
url: &str,
updates: &[PrePushRef],
) -> Result<HookResult> {
let hook = HookPaths::new(repo, other_paths, HOOK_PRE_PUSH)?;
@ -182,7 +257,18 @@ pub fn hooks_pre_push(
return Ok(HookResult::NoHookFound);
}
hook.run_hook(&[])
// If a remote is not named (None or empty), the URL is passed for both arguments
let remote_name = match remote {
Some(r) if !r.is_empty() => r,
_ => url,
};
let stdin_data = PrePushRef::to_stdin(updates);
hook.run_hook_os_str_with_stdin(
[remote_name, url],
Some(stdin_data.as_bytes()),
)
}
pub enum PrepareCommitMsgSource {
@ -251,6 +337,110 @@ mod tests {
use pretty_assertions::assert_eq;
use tempfile::TempDir;
fn branch_update(
repo: &Repository,
remote: Option<&str>,
branch: &str,
remote_branch: Option<&str>,
delete: bool,
) -> PrePushRef {
let local_ref = format!("refs/heads/{branch}");
let local_oid = (!delete).then(|| {
repo.find_branch(branch, git2::BranchType::Local)
.unwrap()
.get()
.peel_to_commit()
.unwrap()
.id()
});
let remote_branch = remote_branch.unwrap_or(branch);
let remote_ref = format!("refs/heads/{remote_branch}");
let remote_oid = remote.and_then(|remote_name| {
repo.find_reference(&format!(
"refs/remotes/{remote_name}/{remote_branch}"
))
.ok()
.and_then(|r| r.peel_to_commit().ok())
.map(|c| c.id())
});
PrePushRef::new(local_ref, local_oid, remote_ref, remote_oid)
}
fn head_branch(repo: &Repository) -> String {
repo.head().unwrap().shorthand().unwrap().to_string()
}
#[test]
fn test_pre_push_ref_format() {
let zero_oid = "0".repeat(40);
let oid_a = "a".repeat(40);
let oid_b = "b".repeat(40);
// Both oids present
let update = PrePushRef::new(
"refs/heads/main",
Some(git2::Oid::from_str(&oid_a).unwrap()),
"refs/heads/main",
Some(git2::Oid::from_str(&oid_b).unwrap()),
);
assert_eq!(
update.to_line(),
format!(
"refs/heads/main {oid_a} refs/heads/main {oid_b}"
)
);
// No remote oid (new branch)
let update = PrePushRef::new(
"refs/heads/feature",
Some(git2::Oid::from_str(&oid_a).unwrap()),
"refs/heads/feature",
None,
);
assert_eq!(
update.to_line(),
format!("refs/heads/feature {oid_a} refs/heads/feature {zero_oid}")
);
// No local oid (delete)
let update = PrePushRef::new(
"refs/heads/old",
None,
"refs/heads/old",
Some(git2::Oid::from_str(&oid_b).unwrap()),
);
assert_eq!(
update.to_line(),
format!(
"refs/heads/old {zero_oid} refs/heads/old {oid_b}"
)
);
// to_stdin adds newlines
let updates = [
PrePushRef::new(
"refs/heads/a",
Some(git2::Oid::from_str(&oid_a).unwrap()),
"refs/heads/a",
None,
),
PrePushRef::new(
"refs/heads/b",
Some(git2::Oid::from_str(&oid_b).unwrap()),
"refs/heads/b",
None,
),
];
assert_eq!(
PrePushRef::to_stdin(&updates),
format!(
"refs/heads/a {oid_a} refs/heads/a {zero_oid}\nrefs/heads/b {oid_b} refs/heads/b {zero_oid}\n"
)
);
}
#[test]
fn test_smoke() {
let (_td, repo) = repo_init();
@ -268,7 +458,7 @@ exit 0
let res = hooks_post_commit(&repo, None).unwrap();
assert!(res.is_ok());
assert!(res.is_successful());
}
#[test]
@ -284,7 +474,7 @@ exit 0
let mut msg = String::from("test");
let res = hooks_commit_msg(&repo, None, &mut msg).unwrap();
assert!(res.is_ok());
assert!(res.is_successful());
assert_eq!(msg, String::from("test"));
}
@ -304,7 +494,7 @@ exit 0
let mut msg = String::from("test_sth");
let res = hooks_commit_msg(&repo, None, &mut msg).unwrap();
assert!(res.is_ok());
assert!(res.is_successful());
assert_eq!(msg, String::from("test_shell_command"));
}
@ -319,7 +509,7 @@ exit 0
create_hook(&repo, HOOK_PRE_COMMIT, hook);
let res = hooks_pre_commit(&repo, None).unwrap();
assert!(res.is_ok());
assert!(res.is_successful());
}
#[test]
@ -339,22 +529,16 @@ exit 0
let result = hook.run_hook(&[TEXT]).unwrap();
let HookResult::RunNotSuccessful {
code,
stdout,
stderr,
hook: h,
} = result
else {
unreachable!("run_hook should've failed");
let HookResult::Run(response) = result else {
unreachable!("run_hook should've run");
};
let stdout = stdout.as_str().trim_ascii_end();
let stdout = response.stdout.as_str().trim_ascii_end();
assert_eq!(code, Some(42));
assert_eq!(h, hook.hook);
assert_eq!(response.code, 42);
assert_eq!(response.hook, hook.hook);
assert_eq!(stdout, TEXT, "{:?} != {TEXT:?}", stdout);
assert!(stderr.is_empty());
assert!(response.stderr.is_empty());
}
#[test]
@ -384,7 +568,7 @@ exit 0
let res =
hooks_pre_commit(&repo, Some(&["../.myhooks"])).unwrap();
assert!(res.is_ok());
assert!(res.is_successful());
}
#[test]
@ -417,7 +601,7 @@ exit 1
let res =
hooks_pre_commit(&repo, Some(&["../.myhooks"])).unwrap();
assert!(res.is_ok());
assert!(res.is_successful());
}
#[test]
@ -431,7 +615,7 @@ exit 1
create_hook(&repo, HOOK_PRE_COMMIT, hook);
let res = hooks_pre_commit(&repo, None).unwrap();
assert!(res.is_not_successful());
assert!(!res.is_successful());
}
#[test]
@ -448,15 +632,17 @@ exit 1
create_hook(&repo, HOOK_PRE_COMMIT, hook);
let res = hooks_pre_commit(&repo, None).unwrap();
let HookResult::RunNotSuccessful { stdout, .. } = res else {
let HookResult::Run(response) = res else {
unreachable!()
};
assert!(
stdout
response
.stdout
.lines()
.any(|line| line.starts_with(PATH_EXPORT)),
"Could not find line starting with {PATH_EXPORT:?} in: {stdout:?}"
"Could not find line starting with {PATH_EXPORT:?} in: {:?}",
response.stdout
);
}
@ -482,13 +668,12 @@ exit 1
let res = hooks_pre_commit(&repo, None).unwrap();
let HookResult::RunNotSuccessful { code, stdout, .. } = res
else {
let HookResult::Run(response) = res else {
unreachable!()
};
assert_eq!(code.unwrap(), 1);
assert_eq!(&stdout, "rejected\n");
assert_eq!(response.code, 1);
assert_eq!(&response.stdout, "rejected\n");
}
#[test]
@ -502,7 +687,7 @@ exit 1
create_hook(&repo, HOOK_PRE_COMMIT, hook);
let res = hooks_pre_commit(&repo, None).unwrap();
assert!(res.is_not_successful());
assert!(!res.is_successful());
}
#[test]
@ -523,7 +708,7 @@ sys.exit(0)
create_hook(&repo, HOOK_PRE_COMMIT, hook);
let res = hooks_pre_commit(&repo, None).unwrap();
assert!(res.is_ok(), "{res:?}");
assert!(res.is_successful(), "{res:?}");
}
#[test]
@ -544,7 +729,7 @@ sys.exit(1)
create_hook(&repo, HOOK_PRE_COMMIT, hook);
let res = hooks_pre_commit(&repo, None).unwrap();
assert!(res.is_not_successful());
assert!(!res.is_successful());
}
#[test]
@ -562,13 +747,12 @@ sys.exit(1)
let mut msg = String::from("test");
let res = hooks_commit_msg(&repo, None, &mut msg).unwrap();
let HookResult::RunNotSuccessful { code, stdout, .. } = res
else {
let HookResult::Run(response) = res else {
unreachable!()
};
assert_eq!(code.unwrap(), 1);
assert_eq!(&stdout, "rejected\n");
assert_eq!(response.code, 1);
assert_eq!(&response.stdout, "rejected\n");
assert_eq!(msg, String::from("msg\n"));
}
@ -587,7 +771,7 @@ exit 0
let mut msg = String::from("test");
let res = hooks_commit_msg(&repo, None, &mut msg).unwrap();
assert!(res.is_ok());
assert!(res.is_successful());
assert_eq!(msg, String::from("msg\n"));
}
@ -633,7 +817,7 @@ exit 0
)
.unwrap();
assert!(matches!(res, HookResult::Ok { .. }));
assert!(res.is_successful());
assert_eq!(msg, String::from("msg:message\n"));
}
@ -658,13 +842,12 @@ exit 2
)
.unwrap();
let HookResult::RunNotSuccessful { code, stdout, .. } = res
else {
let HookResult::Run(response) = res else {
unreachable!()
};
assert_eq!(code.unwrap(), 2);
assert_eq!(&stdout, "rejected\n");
assert_eq!(response.code, 2);
assert_eq!(&response.stdout, "rejected\n");
assert_eq!(
msg,
@ -684,9 +867,25 @@ exit 0
create_hook(&repo, HOOK_PRE_PUSH, hook);
let res = hooks_pre_push(&repo, None).unwrap();
let branch = head_branch(&repo);
let updates = [branch_update(
&repo,
Some("origin"),
&branch,
None,
false,
)];
assert!(matches!(res, HookResult::Ok { .. }));
let res = hooks_pre_push(
&repo,
None,
Some("origin"),
"https://example.com/repo.git",
&updates,
)
.unwrap();
assert!(res.is_successful());
}
#[test]
@ -698,12 +897,331 @@ echo 'failed'
exit 3
";
create_hook(&repo, HOOK_PRE_PUSH, hook);
let res = hooks_pre_push(&repo, None).unwrap();
let HookResult::RunNotSuccessful { code, stdout, .. } = res
else {
let branch = head_branch(&repo);
let updates = [branch_update(
&repo,
Some("origin"),
&branch,
None,
false,
)];
let res = hooks_pre_push(
&repo,
None,
Some("origin"),
"https://example.com/repo.git",
&updates,
)
.unwrap();
let HookResult::Run(response) = res else {
unreachable!()
};
assert_eq!(code.unwrap(), 3);
assert_eq!(&stdout, "failed\n");
assert_eq!(response.code, 3);
assert_eq!(&response.stdout, "failed\n");
}
#[test]
fn test_pre_push_no_remote_name() {
let (_td, repo) = repo_init();
let hook = b"#!/bin/sh
# Verify that when remote is None, URL is passed for both arguments
echo \"arg1=$1 arg2=$2\"
exit 0
";
create_hook(&repo, HOOK_PRE_PUSH, hook);
let branch = head_branch(&repo);
let updates =
[branch_update(&repo, None, &branch, None, false)];
let res = hooks_pre_push(
&repo,
None,
None,
"https://example.com/repo.git",
&updates,
)
.unwrap();
let HookResult::Run(response) = res else {
panic!("Expected Run result, got: {res:?}");
};
assert!(response.is_successful());
// When remote is None, URL should be passed for both arguments
assert_eq!(
response.stdout,
"arg1=https://example.com/repo.git arg2=https://example.com/repo.git\n"
);
}
#[test]
fn test_pre_push_with_arguments() {
let (_td, repo) = repo_init();
let hook = b"#!/bin/sh
echo \"remote_name=$1\"
echo \"remote_url=$2\"
exit 0
";
create_hook(&repo, HOOK_PRE_PUSH, hook);
let branch = head_branch(&repo);
let updates = [branch_update(
&repo,
Some("origin"),
&branch,
None,
false,
)];
let res = hooks_pre_push(
&repo,
None,
Some("origin"),
"https://example.com/repo.git",
&updates,
)
.unwrap();
let HookResult::Run(response) = res else {
unreachable!("Expected Run result, got: {res:?}")
};
assert!(response.is_successful());
assert_eq!(
response.stdout,
"remote_name=origin\nremote_url=https://example.com/repo.git\n"
);
}
#[test]
fn test_pre_push_multiple_updates() {
let (_td, repo) = repo_init();
let hook = b"#!/bin/sh
cat
exit 0
";
create_hook(&repo, HOOK_PRE_PUSH, hook);
let branch = head_branch(&repo);
let branch_update = branch_update(
&repo,
Some("origin"),
&branch,
None,
false,
);
// create a tag to add a second refspec
let head_commit =
repo.head().unwrap().peel_to_commit().unwrap();
repo.tag_lightweight("v1", head_commit.as_object(), false)
.unwrap();
let tag_ref = repo.find_reference("refs/tags/v1").unwrap();
let tag_oid = tag_ref.target().unwrap();
let tag_update = PrePushRef::new(
"refs/tags/v1",
Some(tag_oid),
"refs/tags/v1",
None,
);
let updates = [branch_update, tag_update];
let expected_stdin = PrePushRef::to_stdin(&updates);
let res = hooks_pre_push(
&repo,
None,
Some("origin"),
"https://example.com/repo.git",
&updates,
)
.unwrap();
let HookResult::Run(response) = res else {
unreachable!("Expected Run result, got: {res:?}")
};
assert!(
response.is_successful(),
"Hook should succeed: stdout {} stderr {}",
response.stdout,
response.stderr
);
assert_eq!(
response.stdout, expected_stdin,
"stdin should include all refspec lines"
);
}
#[test]
fn test_pre_push_delete_ref_uses_zero_oid() {
let (_td, repo) = repo_init();
let hook = b"#!/bin/sh
cat
exit 0
";
create_hook(&repo, HOOK_PRE_PUSH, hook);
let branch = head_branch(&repo);
let updates = [branch_update(
&repo,
Some("origin"),
&branch,
None,
true,
)];
let expected_stdin = PrePushRef::to_stdin(&updates);
let res = hooks_pre_push(
&repo,
None,
Some("origin"),
"https://example.com/repo.git",
&updates,
)
.unwrap();
let HookResult::Run(response) = res else {
unreachable!("Expected Run result, got: {res:?}")
};
assert!(response.is_successful());
assert_eq!(response.stdout, expected_stdin);
}
#[test]
fn test_pre_push_stdin() {
let (_td, repo) = repo_init();
let hook = b"#!/bin/sh
cat
exit 0
";
create_hook(&repo, HOOK_PRE_PUSH, hook);
let branch = head_branch(&repo);
let updates = [branch_update(
&repo,
Some("origin"),
&branch,
None,
false,
)];
let expected_stdin = PrePushRef::to_stdin(&updates);
let res = hooks_pre_push(
&repo,
None,
Some("origin"),
"https://github.com/user/repo.git",
&updates,
)
.unwrap();
let HookResult::Run(response) = res else {
unreachable!("Expected Run result, got: {res:?}")
};
assert!(response.is_successful());
assert_eq!(response.stdout, expected_stdin);
}
#[test]
fn test_pre_push_uses_push_target_remote_not_upstream() {
let (_td, repo) = repo_init();
// repo_init() already creates an initial commit on master
let head = repo.head().unwrap();
let local_commit = head.target().unwrap();
// Set up scenario:
// - Local master is at local_commit (latest)
// - origin/master exists at local_commit (fully synced - upstream)
// - backup/master exists at old_commit (behind/different)
// - Branch tracks origin/master as upstream
// - We push to "backup" remote
// - Expected: remote SHA should be old_commit (not origin/master)
// Create origin/master tracking branch (at same commit as local)
repo.reference(
"refs/remotes/origin/master",
local_commit,
true,
"create origin/master",
)
.unwrap();
// Create backup/master at a different commit
let sig = repo.signature().unwrap();
let tree_id = {
let mut index = repo.index().unwrap();
index.write_tree().unwrap()
};
let tree = repo.find_tree(tree_id).unwrap();
let old_commit = repo
.commit(None, &sig, &sig, "old backup commit", &tree, &[])
.unwrap();
repo.reference(
"refs/remotes/backup/master",
old_commit,
true,
"create backup/master at old commit",
)
.unwrap();
// Configure upstream to origin
{
let mut config = repo.config().unwrap();
config.set_str("branch.master.remote", "origin").unwrap();
config
.set_str("branch.master.merge", "refs/heads/master")
.unwrap();
}
let hook = b"#!/bin/sh
cat
exit 0
";
create_hook(&repo, HOOK_PRE_PUSH, hook);
let branch = head_branch(&repo);
let updates = [branch_update(
&repo,
Some("backup"),
&branch,
None,
false,
)];
let expected_stdin = PrePushRef::to_stdin(&updates);
let res = hooks_pre_push(
&repo,
None,
Some("backup"),
"https://github.com/user/backup-repo.git",
&updates,
)
.unwrap();
let HookResult::Run(response) = res else {
panic!("Expected Run result, got: {res:?}")
};
assert!(response.is_successful());
assert_eq!(response.stdout, expected_stdin);
}
}

View file

@ -20,13 +20,18 @@ pub fn repo_init_empty() -> (TempDir, Repository) {
(td, repo)
}
/// initialize test repo in temp path with an empty first commit
pub fn repo_init() -> (TempDir, Repository) {
/// initialize test repo in temp path with given suffix and an empty first commit
pub fn repo_init_suffix<T: AsRef<std::ffi::OsStr>>(
suffix: Option<T>,
) -> (TempDir, Repository) {
init_log();
sandbox_config_files();
let td = TempDir::new().unwrap();
let td = match suffix {
Some(suffix) => TempDir::with_suffix(suffix).unwrap(),
None => TempDir::new().unwrap(),
};
let repo = Repository::init(td.path()).unwrap();
{
let mut config = repo.config().unwrap();
@ -45,6 +50,11 @@ pub fn repo_init() -> (TempDir, Repository) {
(td, repo)
}
/// initialize test repo in temp path with an empty first commit
pub fn repo_init() -> (TempDir, Repository) {
repo_init_suffix::<&std::ffi::OsStr>(None)
}
// init log
fn init_log() {
let _ = env_logger::builder()

View file

@ -1,3 +1,3 @@
max_width=70
hard_tabs=true
newline_style="Unix"
max_width = 70
hard_tabs = true
newline_style = "Unix"

View file

@ -9,11 +9,11 @@ repository = "https://github.com/gitui-org/gitui"
license = "MIT"
readme = "README.md"
categories = ["development-tools::profiling"]
keywords = ["profiling", "logging"]
keywords = ["logging", "profiling"]
[dependencies]
log = "0.4"
[features]
default = []
enabled = []
[dependencies]
log = "0.4"

View file

@ -1,5 +1,6 @@
use crate::{
accessors,
args::CliArgs,
cmdbar::CommandBar,
components::{
command_pump, event_pump, CommandInfo, Component,
@ -10,16 +11,16 @@ use crate::{
options::{Options, SharedOptions},
popup_stack::PopupStack,
popups::{
AppOption, BlameFilePopup, BranchListPopup, CommitPopup,
CompareCommitsPopup, ConfirmPopup, CreateBranchPopup,
CreateRemotePopup, ExternalEditorPopup, FetchPopup,
FileRevlogPopup, FuzzyFindPopup, HelpPopup,
InspectCommitPopup, LogSearchPopupPopup, MsgPopup,
OptionsPopup, PullPopup, PushPopup, PushTagsPopup,
RemoteListPopup, RenameBranchPopup, RenameRemotePopup,
ResetPopup, RevisionFilesPopup, StashMsgPopup,
SubmodulesListPopup, TagCommitPopup, TagListPopup,
UpdateRemoteUrlPopup,
AppOption, BlameFilePopup, BranchListPopup,
CheckoutOptionPopup, CommitPopup, CompareCommitsPopup,
ConfirmPopup, CreateBranchPopup, CreateRemotePopup,
ExternalEditorPopup, FetchPopup, FileRevlogPopup,
FuzzyFindPopup, GotoLinePopup, HelpPopup, InspectCommitPopup,
LogSearchPopupPopup, MsgPopup, OptionsPopup, PullPopup,
PushPopup, PushTagsPopup, RemoteListPopup, RenameBranchPopup,
RenameRemotePopup, ResetPopup, RevisionFilesPopup,
StashMsgPopup, SubmodulesListPopup, TagCommitPopup,
TagListPopup, UpdateRemoteUrlPopup,
},
queue::{
Action, AppTabs, InternalEvent, NeedsUpdate, Queue,
@ -98,6 +99,7 @@ pub struct App {
submodule_popup: SubmodulesListPopup,
tags_popup: TagListPopup,
reset_popup: ResetPopup,
checkout_option_popup: CheckoutOptionPopup,
cmdbar: RefCell<CommandBar>,
tab: usize,
revlog: Revlog,
@ -112,6 +114,7 @@ pub struct App {
popup_stack: PopupStack,
options: SharedOptions,
repo_path_text: String,
goto_line_popup: GotoLinePopup,
// "Flags"
requires_redraw: Cell<bool>,
@ -150,13 +153,14 @@ impl App {
///
#[allow(clippy::too_many_lines)]
pub fn new(
repo: RepoPathRef,
cliargs: CliArgs,
sender_git: Sender<AsyncGitNotification>,
sender_app: Sender<AsyncAppNotification>,
input: Input,
theme: Theme,
key_config: KeyConfig,
) -> Result<Self> {
let repo = RefCell::new(cliargs.repo_path.clone());
log::trace!("open repo at: {:?}", &repo);
let repo_path_text =
@ -172,7 +176,20 @@ impl App {
sender_app,
};
let tab = env.options.borrow().current_tab();
let mut select_file: Option<PathBuf> = None;
let tab = if let Some(file) = cliargs.select_file {
// convert to relative git path
if let Ok(abs) = file.canonicalize() {
if let Ok(path) = abs.strip_prefix(
env.repo.borrow().gitpath().canonicalize()?,
) {
select_file = Some(Path::new(".").join(path));
}
}
2
} else {
env.options.borrow().current_tab()
};
let mut app = Self {
input,
@ -217,7 +234,9 @@ impl App {
status_tab: Status::new(&env),
stashing_tab: Stashing::new(&env),
stashlist_tab: StashList::new(&env),
files_tab: FilesTab::new(&env),
files_tab: FilesTab::new(&env, select_file),
checkout_option_popup: CheckoutOptionPopup::new(&env),
goto_line_popup: GotoLinePopup::new(&env),
tab: 0,
queue: env.queue,
theme: env.theme,
@ -481,6 +500,7 @@ impl App {
msg_popup,
confirm_popup,
commit_popup,
goto_line_popup,
blame_file_popup,
file_revlog_popup,
stashmsg_popup,
@ -493,6 +513,7 @@ impl App {
fetch_popup,
tag_commit_popup,
reset_popup,
checkout_option_popup,
create_branch_popup,
create_remote_popup,
rename_remote_popup,
@ -533,6 +554,7 @@ impl App {
submodule_popup,
tags_popup,
reset_popup,
checkout_option_popup,
create_branch_popup,
rename_branch_popup,
revision_files_popup,
@ -544,7 +566,8 @@ impl App {
fetch_popup,
options_popup,
confirm_popup,
msg_popup
msg_popup,
goto_line_popup
]
);
@ -905,6 +928,17 @@ impl App {
InternalEvent::CommitSearch(options) => {
self.revlog.search(options);
}
InternalEvent::OpenGotoLinePopup(max_line) => {
self.goto_line_popup.open(max_line);
}
InternalEvent::GotoLine(line) => {
if self.blame_file_popup.is_visible() {
self.blame_file_popup.goto_line(line);
}
}
InternalEvent::CheckoutOption(branch) => {
self.checkout_option_popup.open(branch)?;
}
}
Ok(flags)
@ -1063,7 +1097,7 @@ impl App {
Err(e) => {
log::error!("delete remote: {e:?}");
self.queue.push(InternalEvent::ShowErrorMsg(
format!("delete remote error:\n{e}",),
format!("delete remote error:\n{e}"),
));
}
}

View file

@ -17,15 +17,22 @@ const LOG_FILE_FLAG_ID: &str = "logfile";
const LOGGING_FLAG_ID: &str = "logging";
const THEME_FLAG_ID: &str = "theme";
const WORKDIR_FLAG_ID: &str = "workdir";
const FILE_FLAG_ID: &str = "file";
const GIT_DIR_FLAG_ID: &str = "directory";
const WATCHER_FLAG_ID: &str = "watcher";
const KEY_BINDINGS_FLAG_ID: &str = "key_bindings";
const KEY_SYMBOLS_FLAG_ID: &str = "key_symbols";
const DEFAULT_THEME: &str = "theme.ron";
const DEFAULT_GIT_DIR: &str = ".";
#[derive(Clone)]
pub struct CliArgs {
pub theme: PathBuf,
pub select_file: Option<PathBuf>,
pub repo_path: RepoPath,
pub notify_watcher: bool,
pub key_bindings_path: Option<PathBuf>,
pub key_symbols_path: Option<PathBuf>,
}
pub fn process_cmdline() -> Result<CliArgs> {
@ -51,6 +58,10 @@ pub fn process_cmdline() -> Result<CliArgs> {
PathBuf::from,
);
let select_file = arg_matches
.get_one::<String>(FILE_FLAG_ID)
.map(PathBuf::from);
let repo_path = if let Some(w) = workdir {
RepoPath::Workdir { gitdir, workdir: w }
} else {
@ -73,10 +84,21 @@ pub fn process_cmdline() -> Result<CliArgs> {
let notify_watcher: bool =
*arg_matches.get_one(WATCHER_FLAG_ID).unwrap_or(&false);
let key_bindings_path = arg_matches
.get_one::<String>(KEY_BINDINGS_FLAG_ID)
.map(PathBuf::from);
let key_symbols_path = arg_matches
.get_one::<String>(KEY_SYMBOLS_FLAG_ID)
.map(PathBuf::from);
Ok(CliArgs {
theme,
select_file,
repo_path,
notify_watcher,
key_bindings_path,
key_symbols_path,
})
}
@ -95,6 +117,22 @@ fn app() -> ClapApp {
{all-args}{after-help}
",
)
.arg(
Arg::new(KEY_BINDINGS_FLAG_ID)
.help("Use a custom keybindings file")
.short('k')
.long("key-bindings")
.value_name("KEY_LIST_FILENAME")
.num_args(1),
)
.arg(
Arg::new(KEY_SYMBOLS_FLAG_ID)
.help("Use a custom symbols file")
.short('s')
.long("key-symbols")
.value_name("KEY_SYMBOLS_FILENAME")
.num_args(1),
)
.arg(
Arg::new(THEME_FLAG_ID)
@ -129,6 +167,13 @@ fn app() -> ClapApp {
.long("bugreport")
.action(clap::ArgAction::SetTrue),
)
.arg(
Arg::new(FILE_FLAG_ID)
.help("Select the file in the file tab")
.short('f')
.long("file")
.num_args(1),
)
.arg(
Arg::new(GIT_DIR_FLAG_ID)
.help("Set the git directory")

View file

@ -130,7 +130,7 @@ impl CommandBar {
}
}
pub fn toggle_more(&mut self) {
pub const fn toggle_more(&mut self) {
if self.expandable {
self.expanded = !self.expanded;
}

View file

@ -67,7 +67,7 @@ impl ChangesComponent {
}
/// returns true if list is empty
pub fn is_empty(&self) -> bool {
pub const fn is_empty(&self) -> bool {
self.files.is_empty()
}

View file

@ -113,7 +113,7 @@ impl CommitList {
}
///
pub fn marked_count(&self) -> usize {
pub const fn marked_count(&self) -> usize {
self.marked.len()
}
@ -284,7 +284,7 @@ impl CommitList {
}
/// will return view size or None before the first render
fn current_size(&self) -> Option<(u16, u16)> {
const fn current_size(&self) -> Option<(u16, u16)> {
self.current_size.get()
}

View file

@ -152,6 +152,10 @@ impl DiffComponent {
(self.current.path.clone(), self.current.is_stage)
}
///
const fn can_edit_file(&self) -> bool {
!self.is_immutable && !self.current.path.is_empty()
}
///
pub fn clear(&mut self, pending: bool) {
self.current = Current::default();
self.diff = None;
@ -770,6 +774,11 @@ impl Component for DiffComponent {
);
if !self.is_immutable {
out.push(CommandInfo::new(
strings::commands::edit_item(&self.key_config),
self.can_edit_file(),
self.focused() && self.can_edit_file(),
));
out.push(CommandInfo::new(
strings::commands::diff_hunk_remove(&self.key_config),
self.selected_hunk.is_some(),
@ -876,6 +885,15 @@ impl Component for DiffComponent {
) {
self.diff_hunk_move_up_down(-1);
Ok(EventState::Consumed)
} else if key_match(e, self.key_config.keys.edit_file)
&& self.can_edit_file()
{
self.queue.push(
InternalEvent::OpenExternalEditor(Some(
self.current.path.clone(),
)),
);
Ok(EventState::Consumed)
} else if key_match(
e,
self.key_config.keys.stage_unstage_item,
@ -945,7 +963,10 @@ impl Component for DiffComponent {
#[cfg(test)]
mod tests {
use super::*;
use crate::ui::style::Theme;
use crate::{
app::Environment, queue::InternalEvent, ui::style::Theme,
};
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
use std::io::Write;
use std::rc::Rc;
use tempfile::NamedTempFile;
@ -1012,4 +1033,30 @@ mod tests {
);
}
}
#[test]
fn diff_component_opens_editor_for_current_file() {
let env = Environment::test_env();
let mut diff = DiffComponent::new(&env, false);
diff.focus(true);
diff.current.path = String::from("src/main.rs");
let event = Event::Key(KeyEvent::new(
KeyCode::Char('e'),
KeyModifiers::empty(),
));
assert!(matches!(
diff.event(&event).unwrap(),
EventState::Consumed
));
let event = env.queue.pop();
assert!(matches!(
event,
Some(InternalEvent::OpenExternalEditor(Some(path)))
if path == "src/main.rs"
));
}
}

View file

@ -30,7 +30,10 @@ use ratatui::{
Frame,
};
use std::{borrow::Cow, fmt::Write};
use std::{collections::BTreeSet, path::Path};
use std::{
collections::BTreeSet,
path::{Path, PathBuf},
};
use unicode_truncate::UnicodeTruncateStr;
use unicode_width::UnicodeWidthStr;
@ -53,11 +56,15 @@ pub struct RevisionFilesComponent {
revision: Option<CommitInfo>,
focus: Focus,
key_config: SharedKeyConfig,
select_file: Option<PathBuf>,
}
impl RevisionFilesComponent {
///
pub fn new(env: &Environment) -> Self {
pub fn new(
env: &Environment,
select_file: Option<PathBuf>,
) -> Self {
Self {
queue: env.queue.clone(),
tree: FileTree::default(),
@ -72,6 +79,7 @@ impl RevisionFilesComponent {
focus: Focus::Tree,
key_config: env.key_config.clone(),
repo: env.repo.clone(),
select_file,
visible: false,
}
}
@ -134,6 +142,12 @@ impl RevisionFilesComponent {
self.tree.collapse_but_root();
self.files = Some(last);
let select_file = self.select_file.clone();
self.select_file = None;
if let Some(file) = select_file {
self.find_file(file.as_path());
}
}
} else if let Some(rev) = &self.revision {
self.request_files(rev.id);

View file

@ -58,7 +58,7 @@ impl StatusTreeComponent {
}
}
pub fn set_commit(&mut self, revision: Option<CommitId>) {
pub const fn set_commit(&mut self, revision: Option<CommitId>) {
self.revision = revision;
}
@ -92,12 +92,12 @@ impl StatusTreeComponent {
}
///
pub fn show_selection(&mut self, show: bool) {
pub const fn show_selection(&mut self, show: bool) {
self.show_selection = show;
}
/// returns true if list is empty
pub fn is_empty(&self) -> bool {
pub const fn is_empty(&self) -> bool {
self.tree.is_empty()
}
@ -208,7 +208,7 @@ impl StatusTreeComponent {
w = width as usize
)
} else {
format!(" {indent_str}{collapse_char}{string}",)
format!(" {indent_str}{collapse_char}{string}")
};
Some(Span::styled(

View file

@ -18,9 +18,9 @@ use ratatui::{
widgets::{Clear, Paragraph},
Frame,
};
use ratatui_textarea::{CursorMove, Input, Key, Scrolling, TextArea};
use std::cell::Cell;
use std::cell::OnceCell;
use tui_textarea::{CursorMove, Input, Key, Scrolling, TextArea};
///
#[derive(PartialEq, Eq)]
@ -122,17 +122,17 @@ impl TextInputComponent {
}
/// screen area (last time we got drawn)
pub fn get_area(&self) -> Rect {
pub const fn get_area(&self) -> Rect {
self.current_area.get()
}
/// embed into parent draw area
pub fn embed(&mut self) {
pub const fn embed(&mut self) {
self.embed = true;
}
///
pub fn enabled(&mut self, enable: bool) {
pub const fn enabled(&mut self, enable: bool) {
self.selected = Some(enable);
}

View file

@ -172,7 +172,7 @@ impl FileTreeItems {
}
///
pub(crate) fn len(&self) -> usize {
pub(crate) const fn len(&self) -> usize {
self.items.len()
}

View file

@ -18,7 +18,7 @@ impl HorizontalScroll {
}
}
pub fn get_right(&self) -> usize {
pub const fn get_right(&self) -> usize {
self.right.get()
}

View file

@ -20,7 +20,7 @@ impl VerticalScroll {
}
}
pub fn get_top(&self) -> usize {
pub const fn get_top(&self) -> usize {
self.top.get()
}

View file

@ -51,17 +51,13 @@ impl StatusTree {
let last_selection =
self.selected_item().map(|e| e.info.full_path);
let last_selection_index = self.selection.unwrap_or(0);
self.tree = FileTreeItems::new(list, &last_collapsed)?;
self.selection = last_selection.as_ref().map_or_else(
|| self.tree.items().first().map(|_| 0),
|last_selection| {
self.find_last_selection(
last_selection,
last_selection_index,
)
.or_else(|| self.tree.items().first().map(|_| 0))
self.find_last_selection(last_selection)
.or_else(|| self.tree.items().first().map(|_| 0))
},
);
@ -173,7 +169,7 @@ impl StatusTree {
}
///
pub fn is_empty(&self) -> bool {
pub const fn is_empty(&self) -> bool {
self.tree.items().is_empty()
}
@ -196,19 +192,18 @@ impl StatusTree {
fn find_last_selection(
&self,
last_selection: &str,
last_index: usize,
) -> Option<usize> {
if self.is_empty() {
return None;
}
if let Ok(i) = self.tree.items().binary_search_by(|e| {
let res = self.tree.items().binary_search_by(|e| {
e.info.full_path.as_str().cmp(last_selection)
}) {
return Some(i);
});
match res {
Ok(i) => Some(i),
Err(i) => Some(cmp::min(i, self.tree.len() - 1)),
}
Some(cmp::min(last_index, self.tree.len() - 1))
}
fn selection_updown(
@ -520,7 +515,7 @@ mod tests {
res.update(&string_vec_to_status(&["a", "b"])).unwrap();
res.selection = Some(1);
res.update(&string_vec_to_status(&["d", "c", "a"])).unwrap();
res.update(&string_vec_to_status(&["a", "c", "d"])).unwrap();
assert_eq!(res.selection, Some(1));
}
@ -545,6 +540,33 @@ mod tests {
assert_eq!(res.selection, Some(0));
}
#[test]
fn test_next_when_dir_disappears() {
let mut tree = StatusTree::default();
tree.update(&string_vec_to_status(&["a/b", "c", "d"]))
.unwrap();
tree.selection = Some(1);
assert_eq!(
tree.selected_item().unwrap().info.full_path,
"a/b"
);
tree.update(&string_vec_to_status(&["c", "d"])).unwrap();
assert_eq!(tree.selected_item().unwrap().info.full_path, "c");
}
#[test]
fn test_next_when_last_dir_disappears() {
let mut tree = StatusTree::default();
tree.update(&string_vec_to_status(&["a", "b", "c"]))
.unwrap();
tree.selection = Some(2);
assert_eq!(tree.selected_item().unwrap().info.full_path, "c");
tree.update(&string_vec_to_status(&["a", "b"])).unwrap();
assert_eq!(tree.selected_item().unwrap().info.full_path, "b");
}
#[test]
fn test_keep_collapsed_states() {
let mut res = StatusTree::default();

290
src/gitui.rs Normal file
View file

@ -0,0 +1,290 @@
use std::time::Instant;
use anyhow::Result;
use asyncgit::{sync::utils::repo_work_dir, AsyncGitNotification};
use crossbeam_channel::{never, tick, unbounded, Receiver};
use scopetime::scope_time;
#[cfg(test)]
use crossterm::event::{KeyCode, KeyModifiers};
use crate::{
app::{App, QuitState},
args::CliArgs,
draw,
input::{Input, InputEvent, InputState},
keys::KeyConfig,
select_event,
spinner::Spinner,
ui::style::Theme,
watcher::RepoWatcher,
AsyncAppNotification, AsyncNotification, QueueEvent, Updater,
SPINNER_INTERVAL, TICK_INTERVAL,
};
pub struct Gitui {
app: crate::app::App,
rx_input: Receiver<InputEvent>,
rx_git: Receiver<AsyncGitNotification>,
rx_app: Receiver<AsyncAppNotification>,
rx_ticker: Receiver<Instant>,
rx_watcher: Receiver<()>,
}
impl Gitui {
pub(crate) fn new(
cliargs: CliArgs,
theme: Theme,
key_config: &KeyConfig,
updater: Updater,
) -> Result<Self, anyhow::Error> {
let (tx_git, rx_git) = unbounded();
let (tx_app, rx_app) = unbounded();
let input = Input::new();
let (rx_ticker, rx_watcher) = match updater {
Updater::NotifyWatcher => {
let repo_watcher = RepoWatcher::new(
repo_work_dir(&cliargs.repo_path)?.as_str(),
);
(never(), repo_watcher.receiver())
}
Updater::Ticker => (tick(TICK_INTERVAL), never()),
};
let app = App::new(
cliargs,
tx_git,
tx_app,
input.clone(),
theme,
key_config.clone(),
)?;
Ok(Self {
app,
rx_input: input.receiver(),
rx_git,
rx_app,
rx_ticker,
rx_watcher,
})
}
pub(crate) fn run_main_loop<B: ratatui::backend::Backend>(
&mut self,
terminal: &mut ratatui::Terminal<B>,
) -> Result<QuitState, anyhow::Error>
where
<B as ratatui::backend::Backend>::Error:
'static + Send + Sync,
{
let spinner_ticker = tick(SPINNER_INTERVAL);
let mut spinner = Spinner::default();
let mut first_update = true;
self.app.update()?;
loop {
let event = if first_update {
first_update = false;
QueueEvent::Notify
} else {
select_event(
&self.rx_input,
&self.rx_git,
&self.rx_app,
&self.rx_ticker,
&self.rx_watcher,
&spinner_ticker,
)?
};
{
if matches!(event, QueueEvent::SpinnerUpdate) {
spinner.update();
spinner.draw(terminal)?;
continue;
}
scope_time!("loop");
match event {
QueueEvent::InputEvent(ev) => {
if matches!(
ev,
InputEvent::State(InputState::Polling)
) {
//Note: external ed closed, we need to re-hide cursor
terminal.hide_cursor()?;
}
self.app.event(ev)?;
}
QueueEvent::Tick | QueueEvent::Notify => {
self.app.update()?;
}
QueueEvent::AsyncEvent(ev) => {
if !matches!(
ev,
AsyncNotification::Git(
AsyncGitNotification::FinishUnchanged
)
) {
self.app.update_async(ev)?;
}
}
QueueEvent::SpinnerUpdate => unreachable!(),
}
self.draw(terminal)?;
spinner.set_state(self.app.any_work_pending());
spinner.draw(terminal)?;
if self.app.is_quit() {
break;
}
}
}
Ok(self.app.quit_state())
}
fn draw<B: ratatui::backend::Backend>(
&self,
terminal: &mut ratatui::Terminal<B>,
) -> Result<(), B::Error> {
draw(terminal, &self.app)
}
#[cfg(test)]
fn update_async(&mut self, event: crate::AsyncNotification) {
self.app.update_async(event).unwrap();
}
#[cfg(test)]
fn input_event(
&mut self,
code: KeyCode,
modifiers: KeyModifiers,
) {
let event = crossterm::event::KeyEvent::new(code, modifiers);
self.app
.event(crate::input::InputEvent::Input(
crossterm::event::Event::Key(event),
))
.unwrap();
}
#[cfg(test)]
fn wait_for_async_git_notification(
&self,
expected: AsyncGitNotification,
) {
loop {
let actual = self
.rx_git
.recv_timeout(std::time::Duration::from_millis(100))
.unwrap();
if actual == expected {
break;
}
}
}
#[cfg(test)]
fn update(&mut self) {
self.app.update().unwrap();
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use asyncgit::{sync::RepoPath, AsyncGitNotification};
use crossterm::event::{KeyCode, KeyModifiers};
use git2_testing::repo_init_suffix;
use insta::assert_snapshot;
use ratatui::{backend::TestBackend, Terminal};
use crate::{
args::CliArgs, gitui::Gitui, keys::KeyConfig,
ui::style::Theme, AsyncNotification, Updater,
};
// Macro adapted from: https://insta.rs/docs/cmd/
macro_rules! apply_common_filters {
{} => {
let mut settings = insta::Settings::clone_current();
// Windows and MacOS
// We don't match on the full path, but on the suffix we pass to `repo_init_suffix` below.
settings.add_filter(r" *\[…\]\S+-insta/?", "[TEMP_FILE]");
// Linux Temp Folder
settings.add_filter(r" */tmp/\.tmp\S+-insta/", "[TEMP_FILE]");
// Commit ids that follow a vertical bar
settings.add_filter(r"│[a-z0-9]{7} ", "│[AAAAA] ");
let _bound = settings.bind_to_scope();
}
}
#[test]
fn gitui_starts() {
apply_common_filters!();
let (temp_dir, _repo) = repo_init_suffix(Some("-insta"));
let path: RepoPath = temp_dir.path().to_str().unwrap().into();
let cliargs = CliArgs {
theme: PathBuf::from("theme.ron"),
select_file: None,
repo_path: path,
notify_watcher: false,
key_bindings_path: None,
key_symbols_path: None,
};
let theme = Theme::init(&PathBuf::new());
let key_config = KeyConfig::default();
let mut gitui =
Gitui::new(cliargs, theme, &key_config, Updater::Ticker)
.unwrap();
let mut terminal =
Terminal::new(TestBackend::new(90, 12)).unwrap();
gitui.draw(&mut terminal).unwrap();
assert_snapshot!("app_loading", terminal.backend());
let event =
AsyncNotification::Git(AsyncGitNotification::Status);
gitui.update_async(event);
gitui.draw(&mut terminal).unwrap();
assert_snapshot!("app_loading_finished", terminal.backend());
gitui.input_event(KeyCode::Char('2'), KeyModifiers::empty());
gitui.input_event(
key_config.keys.tab_log.code,
key_config.keys.tab_log.modifiers,
);
gitui.wait_for_async_git_notification(
AsyncGitNotification::Log,
);
gitui.update();
gitui.draw(&mut terminal).unwrap();
assert_snapshot!(
"app_log_tab_showing_one_commit",
terminal.backend()
);
}
}

View file

@ -12,7 +12,7 @@ use std::{
};
static FAST_POLL_DURATION: Duration = Duration::from_millis(100);
static SLOW_POLL_DURATION: Duration = Duration::from_millis(10000);
static SLOW_POLL_DURATION: Duration = Duration::from_secs(10);
///
#[derive(Clone, Copy, Debug)]

View file

@ -34,9 +34,21 @@ impl KeyConfig {
.map_or_else(|_| Ok(symbols_file), Ok)
}
pub fn init() -> Result<Self> {
let keys = KeysList::init(Self::get_config_file()?);
let symbols = KeySymbols::init(Self::get_symbols_file()?);
pub fn init(
key_bindings_path: Option<&PathBuf>,
key_symbols_path: Option<&PathBuf>,
) -> Result<Self> {
let keys = KeysList::init(
key_bindings_path
.unwrap_or(&Self::get_config_file()?)
.clone(),
);
let symbols = KeySymbols::init(
key_symbols_path
.unwrap_or(&Self::get_symbols_file()?)
.clone(),
);
Ok(Self { keys, symbols })
}
@ -185,7 +197,7 @@ mod tests {
// testing
let result = std::panic::catch_unwind(|| {
let loaded_config = KeyConfig::init().unwrap();
let loaded_config = KeyConfig::init(None, None).unwrap();
assert_eq!(
loaded_config.keys.move_down,
KeysList::default().move_down
@ -200,7 +212,7 @@ mod tests {
&original_key_symbols_path,
)
.unwrap();
let loaded_config = KeyConfig::init().unwrap();
let loaded_config = KeyConfig::init(None, None).unwrap();
assert_eq!(
loaded_config.keys.move_down,
KeysList::default().move_down
@ -212,7 +224,7 @@ mod tests {
&original_key_list_path,
)
.unwrap();
let loaded_config = KeyConfig::init().unwrap();
let loaded_config = KeyConfig::init(None, None).unwrap();
assert_eq!(
loaded_config.keys.move_down,
GituiKeyEvent::new(
@ -223,7 +235,7 @@ mod tests {
assert_eq!(loaded_config.symbols.esc, "Esc");
fs::remove_file(&original_key_symbols_path).unwrap();
let loaded_config = KeyConfig::init().unwrap();
let loaded_config = KeyConfig::init(None, None).unwrap();
assert_eq!(
loaded_config.keys.move_down,
GituiKeyEvent::new(

View file

@ -128,6 +128,7 @@ pub struct KeysList {
pub commit_history_next: GituiKeyEvent,
pub commit: GituiKeyEvent,
pub newline: GituiKeyEvent,
pub goto_line: GituiKeyEvent,
}
#[rustfmt::skip]
@ -225,6 +226,7 @@ impl Default for KeysList {
commit_history_next: GituiKeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL),
commit: GituiKeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL),
newline: GituiKeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
goto_line: GituiKeyEvent::new(KeyCode::Char('L'), KeyModifiers::SHIFT),
}
}
}

View file

@ -65,6 +65,7 @@ mod bug_report;
mod clipboard;
mod cmdbar;
mod components;
mod gitui;
mod input;
mod keys;
mod notify_mutex;
@ -79,15 +80,15 @@ mod tabs;
mod ui;
mod watcher;
use crate::{app::App, args::process_cmdline};
use crate::{
app::App,
args::{process_cmdline, CliArgs},
};
use anyhow::{anyhow, bail, Result};
use app::QuitState;
use asyncgit::{
sync::{utils::repo_work_dir, RepoPath},
AsyncGitNotification,
};
use asyncgit::{sync::RepoPath, AsyncGitNotification};
use backtrace::Backtrace;
use crossbeam_channel::{never, tick, unbounded, Receiver, Select};
use crossbeam_channel::{Receiver, Select};
use crossterm::{
terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen,
@ -95,21 +96,18 @@ use crossterm::{
},
ExecutableCommand,
};
use input::{Input, InputEvent, InputState};
use gitui::Gitui;
use input::InputEvent;
use keys::KeyConfig;
use ratatui::backend::CrosstermBackend;
use scopeguard::defer;
use scopetime::scope_time;
use spinner::Spinner;
use std::{
cell::RefCell,
io::{self, Stdout},
panic,
path::Path,
time::{Duration, Instant},
};
use ui::style::Theme;
use watcher::RepoWatcher;
type Terminal = ratatui::Terminal<CrosstermBackend<io::Stdout>>;
@ -168,9 +166,12 @@ fn main() -> Result<()> {
asyncgit::register_tracing_logging();
ensure_valid_path(&cliargs.repo_path)?;
let key_config = KeyConfig::init()
.map_err(|e| log_eprintln!("KeyConfig loading error: {e}"))
.unwrap_or_default();
let key_config = KeyConfig::init(
cliargs.key_bindings_path.as_ref(),
cliargs.key_symbols_path.as_ref(),
)
.map_err(|e| log_eprintln!("KeyConfig loading error: {e}"))
.unwrap_or_default();
let theme = Theme::init(&cliargs.theme);
setup_terminal()?;
@ -180,9 +181,8 @@ fn main() -> Result<()> {
set_panic_handler()?;
let mut repo_path = cliargs.repo_path;
let mut terminal = start_terminal(io::stdout(), &repo_path)?;
let input = Input::new();
let mut terminal =
start_terminal(io::stdout(), &cliargs.repo_path)?;
let updater = if cliargs.notify_watcher {
Updater::NotifyWatcher
@ -190,20 +190,28 @@ fn main() -> Result<()> {
Updater::Ticker
};
let mut args = cliargs;
loop {
let quit_state = run_app(
app_start,
repo_path.clone(),
args.clone(),
theme.clone(),
key_config.clone(),
&input,
&key_config,
updater,
&mut terminal,
)?;
match quit_state {
QuitState::OpenSubmodule(p) => {
repo_path = p;
args = CliArgs {
repo_path: p,
select_file: None,
theme: args.theme,
notify_watcher: args.notify_watcher,
key_bindings_path: args.key_bindings_path,
key_symbols_path: args.key_symbols_path,
}
}
_ => break,
}
@ -214,107 +222,17 @@ fn main() -> Result<()> {
fn run_app(
app_start: Instant,
repo: RepoPath,
cliargs: CliArgs,
theme: Theme,
key_config: KeyConfig,
input: &Input,
key_config: &KeyConfig,
updater: Updater,
terminal: &mut Terminal,
) -> Result<QuitState, anyhow::Error> {
let (tx_git, rx_git) = unbounded();
let (tx_app, rx_app) = unbounded();
let rx_input = input.receiver();
let (rx_ticker, rx_watcher) = match updater {
Updater::NotifyWatcher => {
let repo_watcher =
RepoWatcher::new(repo_work_dir(&repo)?.as_str());
(never(), repo_watcher.receiver())
}
Updater::Ticker => (tick(TICK_INTERVAL), never()),
};
let spinner_ticker = tick(SPINNER_INTERVAL);
let mut app = App::new(
RefCell::new(repo),
tx_git,
tx_app,
input.clone(),
theme,
key_config,
)?;
let mut spinner = Spinner::default();
let mut first_update = true;
let mut gitui = Gitui::new(cliargs, theme, key_config, updater)?;
log::trace!("app start: {} ms", app_start.elapsed().as_millis());
loop {
let event = if first_update {
first_update = false;
QueueEvent::Notify
} else {
select_event(
&rx_input,
&rx_git,
&rx_app,
&rx_ticker,
&rx_watcher,
&spinner_ticker,
)?
};
{
if matches!(event, QueueEvent::SpinnerUpdate) {
spinner.update();
spinner.draw(terminal)?;
continue;
}
scope_time!("loop");
match event {
QueueEvent::InputEvent(ev) => {
if matches!(
ev,
InputEvent::State(InputState::Polling)
) {
//Note: external ed closed, we need to re-hide cursor
terminal.hide_cursor()?;
}
app.event(ev)?;
}
QueueEvent::Tick | QueueEvent::Notify => {
app.update()?;
}
QueueEvent::AsyncEvent(ev) => {
if !matches!(
ev,
AsyncNotification::Git(
AsyncGitNotification::FinishUnchanged
)
) {
app.update_async(ev)?;
}
}
QueueEvent::SpinnerUpdate => unreachable!(),
}
draw(terminal, &app)?;
spinner.set_state(app.any_work_pending());
spinner.draw(terminal)?;
if app.is_quit() {
break;
}
}
}
Ok(app.quit_state())
gitui.run_main_loop(terminal)
}
fn setup_terminal() -> Result<()> {
@ -338,7 +256,10 @@ fn shutdown_terminal() {
}
}
fn draw(terminal: &mut Terminal, app: &App) -> io::Result<()> {
fn draw<B: ratatui::backend::Backend>(
terminal: &mut ratatui::Terminal<B>,
app: &App,
) -> Result<(), B::Error> {
if app.requires_redraw() {
terminal.clear()?;
}

View file

@ -116,7 +116,7 @@ impl Options {
self.save();
}
pub fn has_commit_msg_history(&self) -> bool {
pub const fn has_commit_msg_history(&self) -> bool {
!self.data.commit_msgs.is_empty()
}

View file

@ -234,6 +234,16 @@ impl Component for BlameFilePopup {
)
.order(1),
);
out.push(
CommandInfo::new(
strings::commands::open_line_number_popup(
&self.key_config,
),
true,
has_result,
)
.order(1),
);
}
visibility_blocking(self)
@ -307,6 +317,22 @@ impl Component for BlameFilePopup {
),
));
}
} else if key_match(
key,
self.key_config.keys.goto_line,
) {
let maybe_blame_result = &self
.blame
.as_ref()
.and_then(|blame| blame.result());
if let Some(blame_result) = maybe_blame_result {
let max_line = blame_result.lines().len() - 1;
self.queue.push(
InternalEvent::OpenGotoLinePopup(
max_line,
),
);
}
}
return Ok(EventState::Consumed);
@ -742,6 +768,14 @@ impl BlameFilePopup {
})
}
pub fn goto_line(&mut self, line: usize) {
self.visible = true;
let mut table_state = self.table_state.take();
table_state
.select(Some(line.clamp(0, self.get_max_line_number())));
self.table_state.set(table_state);
}
fn selected_commit(&self) -> Option<CommitId> {
self.blame
.as_ref()

View file

@ -20,8 +20,9 @@ use asyncgit::{
checkout_remote_branch, BranchDetails, LocalBranch,
RemoteBranch,
},
checkout_branch, get_branches_info, BranchInfo, BranchType,
CommitId, RepoPathRef, RepoState,
checkout_branch, get_branches_info,
status::StatusType,
BranchInfo, BranchType, CommitId, RepoPathRef, RepoState,
},
AsyncGitNotification,
};
@ -306,8 +307,7 @@ impl BranchListPopup {
if self.visible {
self.has_remotes =
get_branches_info(&self.repo.borrow(), false)
.map(|branches| !branches.is_empty())
.unwrap_or(false);
.is_ok_and(|branches| !branches.is_empty());
}
}
@ -341,7 +341,7 @@ impl BranchListPopup {
Ok(())
}
fn valid_selection(&self) -> bool {
const fn valid_selection(&self) -> bool {
!self.branches.is_empty()
}
@ -582,22 +582,35 @@ impl BranchListPopup {
anyhow::bail!("no valid branch selected");
}
if self.local {
checkout_branch(
&self.repo.borrow(),
&self.branches[self.selection as usize].name,
)?;
self.hide();
} else {
checkout_remote_branch(
&self.repo.borrow(),
&self.branches[self.selection as usize],
)?;
self.local = true;
self.update_branches()?;
}
let status = sync::status::get_status(
&self.repo.borrow(),
StatusType::WorkingDir,
None,
)
.expect("Could not get status");
self.queue.push(InternalEvent::Update(NeedsUpdate::ALL));
let selected_branch = &self.branches[self.selection as usize];
if status.is_empty() {
if self.local {
checkout_branch(
&self.repo.borrow(),
&selected_branch.name,
)?;
self.hide();
} else {
checkout_remote_branch(
&self.repo.borrow(),
selected_branch,
)?;
self.local = true;
self.update_branches()?;
}
self.queue.push(InternalEvent::Update(NeedsUpdate::ALL));
} else {
self.queue.push(InternalEvent::CheckoutOption(
selected_branch.clone(),
));
}
Ok(())
}

View file

@ -0,0 +1,239 @@
use crate::components::{
visibility_blocking, CommandBlocking, CommandInfo, Component,
DrawableComponent, EventState,
};
use crate::queue::{InternalEvent, NeedsUpdate};
use crate::strings::CheckoutOptions;
use crate::try_or_popup;
use crate::{
app::Environment,
keys::{key_match, SharedKeyConfig},
queue::Queue,
strings,
ui::{self, style::SharedTheme},
};
use anyhow::{Ok, Result};
use asyncgit::sync::branch::checkout_remote_branch;
use asyncgit::sync::status::discard_status;
use asyncgit::sync::{checkout_branch, BranchInfo, RepoPath};
use crossterm::event::Event;
use ratatui::{
layout::{Alignment, Rect},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
Frame,
};
pub struct CheckoutOptionPopup {
queue: Queue,
repo: RepoPath,
branch: Option<BranchInfo>,
option: CheckoutOptions,
visible: bool,
key_config: SharedKeyConfig,
theme: SharedTheme,
}
impl CheckoutOptionPopup {
///
pub fn new(env: &Environment) -> Self {
Self {
queue: env.queue.clone(),
repo: env.repo.borrow().clone(),
branch: None,
option: CheckoutOptions::KeepLocalChanges,
visible: false,
key_config: env.key_config.clone(),
theme: env.theme.clone(),
}
}
fn get_text(&self, _width: u16) -> Vec<Line<'_>> {
let mut txt: Vec<Line> = Vec::with_capacity(10);
txt.push(Line::from(vec![
Span::styled(
String::from("Switch to: "),
self.theme.text(true, false),
),
Span::styled(
self.branch.as_ref().expect("No branch").name.clone(),
self.theme.commit_hash(false),
),
]));
let (kind_name, kind_desc) = self.option.to_string_pair();
txt.push(Line::from(vec![
Span::styled(
String::from("How: "),
self.theme.text(true, false),
),
Span::styled(kind_name, self.theme.text(true, true)),
Span::styled(kind_desc, self.theme.text(true, false)),
]));
txt
}
///
pub fn open(&mut self, branch: BranchInfo) -> Result<()> {
self.show()?;
self.branch = Some(branch);
Ok(())
}
fn checkout(&self) -> Result<()> {
if let Some(branch) = &self.branch {
if branch.is_local() {
checkout_branch(&self.repo, &branch.name)?;
} else {
checkout_remote_branch(&self.repo, branch)?;
}
}
Ok(())
}
fn handle_event(&mut self) -> Result<()> {
match self.option {
CheckoutOptions::KeepLocalChanges => {
self.checkout()?;
}
CheckoutOptions::DiscardAllLocalChagnes => {
discard_status(&self.repo)?;
self.checkout()?;
}
}
self.queue.push(InternalEvent::Update(NeedsUpdate::ALL));
self.queue.push(InternalEvent::SelectBranch);
self.hide();
Ok(())
}
const fn change_kind(&mut self, incr: bool) {
self.option = if incr {
self.option.next()
} else {
self.option.previous()
};
}
}
impl DrawableComponent for CheckoutOptionPopup {
fn draw(&self, f: &mut Frame, area: Rect) -> Result<()> {
if self.is_visible() {
const SIZE: (u16, u16) = (55, 4);
let area =
ui::centered_rect_absolute(SIZE.0, SIZE.1, area);
let width = area.width;
f.render_widget(Clear, area);
f.render_widget(
Paragraph::new(self.get_text(width))
.block(
Block::default()
.borders(Borders::ALL)
.title(Span::styled(
"Checkout options",
self.theme.title(true),
))
.border_style(self.theme.block(true)),
)
.alignment(Alignment::Left),
area,
);
}
Ok(())
}
}
impl Component for CheckoutOptionPopup {
fn commands(
&self,
out: &mut Vec<CommandInfo>,
force_all: bool,
) -> CommandBlocking {
if self.is_visible() || force_all {
out.push(
CommandInfo::new(
strings::commands::close_popup(&self.key_config),
true,
true,
)
.order(1),
);
out.push(
CommandInfo::new(
strings::commands::reset_commit(&self.key_config),
true,
true,
)
.order(1),
);
out.push(
CommandInfo::new(
strings::commands::reset_type(&self.key_config),
true,
true,
)
.order(1),
);
}
visibility_blocking(self)
}
fn event(
&mut self,
event: &crossterm::event::Event,
) -> Result<EventState> {
if self.is_visible() {
if let Event::Key(key) = &event {
if key_match(key, self.key_config.keys.exit_popup) {
self.hide();
} else if key_match(
key,
self.key_config.keys.move_down,
) {
self.change_kind(true);
} else if key_match(key, self.key_config.keys.move_up)
{
self.change_kind(false);
} else if key_match(key, self.key_config.keys.enter) {
try_or_popup!(
self,
"checkout error:",
self.handle_event()
);
}
}
return Ok(EventState::Consumed);
}
Ok(EventState::NotConsumed)
}
fn is_visible(&self) -> bool {
self.visible
}
fn hide(&mut self) {
self.visible = false;
}
fn show(&mut self) -> Result<()> {
self.visible = true;
Ok(())
}
}

View file

@ -336,6 +336,7 @@ impl CommitPopup {
Ok(())
}
fn signoff_commit(&mut self) {
let msg = self.input.get_text();
let signed_msg = self.add_sign_off(msg);
@ -343,7 +344,8 @@ impl CommitPopup {
self.input.set_text(signed_msg);
}
}
fn toggle_verify(&mut self) {
const fn toggle_verify(&mut self) {
self.verify = !self.verify;
}
@ -446,7 +448,7 @@ impl CommitPopup {
msg_source,
&mut msg,
)? {
log::error!("prepare-commit-msg hook rejection: {e}",);
log::error!("prepare-commit-msg hook rejection: {e}");
}
self.input.set_text(msg);

View file

@ -131,7 +131,7 @@ impl CreateBranchPopup {
Err(e) => {
log::error!("create branch: {e}");
self.queue.push(InternalEvent::ShowErrorMsg(
format!("create branch error:\n{e}",),
format!("create branch error:\n{e}"),
));
}
}

View file

@ -202,7 +202,7 @@ impl CreateRemotePopup {
Err(e) => {
log::error!("create remote: {e}");
self.queue.push(InternalEvent::ShowErrorMsg(
format!("create remote error:\n{e}",),
format!("create remote error:\n{e}"),
));
}
}

167
src/popups/goto_line.rs Normal file
View file

@ -0,0 +1,167 @@
use crate::{
app::Environment,
components::{
visibility_blocking, CommandBlocking, CommandInfo, Component,
DrawableComponent, EventState,
},
keys::{key_match, SharedKeyConfig},
queue::{InternalEvent, Queue},
strings,
ui::{self, style::SharedTheme},
};
use ratatui::{
layout::Rect,
style::{Color, Style},
widgets::{Block, Clear, Paragraph},
Frame,
};
use anyhow::Result;
use crossterm::event::{Event, KeyCode};
pub struct GotoLinePopup {
visible: bool,
input: String,
line_number: usize,
key_config: SharedKeyConfig,
queue: Queue,
theme: SharedTheme,
invalid_input: bool,
max_line: usize,
}
impl GotoLinePopup {
pub fn new(env: &Environment) -> Self {
Self {
visible: false,
input: String::new(),
key_config: env.key_config.clone(),
queue: env.queue.clone(),
theme: env.theme.clone(),
invalid_input: false,
max_line: 0,
line_number: 0,
}
}
pub const fn open(&mut self, max_line: usize) {
self.visible = true;
self.max_line = max_line;
}
}
impl Component for GotoLinePopup {
///
fn commands(
&self,
out: &mut Vec<CommandInfo>,
force_all: bool,
) -> CommandBlocking {
if self.is_visible() || force_all {
out.push(
CommandInfo::new(
strings::commands::close_popup(&self.key_config),
true,
true,
)
.order(1),
);
out.push(
CommandInfo::new(
strings::commands::goto_line(&self.key_config),
true,
true,
)
.order(1),
);
}
visibility_blocking(self)
}
fn is_visible(&self) -> bool {
self.visible
}
///
fn event(&mut self, event: &Event) -> Result<EventState> {
if self.is_visible() {
if let Event::Key(key) = event {
if key_match(key, self.key_config.keys.exit_popup) {
self.visible = false;
self.input.clear();
} else if let KeyCode::Char(c) = key.code {
if c.is_ascii_digit() || c == '-' {
self.input.push(c);
}
} else if key.code == KeyCode::Backspace {
self.input.pop();
} else if key_match(key, self.key_config.keys.enter) {
self.visible = false;
if self.invalid_input {
self.queue.push(InternalEvent::ShowErrorMsg(
format!("Invalid input: only numbers between -{} and {} (included) are allowed (-1 denotes the last line, -2 denotes the second to last line, and so on)",self.max_line + 1, self.max_line))
,
);
} else if !self.input.is_empty() {
self.queue.push(InternalEvent::GotoLine(
self.line_number,
));
}
self.input.clear();
self.invalid_input = false;
}
}
match self.input.parse::<isize>() {
Ok(input) => {
let mut max_value_allowed_abs = self.max_line;
// negative indices are 1 based
if input < 0 {
max_value_allowed_abs += 1;
}
let input_abs = input.unsigned_abs();
if input_abs > max_value_allowed_abs {
self.invalid_input = true;
} else {
self.invalid_input = false;
self.line_number = if input >= 0 {
input_abs
} else {
max_value_allowed_abs - input_abs
}
}
}
Err(_) => {
if !self.input.is_empty() {
self.invalid_input = true;
}
}
}
return Ok(EventState::Consumed);
}
Ok(EventState::NotConsumed)
}
}
impl DrawableComponent for GotoLinePopup {
fn draw(&self, f: &mut Frame, area: Rect) -> Result<()> {
if self.is_visible() {
let style = if self.invalid_input {
Style::default().fg(Color::Red)
} else {
self.theme.text(true, false)
};
let input = Paragraph::new(self.input.as_str())
.style(style)
.block(Block::bordered().title("Go to"));
let input_area = ui::centered_rect_absolute(15, 3, area);
f.render_widget(Clear, input_area);
f.render_widget(input, input_area);
}
Ok(())
}
}

View file

@ -225,7 +225,7 @@ impl LogSearchPopupPopup {
),
)]),
Line::from(vec![Span::styled(
format!("[{x_summary}] summary",),
format!("[{x_summary}] summary"),
self.theme.text(
matches!(
self.selection,
@ -235,7 +235,7 @@ impl LogSearchPopupPopup {
),
)]),
Line::from(vec![Span::styled(
format!("[{x_body}] message body",),
format!("[{x_body}] message body"),
self.theme.text(
matches!(
self.selection,
@ -245,7 +245,7 @@ impl LogSearchPopupPopup {
),
)]),
Line::from(vec![Span::styled(
format!("[{x_files}] committed files",),
format!("[{x_files}] committed files"),
self.theme.text(
matches!(
self.selection,
@ -255,7 +255,7 @@ impl LogSearchPopupPopup {
),
)]),
Line::from(vec![Span::styled(
format!("[{x_authors}] authors",),
format!("[{x_authors}] authors"),
self.theme.text(
matches!(
self.selection,
@ -315,7 +315,7 @@ impl LogSearchPopupPopup {
}
}
fn move_selection(&mut self, arg: bool) {
const fn move_selection(&mut self, arg: bool) {
if arg {
//up
self.selection = match self.selection {

View file

@ -1,5 +1,6 @@
mod blame_file;
mod branchlist;
mod checkout_option;
mod commit;
mod compare_commits;
mod confirm;
@ -9,6 +10,7 @@ mod externaleditor;
mod fetch;
mod file_revlog;
mod fuzzy_find;
mod goto_line;
mod help;
mod inspect_commit;
mod log_search;
@ -30,6 +32,7 @@ mod update_remote_url;
pub use blame_file::{BlameFileOpen, BlameFilePopup};
pub use branchlist::BranchListPopup;
pub use checkout_option::CheckoutOptionPopup;
pub use commit::CommitPopup;
pub use compare_commits::CompareCommitsPopup;
pub use confirm::ConfirmPopup;
@ -39,6 +42,7 @@ pub use externaleditor::ExternalEditorPopup;
pub use fetch::FetchPopup;
pub use file_revlog::{FileRevOpen, FileRevlogPopup};
pub use fuzzy_find::FuzzyFindPopup;
pub use goto_line::GotoLinePopup;
pub use help::HelpPopup;
pub use inspect_commit::{InspectCommitOpen, InspectCommitPopup};
pub use log_search::LogSearchPopupPopup;

View file

@ -134,7 +134,7 @@ impl OptionsPopup {
]));
}
fn move_selection(&mut self, up: bool) {
const fn move_selection(&mut self, up: bool) {
if up {
self.selection = match self.selection {
AppOption::StatusShowUntracked => {

View file

@ -145,9 +145,16 @@ impl PushPopup {
};
// run pre push hook - can reject push
if let HookResult::NotOk(e) =
hooks_pre_push(&self.repo.borrow())?
{
let repo = self.repo.borrow();
if let HookResult::NotOk(e) = hooks_pre_push(
&repo,
&remote,
&asyncgit::sync::PrePushTarget::Branch {
branch: &self.branch,
delete: self.modifier.delete(),
},
cred.clone(),
)? {
log::error!("pre-push hook failed: {e}");
self.queue.push(InternalEvent::ShowErrorMsg(format!(
"pre-push hook failed:\n{e}"

View file

@ -84,10 +84,15 @@ impl PushTagsPopup {
&mut self,
cred: Option<BasicAuthCredential>,
) -> Result<()> {
// run pre push hook - can reject push
if let HookResult::NotOk(e) =
hooks_pre_push(&self.repo.borrow())?
{
let remote = get_default_remote(&self.repo.borrow())?;
let repo = self.repo.borrow();
if let HookResult::NotOk(e) = hooks_pre_push(
&repo,
&remote,
&asyncgit::sync::PrePushTarget::Tags,
cred.clone(),
)? {
log::error!("pre-push hook failed: {e}");
self.queue.push(InternalEvent::ShowErrorMsg(format!(
"pre-push hook failed:\n{e}"
@ -100,7 +105,7 @@ impl PushTagsPopup {
self.pending = true;
self.progress = None;
self.git_push.request(PushTagsRequest {
remote: get_default_remote(&self.repo.borrow())?,
remote,
basic_credential: cred,
})?;
Ok(())

View file

@ -146,12 +146,14 @@ impl Component for RemoteListPopup {
} else if key_match(
e,
self.key_config.keys.update_remote_name,
) {
) && self.valid_selection()
{
self.rename_remote();
} else if key_match(
e,
self.key_config.keys.update_remote_url,
) {
) && self.valid_selection()
{
self.update_remote_url();
}
}
@ -411,7 +413,7 @@ impl RemoteListPopup {
Ok(true)
}
fn valid_selection(&self) -> bool {
const fn valid_selection(&self) -> bool {
!self.remote_names.is_empty()
&& self.remote_names.len() >= self.selection as usize
}

View file

@ -140,7 +140,7 @@ impl RenameBranchPopup {
Err(e) => {
log::error!("create branch: {e}");
self.queue.push(InternalEvent::ShowErrorMsg(
format!("rename branch error:\n{e}",),
format!("rename branch error:\n{e}"),
));
}
}

View file

@ -163,7 +163,7 @@ impl RenameRemotePopup {
Err(e) => {
log::error!("rename remote: {e}");
self.queue.push(InternalEvent::ShowErrorMsg(
format!("rename remote error:\n{e}",),
format!("rename remote error:\n{e}"),
));
}
}

View file

@ -120,7 +120,7 @@ impl ResetPopup {
///
#[allow(clippy::unnecessary_wraps)]
pub fn update(&mut self) -> Result<()> {
self.git_branch_name.lookup().map(Some).unwrap_or(None);
self.git_branch_name.lookup().ok();
Ok(())
}
@ -137,7 +137,7 @@ impl ResetPopup {
self.hide();
}
fn change_kind(&mut self, incr: bool) {
const fn change_kind(&mut self, incr: bool) {
self.kind = if incr {
match self.kind {
ResetType::Soft => ResetType::Mixed,

View file

@ -38,7 +38,7 @@ impl RevisionFilesPopup {
///
pub fn new(env: &Environment) -> Self {
Self {
files: RevisionFilesComponent::new(env),
files: RevisionFilesComponent::new(env, None),
visible: false,
key_config: env.key_config.clone(),
open_request: None,

View file

@ -138,7 +138,7 @@ impl StashMsgPopup {
}
///
pub fn options(&mut self, options: StashingOptions) {
pub const fn options(&mut self, options: StashingOptions) {
self.options = options;
}
}

View file

@ -317,8 +317,8 @@ impl SubmodulesListPopup {
}
fn set_selection(&mut self, selection: u16) -> Result<()> {
let num_entriess: u16 = self.submodules.len().try_into()?;
let num_entries = num_entriess.saturating_sub(1);
let num_entries: u16 = self.submodules.len().try_into()?;
let num_entries = num_entries.saturating_sub(1);
let selection = if selection > num_entries {
num_entries

View file

@ -196,7 +196,7 @@ impl TagCommitPopup {
log::error!("e: {e}");
self.queue.push(InternalEvent::ShowErrorMsg(
format!("tag error:\n{e}",),
format!("tag error:\n{e}"),
));
}
}

View file

@ -312,8 +312,7 @@ impl TagListPopup {
self.has_remotes =
sync::get_branches_info(&self.repo.borrow(), false)
.map(|branches| !branches.is_empty())
.unwrap_or(false);
.is_ok_and(|branches| !branches.is_empty());
let basic_credential = if self.has_remotes {
if need_username_password(&self.repo.borrow())? {

View file

@ -140,7 +140,7 @@ impl UpdateRemoteUrlPopup {
Err(e) => {
log::error!("update remote url: {e}");
self.queue.push(InternalEvent::ShowErrorMsg(
format!("update remote url error:\n{e}",),
format!("update remote url error:\n{e}"),
));
}
}

View file

@ -8,7 +8,8 @@ use crate::{
};
use asyncgit::{
sync::{
diff::DiffLinePosition, CommitId, LogFilterSearchOptions,
diff::DiffLinePosition, BranchInfo, CommitId,
LogFilterSearchOptions,
},
PushType,
};
@ -157,6 +158,12 @@ pub enum InternalEvent {
RewordCommit(CommitId),
///
CommitSearch(LogFilterSearchOptions),
///
OpenGotoLinePopup(usize),
///
GotoLine(usize),
///
CheckoutOption(BranchInfo),
}
/// single threaded simple queue for components to communicate with each other

View file

@ -0,0 +1,17 @@
---
source: src/gitui.rs
expression: terminal.backend()
snapshot_kind: text
---
" Status [1] | Log [2] | Files [3] | Stashing [4] | Stashes [5][TEMP_FILE] "
" ──────────────────────────────────────────────────────────────────────────────────────── "
"┌Unstaged Changes───────────────────────────┐┌Diff: ─────────────────────────────────────┐"
"│Loading ... ││ │"
"│ ││ │"
"│ ││ │"
"└───────────────────────────────────{master}┘│ │"
"┌Staged Changes─────────────────────────────┐│ │"
"│Loading ... ││ │"
"│ ││ │"
"└───────────────────────────────────────────┘└───────────────────────────────────────────┘"
" "

View file

@ -0,0 +1,17 @@
---
source: src/gitui.rs
expression: terminal.backend()
snapshot_kind: text
---
" Status [1] | Log [2] | Files [3] | Stashing [4] | Stashes [5][TEMP_FILE] "
" ──────────────────────────────────────────────────────────────────────────────────────── "
"┌Unstaged Changes───────────────────────────┐┌Diff: ─────────────────────────────────────┐"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"└───────────────────────────────────{master}┘│ │"
"┌Staged Changes─────────────────────────────┐│ │"
"│ ││ │"
"│ ││ │"
"└───────────────────────────────────────────┘└───────────────────────────────────────────┘"
"Branches [b] Push [p] Fetch [⇧F] Pull [f] Undo Commit [⇧U] Submodules [⇧S] more [.]"

View file

@ -0,0 +1,17 @@
---
source: src/gitui.rs
expression: terminal.backend()
snapshot_kind: text
---
" Status [1] | Log [2] | Files [3] | Stashing [4] | Stashes [5][TEMP_FILE] "
" ──────────────────────────────────────────────────────────────────────────────────────── "
"┌Commit 1/1──────────────────────────────────────────────────────────────────────────────┐"
"│[AAAAA] <1m ago name initial █"
"│ ║"
"│ ║"
"│ ║"
"│ ║"
"│ ║"
"│ ║"
"└────────────────────────────────────────────────────────────────────────────────────────┘"
"Scroll [↑↓] Mark [˽] Details [⏎] Branches [b] Compare [⇧C] Copy Hash [y] Tag [t] more [.]"

View file

@ -1,8 +1,5 @@
use ratatui::{
backend::{Backend, CrosstermBackend},
Terminal,
};
use std::{cell::Cell, char, io};
use ratatui::{backend::Backend, Terminal};
use std::{cell::Cell, char};
// static SPINNER_CHARS: &[char] = &['◢', '◣', '◤', '◥'];
// static SPINNER_CHARS: &[char] = &['⢹', '⢺', '⢼', '⣸', '⣇', '⡧', '⡗', '⡏'];
@ -34,15 +31,15 @@ impl Spinner {
}
///
pub fn set_state(&mut self, active: bool) {
pub const fn set_state(&mut self, active: bool) {
self.active = active;
}
/// draws or removes spinner char depending on `pending` state
pub fn draw(
pub fn draw<B: ratatui::backend::Backend>(
&self,
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> io::Result<()> {
terminal: &mut Terminal<B>,
) -> Result<(), B::Error> {
let idx = self.idx;
let char_to_draw =

View file

@ -438,6 +438,46 @@ pub fn ellipsis_trim_start(s: &str, width: usize) -> Cow<'_, str> {
}
}
#[derive(PartialEq, Eq, Clone, Copy)]
pub enum CheckoutOptions {
KeepLocalChanges,
DiscardAllLocalChagnes,
}
impl CheckoutOptions {
pub const fn previous(self) -> Self {
match self {
Self::KeepLocalChanges => Self::DiscardAllLocalChagnes,
Self::DiscardAllLocalChagnes => Self::KeepLocalChanges,
}
}
pub const fn next(self) -> Self {
match self {
Self::KeepLocalChanges => Self::DiscardAllLocalChagnes,
Self::DiscardAllLocalChagnes => Self::KeepLocalChanges,
}
}
pub const fn to_string_pair(
self,
) -> (&'static str, &'static str) {
const CHECKOUT_OPTION_UNCHANGE: &str =
" 🟡 Keep local changes";
const CHECKOUT_OPTION_DISCARD: &str =
" 🔴 Discard all local changes";
match self {
Self::KeepLocalChanges => {
("Don't change", CHECKOUT_OPTION_UNCHANGE)
}
Self::DiscardAllLocalChagnes => {
("Discard", CHECKOUT_OPTION_DISCARD)
}
}
}
}
pub mod commit {
use crate::keys::SharedKeyConfig;
@ -1449,6 +1489,18 @@ pub mod commands {
CMD_GROUP_LOG,
)
}
pub fn open_line_number_popup(
key_config: &SharedKeyConfig,
) -> CommandText {
CommandText::new(
format!(
"Go to [{}]",
key_config.get_hint(key_config.keys.goto_line),
),
"go to a given line number in the blame view",
CMD_GROUP_GENERAL,
)
}
pub fn log_tag_commit(
key_config: &SharedKeyConfig,
) -> CommandText {
@ -1870,4 +1922,15 @@ pub mod commands {
CMD_GROUP_LOG,
)
}
pub fn goto_line(key_config: &SharedKeyConfig) -> CommandText {
CommandText::new(
format!(
"Go To [{}]",
key_config.get_hint(key_config.keys.enter),
),
"Go to the given line",
CMD_GROUP_GENERAL,
)
}
}

View file

@ -1,4 +1,4 @@
use std::path::Path;
use std::path::{Path, PathBuf};
use crate::{
app::Environment,
@ -19,10 +19,13 @@ pub struct FilesTab {
impl FilesTab {
///
pub fn new(env: &Environment) -> Self {
pub fn new(
env: &Environment,
select_file: Option<PathBuf>,
) -> Self {
Self {
visible: false,
files: RevisionFilesComponent::new(env),
files: RevisionFilesComponent::new(env, select_file),
repo: env.repo.clone(),
}
}

View file

@ -55,7 +55,7 @@ impl StashList {
}
Err(e) => {
self.queue.push(InternalEvent::ShowErrorMsg(
format!("stash apply error:\n{e}",),
format!("stash apply error:\n{e}"),
));
}
}

View file

@ -382,7 +382,7 @@ impl Status {
///
pub fn update(&mut self) -> Result<()> {
self.git_branch_name.lookup().map(Some).unwrap_or(None);
let _ = self.git_branch_name.lookup().ok();
if self.is_visible() {
let config =

View file

@ -161,7 +161,10 @@ impl<'a, 'b> LineTruncator<'a, 'b> {
}
}
pub fn set_horizontal_offset(&mut self, horizontal_offset: u16) {
pub const fn set_horizontal_offset(
&mut self,
horizontal_offset: u16,
) {
self.horizontal_offset = horizontal_offset;
}
}

View file

@ -70,7 +70,7 @@ impl ParagraphState {
self.scroll
}
pub fn set_scroll(&mut self, scroll: ScrollPos) {
pub const fn set_scroll(&mut self, scroll: ScrollPos) {
self.scroll = scroll;
}
}