11 Commits

Author SHA1 Message Date
CI Bot 9f0fe71c3e chore(release): bump version to 1.1.1-dev [skip ci] 2026-06-14 16:18:46 +00:00
CI Bot 2bb7a46a43 chore(release): release 1.1.0 [skip ci] 2026-06-14 16:17:26 +00:00
phundrak 4ad6e944b2 ci(nix): don’t put a zip in a zip
Run checks and build archives / build (linux-aarch64) (push) Successful in 1m39s
Run checks and build archives / build (linux-x86_64) (push) Successful in 1m34s
Release / release (push) Successful in 5m4s
Release / build (linux-x86_64) (push) Failing after 2m19s
Release / build (linux-aarch64) (push) Failing after 2m23s
Run checks and build archives / coverage-and-sonar (push) Successful in 8m26s
Run checks and build archives / build (windows-x86_64) (push) Successful in 1m38s
Release / build (windows-x86_64) (push) Failing after 1m34s
2026-06-14 17:20:28 +02:00
phundrak bd4aaff3f3 feat(references): add ticket reference footers
Run checks and build archives / build (linux-x86_64) (push) Has been cancelled
Run checks and build archives / build (windows-x86_64) (push) Has been cancelled
Run checks and build archives / build (linux-aarch64) (push) Has been cancelled
Run checks and build archives / coverage-and-sonar (push) Successful in 5m29s
Refs: #4
2026-06-14 17:13:01 +02:00
phundrak e6ac6890b2 docs(README): update the README to reflect new features
Refs: #11
2026-06-14 17:13:01 +02:00
phundrak e45130d31b feat: implement --new flag
The new `--new` or `-n` flag allows to create a new revision after the
single revision being described. Running `jj-cz --new` is the
equivalent of running `jj-cz @ && jj new`. Running `jj-cz --new xs` is
the equivalent of running `jj-cz xs && jj new xs`.

Passing several revisions to `jj-cz` with the `--new` flag will result
in an error.

Refs: #6
2026-06-14 16:56:07 +02:00
phundrak 0e6b559d00 chore(deps): upgrade jj-lib to 0.42.0
Run checks and build archives / coverage-and-sonar (push) Successful in 7m31s
Run checks and build archives / build (windows-x86_64) (push) Successful in 6m19s
Run checks and build archives / build (linux-aarch64) (push) Successful in 4m45s
Run checks and build archives / build (linux-x86_64) (push) Successful in 8m13s
2026-06-07 16:16:47 +02:00
phundrak c1c25e33ff chore(nix): temporary use of cargoHash instead of cargoLock.lockFile
Run checks and build archives / coverage-and-sonar (push) Successful in 6m57s
Run checks and build archives / build (linux-aarch64) (push) Successful in 1m56s
Run checks and build archives / build (windows-x86_64) (push) Successful in 1m38s
Run checks and build archives / build (linux-x86_64) (push) Successful in 1m46s
2026-05-28 23:05:19 +02:00
phundrak 8142aee605 ci(nix): add archive packages and overhaul CI workflows 2026-05-28 23:04:48 +02:00
phundrak 6a702ec205 feat(nix): simplify flake.nix, remove devenv 2026-05-28 23:04:45 +02:00
phundrak 412a056e70 chore(deps): upgrade to jj-lib 0.41.0 2026-05-28 21:42:16 +02:00
33 changed files with 1633 additions and 999 deletions
+1 -3
View File
@@ -4,8 +4,6 @@ if ! has nix_direnv_version || ! nix_direnv_version 3.1.0; then
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.1.0/direnvrc" "sha256-yMJ2OVMzrFaDPn7q8nCBZFRYpL/f0RcHzhmw/i6btJM=" source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.1.0/direnvrc" "sha256-yMJ2OVMzrFaDPn7q8nCBZFRYpL/f0RcHzhmw/i6btJM="
fi fi
export DEVENV_IN_DIRENV_SHELL=true
# Load .env file if present # Load .env file if present
dotenv_if_exists dotenv_if_exists
@@ -20,5 +18,5 @@ if [[ -f .envrc.local ]]; then
fi fi
if ! use flake . --no-pure-eval; then if ! use flake . --no-pure-eval; then
echo "Devenv could not be built. The devenv environment was not loaded. Make the necessary changes to flake.nix and hit enter to try again." >&2 echo "Development shell could not be built. The environment was not loaded. Make the necessary changes to flake.nix and hit enter to try again." >&2
fi fi
+31 -27
View File
@@ -1,4 +1,4 @@
name: Publish Docker Images name: Run checks and build archives
on: on:
push: push:
@@ -6,7 +6,7 @@ on:
- main - main
- develop - develop
tags: tags:
- 'v*.*.*' - "v*.*.*"
pull_request: pull_request:
types: [opened, synchronize, reopened] types: [opened, synchronize, reopened]
@@ -56,32 +56,36 @@ jobs:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
- name: Build Linux release binary build:
run: nix build --no-pure-eval --accept-flake-config needs: coverage-and-sonar
strategy:
matrix:
target: ["linux-x86_64", "linux-aarch64", "windows-x86_64"]
- name: Prepare Linux binary runs-on: ubuntu-latest
run: | permissions:
mkdir dist-linux contents: read
cp result/bin/jj-cz dist-linux/ pull-requests: read
cp LICENSE.*.md dist-linux/ steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Upload Linux artifact - name: Install Nix
uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Set up cachix
uses: cachix/cachix-action@v17
with:
name: phundrak
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: Build jj-cz archive
run: nix build .#${{matrix.target}}-archive
- name: Upload artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: jj-cz-x86_64-unknown-linux-gnu name: jj-cz-${{matrix.target}}
path: dist-linux/* path: result/dist/*
- name: Build Windows release binary
run: nix build .#windows --no-pure-eval --accept-flake-config
- name: Prepare Windows binary
run: |
mkdir -p dist-windows
cp result/bin/jj-cz.exe dist-windows/
cp LICENSE.*.md dist-windows/
- name: Upload Windows artifact
uses: actions/upload-artifact@v3
with:
name: jj-cz-x86_64-pc-windows-gnu
path: dist-windows/*
+66 -38
View File
@@ -6,29 +6,12 @@ on:
- main - main
jobs: jobs:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Nix
uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Set up cachix
uses: cachix/cachix-action@v17
with:
name: phundrak
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: Run Checks
shell: bash -c "nix develop --no-pure-eval --accept-flake-config --command {0}"
run: just check-all
release: release:
needs: checks
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs:
release: ${{ steps.releasable.outputs.release }}
release_id: ${{ steps.create_release.outputs.release_id }}
version: ${{ steps.next_version.outputs.version }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
@@ -49,58 +32,103 @@ jobs:
- name: Check for releasable commits - name: Check for releasable commits
id: releasable id: releasable
run: | run: |
COUNT=$(nix develop --no-pure-eval --command just cliff-count) COUNT=$(nix develop --no-pure-eval --accept-flake-config --command just cliff-count)
echo "count=$COUNT" >> $GITHUB_OUTPUT if [ "$COUNT" -gt 0 ]; then
echo "release=true" >> $GITHUB_OUTPUT
else
echo "release=false" >> $GITHUB_OUTPUT
fi
- name: Determine next version - name: Determine next version
if: steps.releasable.outputs.count > 0 if: steps.releasable.outputs.release == 'true'
id: next_version id: next_version
run: | run: |
CLIFF_NEXT_VERSION=$(nix develop --no-pure-eval --command just cliff-next-version) CLIFF_NEXT_VERSION=$(nix develop --no-pure-eval --accept-flake-config --command just cliff-next-version)
echo "version=$CLIFF_NEXT_VERSION" >> $GITHUB_OUTPUT echo "version=$CLIFF_NEXT_VERSION" >> $GITHUB_OUTPUT
- name: Update changelog - name: Update changelog
if: steps.releasable.outputs.count > 0 if: steps.releasable.outputs.release == 'true'
shell: bash -c "nix develop --no-pure-eval --accept-flake-config --command {0}" shell: bash -c "nix develop --no-pure-eval --accept-flake-config --command {0}"
run: just cliff-bump run: just cliff-bump
- name: Create release commit - name: Create release commit
if: steps.releasable.outputs.count > 0 if: steps.releasable.outputs.release == 'true'
env: env:
VERSION: ${{ steps.next_version.outputs.version }} VERSION: ${{ steps.next_version.outputs.version }}
shell: bash -c "nix develop --no-pure-eval --accept-flake-config --command {0}" shell: bash -c "nix develop --no-pure-eval --accept-flake-config --command {0}"
run: just commit-release $VERSION run: just commit-release $VERSION
- name: Create version tag - name: Create version tag
if: steps.releasable.outputs.count > 0 if: steps.releasable.outputs.release == 'true'
env: env:
VERSION: ${{ steps.next_version.outputs.version }} VERSION: ${{ steps.next_version.outputs.version }}
shell: bash -c "nix develop --no-pure-eval --accept-flake-config --command {0}" shell: bash -c "nix develop --no-pure-eval --accept-flake-config --command {0}"
run: just create-release-tag $VERSION run: just create-release-tag $VERSION
- name: Build Linux release binaries - name: Create Gitea release
if: steps.releasable.outputs.count > 0 if: steps.releasable.outputs.release == 'true'
run: nix build id: create_release
env:
- name: Build Windows release binaries VERSION: ${{ steps.next_version.outputs.version }}
if: steps.releasable.outputs.count > 0 CI_TOKEN: ${{ secrets.CI_TOKEN }}
run: nix build .#windows shell: bash -c "nix develop --no-pure-eval --accept-flake-config --command {0}"
run: |
RESPONSE=$(curl -s -X POST \
-H "Authorization: token $CI_TOKEN" \
-H "Content-Type: application/json" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases" \
-d "{\"tag_name\": \"v${VERSION}\", \"name\": \"v${VERSION}\"}")
echo "release_id=$(echo "$RESPONSE" | jq -r '.id')" >> $GITHUB_OUTPUT
- name: Publish on crates.io - name: Publish on crates.io
if: steps.releasable.outputs.count > 0 if: steps.releasable.outputs.release == 'true'
env: env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
shell: bash -c "nix develop --no-pure-eval --accept-flake-config --command {0}" shell: bash -c "nix develop --no-pure-eval --accept-flake-config --command {0}"
run: cargo publish run: cargo publish
- name: Rebase develop onto main - name: Rebase develop onto main
if: steps.releasable.outputs.count > 0 if: steps.releasable.outputs.release == 'true'
shell: bash -c "nix develop --no-pure-eval --accept-flake-config --command {0}" shell: bash -c "nix develop --no-pure-eval --accept-flake-config --command {0}"
run: just rebase-develop run: just rebase-develop
- name: Bump to next dev version - name: Bump to next dev version
if: steps.releasable.outputs.count > 0 if: steps.releasable.outputs.release == 'true'
env: env:
VERSION: ${{ steps.next_version.outputs.version }} VERSION: ${{ steps.next_version.outputs.version }}
shell: bash -c "nix develop --no-pure-eval --accept-flake-config --command {0}" shell: bash -c "nix develop --no-pure-eval --accept-flake-config --command {0}"
run: just update-develop-version $VERSION run: just update-develop-version $VERSION
build:
needs: release
if: needs.release.outputs.release == 'true'
strategy:
matrix:
target: ["linux-x86_64", "linux-aarch64", "windows-x86_64"]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Nix
uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Set up cachix
uses: cachix/cachix-action@v17
with:
name: phundrak
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: Build jj-cz archive
run: nix build .#${{ matrix.target }}-archive
- name: Upload release asset
env:
CI_TOKEN: ${{ secrets.CI_TOKEN }}
RELEASE_ID: ${{ needs.release.outputs.release_id }}
run: |
curl -s -X POST \
-H "Authorization: token $CI_TOKEN" \
-F "attachment=@$(ls result/dist/*.zip)" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets"
+32
View File
@@ -1,3 +1,35 @@
## [1.1.0] - 2026-06-14
### Features
- *(errors)* Preserve jj-emitted errors when loading config
- *(cli)* Add jj-lib version to version output
- *(nix)* Simplify flake.nix, remove devenv
- Implement --new flag
- *(references)* Add ticket reference footers
### Bug Fixes
- *(scope)* No new string allocation to count characters
### Refactor
- *(nix)* Simplify package declaration
- *(workflow)* Remove unnecessary async declarations
- *(BreakingChange)* Rename method ignore to is_absent
- *(prompter)* Simplify commit type selection
### Documentation
- *(contributing)* Clarifying and expanding AI requirements
- *(README)* Update the README to reflect new features
### Miscellaneous Tasks
- *(jj-lib)* Upgrade to jj-lib 0.40.0
- *(nix)* Add archive packages and overhaul CI workflows
- *(nix)* Temporary use of cargoHash instead of cargoLock.lockFile
- *(nix)* Dont put a zip in a zip
## [1.0.0] - 2026-03-25 ## [1.0.0] - 2026-03-25
### Features ### Features
Generated
+246 -437
View File
File diff suppressed because it is too large Load Diff
+12 -11
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "jj-cz" name = "jj-cz"
version = "1.0.1-dev" version = "1.1.1-dev"
description = "Conventional commits for Jujutsu" description = "Conventional commits for Jujutsu"
edition = "2024" edition = "2024"
publish = true publish = true
@@ -24,21 +24,22 @@ test-utils = []
[dependencies] [dependencies]
async-trait = "0.1.89" async-trait = "0.1.89"
etcetera = "0.11.0" etcetera = "0.11.0"
clap = { version = "4.5.57", features = ["derive"] } clap = { version = "4.6.1", features = ["derive"] }
git-conventional = "0.12.9" git-conventional = "1.1.0"
inquire = { version = "0.9.2", features = ["editor"] } inquire = { version = "0.9.4", features = ["editor"] }
jj-lib = "0.40.0" jj-lib = "0.42.0"
lazy-regex = { version = "3.5.1", features = ["lite"] } lazy-regex = { version = "3.6.0", features = ["lite"] }
thiserror = "2.0.18" thiserror = "2.0.18"
tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] } tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread"] }
textwrap = "0.16.2" textwrap = "0.16.2"
unicode-width = "0.2.2" unicode-width = "0.2.2"
chrono = "0.4.44" chrono = "0.4.45"
futures-util = "0.3.32"
[dev-dependencies] [dev-dependencies]
assert_cmd = "2.1.2" assert_cmd = "2.2.2"
assert_fs = "1.1.3" assert_fs = "1.1.4"
predicates = "3.1.3" predicates = "3.1.4"
[lints.rust] [lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] } unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] }
+77 -7
View File
@@ -1,13 +1,45 @@
# jj-cz: Conventional Commits for Jujutsu ---
include_toc: true
gitea: none
---
An interactive CLI tool that guides Jujutsu users through creating
[conventional commit](https://www.conventionalcommits.org/) messages. <h1 align="center">jj-cz: Conventional Commits for Jujutsu</h1>
<div align="center">
<strong>
An interactive CLI tool that guides Jujutsu users through creating <a href="https://www.conventionalcommits.org/" rel="noopener">conventional commit</a> messages.
</strong>
</div>
<br/>
<div align="center">
<!-- CI -->
<a href="https://labs.phundrak.com/phundrak/jj-cz/actions?workflow=action.yml&branch=develop">
<img src="https://labs.phundrak.com/phundrak/jj-cz/actions/workflows/action.yml/badge.svg?branch=develop" alt="actions status" />
</a>
<!-- Crates.io -->
<a href="https://crates.io/crates/sqlx">
<img src="https://img.shields.io/crates/v/jj-cz.svg" alt="Crates.io version"/>
</a>
<!-- License -->
<a href="#license">
<img src="https://img.shields.io/badge/License-MIT-blue" alt="MIT License" />
</a>
<a href="#license">
<img src="https://img.shields.io/badge/License-GPL--3.0--or--later-blue" alt="GPL License" />
</a>
<!-- Tools -->
<a href="https://www.gnu.org/software/emacs/" target="_blank">
<img src="https://img.shields.io/badge/Made%20with-GNU%2FEmacs-blueviolet.svg?logo=GNU%20Emacs&logoColor=white" alt="Made with GNU/Emacs" />
</a>
</div>
[![demo](assets/demo.gif)](assets/demo.cast)
## Features ## Features
- Interactive prompts for type, scope, and description - Interactive prompts for type, scope, breaking changes, ticket references, and description
- All 11 commit types with descriptions (feat, fix, docs, style, - All 11 commit types with descriptions (feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert)
refactor, perf, test, build, ci, chore, revert)
- Optional scope with validation - Optional scope with validation
- 72-character first-line limit enforcement - 72-character first-line limit enforcement
- Preview before applying - Preview before applying
@@ -28,12 +60,23 @@ You can also set the revision message of a few revisions at once, or
target a single revision other than the current one. target a single revision other than the current one.
```sh ```sh
jj-cz @- xs develop jj-cz @- xs develop # assuming the revision xs and the bookmark develop exist
``` ```
No explicit revision is simply the equivalent of `jj-cz @`, like No explicit revision is simply the equivalent of `jj-cz @`, like
`jj desc`. `jj desc`.
If you want to create a new revision after calling `jj-cz` on a single
revision, you can use the `-n` or `--new` flag.
```sh
jj-cz -n # equivalent of `jj-cz && jj new`
jj-cz xs -n # equivalent of `jj-cz xs && jj new xs`
jj-cz -n xs # equivalent of `jj-cz xs && jj new xs`
```
You cannot, however, call `jj-cz` on multiple revisions with the `--new` flag active.
## Requirements ## Requirements
- A Jujutsu repository - A Jujutsu repository
@@ -93,3 +136,30 @@ Just make sure Rust is available on your machine (duh!).
```sh ```sh
cargo install --path . cargo install --path .
``` ```
## Tips and questions
### Running `jj cz` instead of `jj-cz`
I do not actually use `jj-cz`, but `jj cz`. I just find it more
natural to treat it as its own jj subcommand. To achieve that, you can
simply add an alias to your jujutsu configuration.
```toml
[aliases]
cz = ["utils", "exec", "--", "jj-cz"]
```
### `$EDITOR` and editing the revisions body message
`jj-cz` relies on your `$EDITOR` variable to open a temporary file in
which youll write the body of your commit. This body does not include
some footers `jj-cz` may include by itself, such as the breaking
change footer.
In some cases, you may not notice a new editor open. In this case,
check whether you already have an editor open, the file might be
there. In my case, if I already have an open Emacsclient, it will open
there.
## License
This project is licensed under either the [MIT](LICENSE.MIT.md) or [GPL-3.0](LICENSE.GPL.md) licenses, as you prefer.
+190
View File
@@ -0,0 +1,190 @@
{"version":3,"term":{"cols":120,"rows":18,"type":"screen-256color","version":"tmux 3.6a","theme":{"fg":"#d8dee9","bg":"#2e3440","palette":"#3b4252:#bf616a:#a3be8c:#ebcb8b:#81a1c1:#b48ead:#88c0d0:#e5e9f0:#4c566a:#bf616a:#a3be8c:#d08770:#5e81ac:#b48ead:#8fbcbb:#eceff4"}},"timestamp":1781441421,"env":{"SHELL":"bash --norc"}}
[0.003, "o", "bash-5.3$ "]
[2.269, "o", "j"]
[0.149, "o", "j"]
[0.256, "o", " "]
[0.075, "o", "s"]
[0.120, "o", "h"]
[0.149, "o", "o"]
[0.135, "o", "w"]
[0.225, "o", "\r\n"]
[0.079, "o", "\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\u001b[?2004h\u001b[>4;2m"]
[0.001, "o", "\u001b[?25l\u001b[HCommit ID: \u001b[34mb6890ed4d3c203026c4b6c349c61c918486732e4\u001b[39m\u001b[K\u001b[2;1HChange ID: \u001b[35mymmvxvqykryrsxmnxzttymztwtkpltqz\u001b[39m\u001b[K\u001b[3;1HAuthor : \u001b[33mLucien Cartier-Tilet\u001b[39m <\u001b[33mlucien@phundrak.com\u001b[39m> (\u001b[36m2026-06-14 14:31:57\u001b[39m)\u001b[K\u001b[4;1HCommitter: \u001b[33mLucien Cartier-Tilet\u001b[39m <\u001b[33mlucien@phundrak.com\u001b[39m> (\u001b[36m2026-06-14 14:50:24\u001b[39m)\u001b[K\u001b[5;1HSignature: \u001b[32mgood\u001b[39m signature by \u001b[33mlucien@phundrak.com\u001b[39m \u001b[36mSHA256:CE0HPsbW3L2YiJETx1zYZ2muMptaAqTN2g3498KrMkc\u001b[39m\u001b[K\u001b[6;1H\u001b[K\u001b[7;1H\u001b[33m (no description set)\u001b[39m\u001b[K\u001b[8;1H\u001b[K\u001b[9;1H\u001b[33mModified regular file README.md:\u001b[39m\u001b[K\u001b[10;1H ...\u001b[K\u001b[11;1H\u001b[0;2m\u000f\u001b[31m 3\u001b[0m\u000f \u001b[0;2m\u000f\u001b[32m 3\u001b[0m\u000f: An interactive CLI tool that guides Jujutsu users through creating\u001b[K\u001b[12;1H\u001b[0;2m\u000f\u001b[31m 4\u001b[0m\u000f \u001b[0;2m\u000f\u001b[32m 4\u001b[0m\u000f: [conventional commit](https://www.conventionalcommits.org/) messages.\u001b[K\u001b[13;1H\u001b[0;2m\u000f\u001b[31m 5\u001b[0m\u000f \u001b[0;2m\u000f\u001b[32m 5\u001b[0m\u000f: \u001b[K\u001b[14;1H \u001b[32m 6\u001b[39m: \u001b[0;4m\u000f\u001b[32m[![asciicast](assets/demo.cast)](assets/demo.cast)\u001b[0m\u000f\u001b[K\u001b[15;1H \u001b[32m 7\u001b[39m: \u001b[K\u001b[16;1H\u001b[0;2m\u000f\u001b[31m 6\u001b[0m\u000f \u001b[0;2m\u000f\u001b[32m 8\u001b[0m\u000f: ## Features\u001b[K\u001b[17;1H\u001b[0;2m\u000f\u001b[31m 7\u001b[0m\u000f \u001b[0;2m\u000f\u001b[32m 9\u001b[0m\u000f: \u001b[K\u001b[18;1H\u001b[30m\u001b[47m wrap lines 1- 17/ 84 \u001b[39m\u001b[K\r\u001b[49m"]
[1.270, "o", "\u001b[34h\u001b[?25h\u001b[1;18r\u001b[18;1H\u001b[J\u001b[34h\u001b[?25h\u001b[?2004l\u001b[>4;0m"]
[0.002, "o", "bash-5.3$ "]
[0.403, "o", "j"]
[0.137, "o", "j"]
[0.284, "o", " "]
[0.584, "o", "\b \b"]
[0.046, "o", "-"]
[0.360, "o", "c"]
[0.089, "o", "z"]
[0.316, "o", "\r\n"]
[0.027, "o", "\u001b[?2004h"]
[0.000, "o", "\u001b[?25l\u001b[38;5;10m?\u001b[39m Select commit type: \r\n\u001b[38;5;14m>\u001b["]
[0.000, "o", "39m \u001b[38;5;14"]
[0.000, "o", "m"]
[0.000, "o", "feat\u001b[39m\r\n\u001b[38;5;14m \u001b[39m fix\r\n\u001b[38;5;14m \u001b[39m"]
[0.000, "o", " docs\r\n\u001b["]
[0.000, "o", "38;5;14m \u001b[39m style\r\n\u001b[38;5;14m "]
[0.000, "o", "\u001b[39m refactor\r\n\u001b[38;5;14m \u001b[39m perf"]
[0.000, "o", "\r\n\u001b["]
[0.000, "o", "38;5;14m "]
[0.000, "o", "\u001b[39m test"]
[0.000, "o", "\r\n\u001b[38;"]
[0.000, "o", "5;14m"]
[0.000, "o", " \u001b[39m build\r"]
[0.000, "o", "\n\u001b[38;5;14"]
[0.000, "o", "m \u001b["]
[0.000, "o", "39m"]
[0.000, "o", " ci\r"]
[0.000, "o", "\n"]
[0.000, "o", "\u001b[38;5;14m \u001b[39"]
[0.000, "o", "m "]
[0.000, "o", "chore"]
[0.000, "o", "\r\n\u001b["]
[0.000, "o", "38;5;14m"]
[0.000, "o", " \u001b["]
[0.000, "o", "39m revert\r"]
[0.000, "o", "\n\u001b[38;5;14m"]
[0.000, "o", "["]
[0.000, "o", "\u001b[39"]
[0.000, "o", "m\u001b["]
[0.000, "o", "38;5;14mUse arrow keys to navigate, Enter to select. See https://www.conventionalcommits.org/ for details.\u001b[39m\u001b[38;"]
[0.000, "o", "5;14m]\u001b[39"]
[0.000, "o", "m\r\u001b[12"]
[0.000, "o", "A\u001b[22"]
[0.000, "o", "C\u001b[?25h"]
[0.377, "o", "\u001b[?25l\u001b[22D\u001b[38;5;10m?\u001b[39m Select commit type: d \u001b[K\r\n\u001b[38;5;14m>\u001b[39m \u001b[38;5;14mdocs\u001b[39m\u001b[K"]
[0.000, "o", "\r\n\u001b[38;5;14m \u001b[39m build\u001b[K\r\n\u001b[38;5;14m[\u001b[39m\u001b[38;5;14mUse arrow keys to navigate, Enter to select. See https://www.conventionalcommits.org/ for details."]
[0.000, "o", "\u001b[39m\u001b[38;5;14m]\u001b[39m\u001b[K\r"]
[0.000, "o", "\n\u001b[2K\r\n\u001b[2K\r\n\u001b[2K\r\n\u001b[2K\r\n"]
[0.000, "o", "\u001b[2K\r\n\u001b[2K"]
[0.000, "o", "\r\n\u001b[2K\r\n\u001b[2K"]
[0.000, "o", "\r\n"]
[0.000, "o", "\u001b[2K\r\u001b["]
[0.000, "o", "12A\u001b[23"]
[0.000, "o", "C\u001b[?25h"]
[0.091, "o", "\u001b[?25l\u001b[23D\u001b[38;5;10m?\u001b[39m Select commit type: do \u001b[K\r\n\r\n\u001b[38;5;14m[\u001b[39m\u001b[38;5;14mUse arrow keys to navigate, Enter to select. See https://www.conventionalcommits.org/ for details.\u001b[39m\u001b[38;5;14m]\u001b[39m\u001b[K\r\n"]
[0.000, "o", "\u001b[2K\r\u001b[3A\u001b[24C\u001b[?25h"]
[0.119, "o", "\u001b[?25l\u001b[24D\u001b[38;5;10m?\u001b[39m Select commit type: doc \u001b[K\r\n\r\n\r\u001b[2A\u001b[25C\u001b[?25h"]
[0.391, "o", "\u001b[?25l\u001b[25D\u001b[38;5;10m>\u001b[39m Select commit type: \u001b[38;5;14mdocs: Documentation only changes\u001b[39m\u001b[K\r\n\u001b[2K\r\n\u001b[2K\r\n\u001b[?25h"]
[0.000, "o", "\u001b[2A\u001b[?25h\u001b[?2004l\u001b[?2004h"]
[0.000, "o", "\u001b[?25l\u001b[38;5;10m?\u001b[39m Enter scope (optional): \u001b[38;"]
[0.000, "o", "5;8mLeave empty if no scope\u001b[39"]
[0.000, "o", "m \r\n\u001b[38;5;14m[\u001b[39m\u001b[38;5;14mScope is optional. If provided, it should be a noun representing the section of code affected (e.g., 'api', 'ui', 'conf\u001b[39m"]
[0.000, "o", "\r\n"]
[0.000, "o", "\u001b[38;5;14mig'). Max 30 characters.\u001b["]
[0.000, "o", "39m"]
[0.000, "o", "\u001b[38;5;14m]\u001b[39m\r\u001b["]
[0.000, "o", "2A\u001b["]
[0.000, "o", "26C\u001b[?25h"]
[1.140, "o", "\u001b[?25l\u001b[26D\u001b[38;5;10m?\u001b[39"]
[0.000, "o", "m Enter scope (optional): R \u001b[K\r\n\r\n\r\u001b[2A\u001b[27C\u001b[?25h"]
[0.000, "o", "\u001b[?25l\u001b[27D\u001b[38;5;10m?\u001b[39m Enter scope (optional): RE \u001b[K\r\n\r\n\r\u001b["]
[0.000, "o", "2A\u001b[28C\u001b[?25h"]
[0.076, "o", "\u001b[?25l\u001b[28D\u001b[38;5;10m?\u001b[39m Enter scope (optional): REA \u001b[K\r\n\r\n\r\u001b[2A\u001b[29C\u001b[?25h"]
[0.089, "o", "\u001b[?25l\u001b[29D\u001b[38;5;10m?\u001b[39m Enter scope (optional): READ \u001b[K\r\n\r\n\r\u001b[2A\u001b[30C\u001b[?25h"]
[0.090, "o", "\u001b[?25l\u001b[30D\u001b[38;5;10m?\u001b[39m Enter scope (optional): READM \u001b[K\r\n\r\n\r\u001b[2A\u001b[31C\u001b[?25h"]
[0.272, "o", "\u001b[?25l\u001b[31D\u001b[38;5;10m?\u001b[39m Enter scope (optional): README \u001b[K\r\n\r\n\r\u001b[2A\u001b[32C\u001b[?25h"]
[0.748, "o", "\u001b[?25l\u001b[32D\u001b[38;5;10m>\u001b[39m Enter scope (optional): \u001b[38;5;14mREADME\u001b[39m\u001b[K\r\n\u001b[2K\r\n\u001b[2K\r\n\u001b[?25h\u001b[2A\u001b[?25h\u001b[?2004l"]
[0.000, "o", "\u001b[?2004h"]
[0.000, "o", "\u001b[?25l\u001b[38;5;10m?\u001b[39m Enter description (required): \r\n\u001b[38;5;14"]
[0.001, "o", "m[\u001b[39m\u001b[38;5;14mDescription is required. Short summary in imperative mood (e.g., 'add feature', 'fix bug'). Soft limit: 50 characters.\u001b[39m\u001b[38;5;14m]\u001b[39m\r\u001b[1A\u001b[32C\u001b[?25h"]
[1.229, "o", "\u001b[?25l\u001b[32D\u001b[38;5;10m?\u001b[39m Enter description (required): u \u001b[K\r\n\r\u001b[1A\u001b[33C\u001b[?25h"]
[0.090, "o", "\u001b[?25l\u001b[33D\u001b[38;5;10m?\u001b[39m Enter description (required): up \u001b[K\r\n\r\u001b[1A\u001b[34C\u001b[?25h"]
[0.076, "o", "\u001b[?25l\u001b[34D\u001b[38;5;10m"]
[0.000, "o", "?\u001b[39m Enter description (required): upd \u001b[K\r\n\r\u001b[1A\u001b[35C\u001b[?25h"]
[0.134, "o", "\u001b[?25l\u001b[35D\u001b[38;5;10m?\u001b[39m Enter description (required): upda \u001b[K\r\n\r\u001b[1A\u001b[36C\u001b[?25h"]
[0.075, "o", "\u001b[?25l\u001b[36D\u001b[38;5;10m?\u001b[39m Enter description (required): updat \u001b[K\r\n\r\u001b[1A\u001b[37C\u001b[?25h"]
[0.225, "o", "\u001b[?25l\u001b[37D\u001b[38;5;10m?\u001b[39m Enter description (required): update \u001b[K\r\n\r\u001b[1A\u001b[38C\u001b[?25h"]
[0.090, "o", "\u001b[?25l\u001b[38D\u001b[38;5;10m?\u001b[39m Enter description (required): update \u001b[K\r\n\r\u001b[1A\u001b[39C\u001b[?25h"]
[0.255, "o", "\u001b[?25l\u001b[39D\u001b[38;5;10m?\u001b[39m Enter description (required): update t \u001b[K\r\n\r\u001b[1A\u001b[40C\u001b[?25h\u001b[?25l\u001b[40D\u001b[38;5;10m?\u001b[39m Enter description (required): update th \u001b[K\r\n\r\u001b[1"]
[0.000, "o", "A\u001b[41C\u001b[?25h"]
[0.000, "o", "\u001b[?25l\u001b[41D\u001b[38;5;10m?\u001b[39m Enter description (required): update the \u001b[K\r\n\r\u001b[1A\u001b[42C\u001b[?25h"]
[0.166, "o", "\u001b[?25l\u001b[42D\u001b[38;5;10m?\u001b[39"]
[0.000, "o", "m Enter description (required): update the \u001b[K\r\n\r\u001b[1A\u001b[43C\u001b[?25h"]
[0.329, "o", "\u001b[?25l\u001b[43D\u001b[38;5;10m?\u001b[39m Enter description (required): update the R \u001b[K\r\n\r\u001b[1A\u001b[44C\u001b[?25h\u001b[?25l\u001b[44D\u001b[38;5;10m?\u001b[39m Enter description (required):"]
[0.000, "o", " update the RE \u001b[K\r\n\r\u001b[1A\u001b[45C\u001b[?25h"]
[0.075, "o", "\u001b[?25l\u001b[45D\u001b[38;5;10m?\u001b[39m Enter description (required): update the REA \u001b[K\r\n\r\u001b[1A\u001b[46C\u001b[?25h"]
[0.150, "o", "\u001b[?25l\u001b[46D\u001b[38;5;10m?\u001b[39m Enter description (required): "]
[0.000, "o", "update the READ \u001b[K\r\n\r\u001b[1A\u001b[47C\u001b[?25h"]
[0.060, "o", "\u001b[?25l\u001b[47D\u001b[38;5;10m?\u001b[39m Enter description (required): update the READM \u001b[K\r\n\r\u001b[1A\u001b[48C\u001b[?25h"]
[0.300, "o", "\u001b[?25l\u001b[48D\u001b["]
[0.000, "o", "38;5;10m?\u001b[39m Enter description (required): update the README \u001b[K\r"]
[0.000, "o", "\n\r\u001b[1A"]
[0.000, "o", "\u001b[49C\u001b[?25h"]
[0.600, "o", "\u001b[?25l\u001b[49D\u001b[38;5;10m?\u001b[39m Enter description (required): update the README \u001b[K\r\n\r\u001b[1A\u001b[50C\u001b[?25h"]
[0.165, "o", "\u001b[?25l\u001b[50D\u001b[38;5;10m?\u001b[39m Enter description (required): update the README t \u001b[K\r\n\r\u001b[1A"]
[0.000, "o", "\u001b[51C\u001b[?25h"]
[0.000, "o", "\u001b[?25l\u001b[51D\u001b[38;5;10m?\u001b[39m Enter description (required): update the README to \u001b[K\r"]
[0.000, "o", "\n\r"]
[0.000, "o", "\u001b[1A\u001b[52C\u001b[?25h"]
[0.165, "o", "\u001b[?25l\u001b[52D\u001b[38;5;10m?\u001b[39m Enter description (required): update the README to \u001b[K\r\n\r\u001b[1A\u001b[53C\u001b[?25h"]
[0.075, "o", "\u001b[?25l\u001b[53D\u001b[38;5;10m?\u001b[39m Enter description (required): update the README to r \u001b[K\r\n\r\u001b[1A\u001b[54C\u001b[?25h"]
[0.090, "o", "\u001b[?25l\u001b[54D\u001b[38;5;10m?\u001b[39m Enter description (required): update the README to re \u001b[K\r\n\r\u001b[1A\u001b[55C\u001b[?25h"]
[0.180, "o", "\u001b[?25l\u001b[55D\u001b[38;5;10m?\u001b[39m Enter description (required): update the README to ref \u001b[K\r\n\r\u001b[1A\u001b[56C\u001b[?25h"]
[0.151, "o", "\u001b[?25l\u001b[56D\u001b[38;5;10m?\u001b[39m Enter description (required): update the README to refl \u001b[K\r\n\r\u001b[1A\u001b[57C\u001b[?25h"]
[0.254, "o", "\u001b[?25l\u001b[57D\u001b[38;5;10m?\u001b[39m Enter description (required): "]
[0.000, "o", "update the README to refle \u001b[K\r\n\r\u001b[1A\u001b[58C\u001b[?25h"]
[0.000, "o", "\u001b[?25l\u001b[58D\u001b[38;5;10m?\u001b[39m Enter description (required): update the README to reflec \u001b[K"]
[0.000, "o", "\r\n\r\u001b[1A\u001b["]
[0.000, "o", "59C\u001b[?25h"]
[0.210, "o", "\u001b[?25l\u001b[59D\u001b[38;5;10m"]
[0.000, "o", "?\u001b[39m Enter description (required): update the README to reflect \u001b[K\r\n\r\u001b[1A"]
[0.000, "o", "\u001b[60C\u001b[?25h"]
[0.151, "o", "\u001b[?25l\u001b[60D\u001b[38;5;10m?\u001b[39m Enter description (required): update the README to reflect \u001b[K\r\n\r\u001b[1A\u001b[61C\u001b[?25h"]
[0.074, "o", "\u001b[?25l\u001b[61D\u001b[38;5;10m?\u001b[39"]
[0.000, "o", "m Enter description (required): update the README to reflect n \u001b[K\r\n\r\u001b[1A\u001b[62C\u001b[?25h"]
[0.075, "o", "\u001b[?25l\u001b[62D\u001b[38;5;10m?\u001b[39m Enter description (required): update the README to reflect ne \u001b[K\r\n\r\u001b[1A\u001b[63C\u001b[?25h"]
[0.135, "o", "\u001b[?25l\u001b[63D\u001b[38;5;10m?\u001b[39m Enter description (required): update the README to reflect new \u001b[K\r\n\r\u001b[1A\u001b[64C\u001b[?25h"]
[0.256, "o", "\u001b[?25l\u001b[64D\u001b[38;5;10m?\u001b[39m Enter description (required): update the README to reflect new \u001b[K\r\n\r\u001b[1A\u001b[65C\u001b[?25h"]
[0.179, "o", "\u001b[?25l\u001b[65D\u001b[38;5;10m?\u001b[39m Enter description (required): update the README to reflect new f \u001b[K\r\n\r\u001b[1A\u001b[66C\u001b[?25h"]
[0.104, "o", "\u001b[?25l\u001b[66D\u001b[38;5;10m?\u001b[39m Enter description (required): update the README to reflect new fe \u001b[K\r\n\r\u001b[1A\u001b[67C\u001b[?25h"]
[0.016, "o", "\u001b[?25l\u001b[67D\u001b[38;5;10m?\u001b[39m Enter description (required): update the README to reflect new fea \u001b[K\r\n\r\u001b["]
[0.000, "o", "1A\u001b[68C\u001b[?25h"]
[0.240, "o", "\u001b[?25l\u001b[68D\u001b[38;5;10m?\u001b[39m Enter description (required): update the README to reflect new feat \u001b[K\r\n\r\u001b[1A\u001b[69C\u001b[?25h"]
[0.075, "o", "\u001b[?25l\u001b[69D\u001b[38;5;10m?\u001b[39m Enter description (required): update the README to reflect new featr \u001b[K\r\n\r\u001b[1A\u001b[70C\u001b[?25h"]
[0.000, "o", "\u001b[?25l\u001b[70D\u001b[38;5;10m?\u001b[39m Enter description (required): update the README to reflect new featru \u001b[K\r\n\r\u001b[1A\u001b[71C\u001b[?25h"]
[0.735, "o", "\u001b[?25l\u001b[71D\u001b[38;5;10m?\u001b[39m Enter description (required): update the README to reflect new featr \u001b[K\r\n\r\u001b[1A\u001b[70C\u001b[?25h"]
[0.179, "o", "\u001b[?25l\u001b[70D\u001b[38;5;10m?\u001b[39m Enter description (required): update the README to reflect new feat \u001b[K"]
[0.000, "o", "\r\n\r\u001b[1A\u001b[69C\u001b[?25h"]
[0.091, "o", "\u001b[?25l\u001b[69D\u001b[38;5;10m?\u001b[39m Enter description (required): update the README to reflect new featu \u001b[K\r\n\r\u001b[1A\u001b[70C\u001b[?25h"]
[0.166, "o", "\u001b[?25l\u001b[70D\u001b[38;5;10m?\u001b[39m Enter description (required): update the README to reflect new featur \u001b[K\r\n\r\u001b[1A\u001b[71C\u001b[?25h"]
[0.253, "o", "\u001b[?25l\u001b[71D\u001b[38;5;10m?\u001b[39m Enter description (required): update the README to reflect new feature \u001b[K\r\n\r\u001b[1A\u001b[72C\u001b[?25h"]
[0.000, "o", "\u001b[?25l\u001b[72D\u001b[38;5;10m?\u001b[39m Enter description (required): update the README to reflect new features \u001b[K\r\n\r\u001b[1A\u001b[73C\u001b[?25h"]
[0.796, "o", "\u001b[?25l\u001b[73D\u001b[38;5;10m>\u001b[39m Enter description (required): \u001b[38;5;14m"]
[0.000, "o", "update the README to reflect new features\u001b[39m\u001b[K\r\n\u001b[2K\r\n\u001b[?25h\u001b[1A\u001b[?25h\u001b[?2004l"]
[0.000, "o", "\u001b[?2004h"]
[0.000, "o", "\u001b[?25l\u001b[38;5;10m?\u001b[39m "]
[0.000, "o", "Does this revision include a breaking change? (y/N) \r\u001b["]
[0.000, "o", "54C\u001b[?25h"]
[1.079, "o", "\u001b[?25l\u001b[54D\u001b[38;5;10m>\u001b[39m Does this revision include a breaking change? \u001b[38;5;14mNo\u001b[39m\u001b[K\r\n\u001b[?25h"]
[0.000, "o", "\u001b[?25h\u001b[?2004l"]
[0.000, "o", "\u001b[?2004h"]
[0.000, "o", "\u001b[?25l\u001b[38;5;10m?\u001b[39m Add a body? (y/N) \r\u001b[20C\u001b[?25h"]
[1.035, "o", "\u001b[?25l\u001b[20D\u001b[38;5;10m>\u001b[39m Add a body? \u001b[38;5;14mNo\u001b[39m\u001b[K\r\n\u001b[?25h"]
[0.000, "o", "\u001b[?25h\u001b[?2004l"]
[0.000, "o", "\r\n📝 Commit Message Preview:\r\n┌──────────────────────────────────────────────────────────────────────────┐\r\n│ docs(README): update the README to reflect new features │\r\n└──────────────────────────────────────────────────────────────────────────┘\r\n\r\n\u001b[?2004h"]
[0.000, "o", "\u001b[?25l\u001b[38;5;10m?\u001b[39m Apply this commit message? (Y/n) "]
[0.000, "o", "\r\n\u001b[38;5;14m[\u001b[39m\u001b[38;5;14m"]
[0.000, "o", "Select 'No' to cancel and start over\u001b[39m"]
[0.000, "o", "\u001b[38;"]
[0.000, "o", "5;14m]\u001b[39m\r\u001b[1A\u001b[35C\u001b[?25h"]
[1.185, "o", "\u001b[?25l\u001b[35D\u001b[38;5;10m>\u001b[39m Apply this commit message? \u001b[38;5;14mYes\u001b[39m\u001b[K\r\n\u001b[2K\r\n\u001b[?25h\u001b[1A\u001b[?25h\u001b[?2004l"]
[0.015, "o", "✅ Commit message applied successfully!\r\n"]
[0.001, "o", "bash-5.3$ "]
[1.394, "o", "j"]
[0.182, "o", "j"]
[0.254, "o", " "]
[0.225, "o", "s"]
[0.060, "o", "h"]
[0.256, "o", "o"]
[0.118, "o", "w"]
[0.225, "o", "\r\n"]
[0.087, "o", "\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\u001b[?2004h\u001b[>4;2m"]
[0.000, "o", "\u001b[?25l\u001b[HCommit ID: \u001b[34m9a200a9c47baa5a8a43c46e5fd9cc6a5ebd5bddb\u001b[39m\u001b[K\u001b[2;1HChange ID: \u001b[35mymmvxvqykryrsxmnxzttymztwtkpltqz\u001b[39m\u001b[K\u001b[3;1HAuthor : \u001b[33mLucien Cartier-Tilet\u001b[39m <\u001b[33mlucien@phundrak.com\u001b[39m> (\u001b[36m2026-06-14 14:31:57\u001b[39m)\u001b[K\u001b[4;1HCommitter: \u001b[33mLucien Cartier-Tilet\u001b[39m <\u001b[33mlucien@phundrak.com\u001b[39m> (\u001b[36m2026-06-14 14:50:46\u001b[39m)\u001b[K\u001b[5;1HSignature: \u001b[32mgood\u001b[39m signature by \u001b[33mlucien@phundrak.com\u001b[39m \u001b[36mSHA256:CE0HPsbW3L2YiJETx1zYZ2muMptaAqTN2g3498KrMkc\u001b[39m\u001b[K\u001b[6;1H\u001b[K\u001b[7;1H docs(README): update the README to reflect new features\u001b[K\u001b[8;1H\u001b[K\u001b[9;1H\u001b[33mModified regular file README.md:\u001b[39m\u001b[K\u001b[10;1H ...\u001b[K\u001b[11;1H\u001b[0;2m\u000f\u001b[31m 3\u001b[0m\u000f \u001b[0;2m\u000f\u001b[32m 3\u001b[0m\u000f: An interactive CLI tool that guides Jujutsu users through creating\u001b[K\u001b[12;1H\u001b[0;2m\u000f\u001b[31m 4\u001b[0m\u000f \u001b[0;2m\u000f\u001b[32m 4\u001b[0m\u000f: [conventional commit](https://www.conventionalcommits.org/) messages.\u001b[K\u001b[13;1H\u001b[0;2m\u000f\u001b[31m 5\u001b[0m\u000f \u001b[0;2m\u000f\u001b[32m 5\u001b[0m\u000f: \u001b[K\u001b[14;1H \u001b[32m 6\u001b[39m: \u001b[0;4m\u000f\u001b[32m[![asciicast](assets/demo.cast)](assets/demo.cast)\u001b[0m\u000f\u001b[K\u001b[15;1H \u001b[32m 7\u001b[39m: \u001b[K\u001b[16;1H\u001b[0;2m\u000f\u001b[31m 6\u001b[0m\u000f \u001b[0;2m\u000f\u001b[32m 8\u001b[0m\u000f: ## Features\u001b[K\u001b[17;1H\u001b[0;2m\u000f\u001b[31m 7\u001b[0m\u000f \u001b[0;2m\u000f\u001b[32m 9\u001b[0m\u000f: \u001b[K\u001b[18;1H\u001b[30m\u001b[47m wrap lines 1- 17/258 \u001b[39m\u001b[K\r\u001b[49m"]
[1.968, "o", "\u001b[34h\u001b[?25h\u001b[1;18r\u001b[18;1H\u001b[J\u001b[34h\u001b[?25h\u001b[?2004l\u001b[>4;0m"]
[0.002, "o", "bash-5.3$ "]
[0.764, "o", "exit\r\n"]
[0.001, "x", "0"]
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

+7
View File
@@ -38,6 +38,12 @@ command = [
] ]
need_stdout = true need_stdout = true
[jobs.coverage]
command = [
"cargo", "tarpaulin", "--config", ".tarpaulin.local.toml", "--features", "test-utils"
]
need_stdout = true
[jobs.doc] [jobs.doc]
command = ["cargo", "doc", "--color", "always", "--no-deps"] command = ["cargo", "doc", "--color", "always", "--no-deps"]
need_stdout = false need_stdout = false
@@ -82,3 +88,4 @@ allow_warnings = true
[keybindings] [keybindings]
# alt-m = "job:my-job" # alt-m = "job:my-job"
c = "job:clippy-all" # comment this to have 'c' run clippy on only the default target c = "job:clippy-all" # comment this to have 'c' run clippy on only the default target
v = "job:coverage"
Generated
+11 -260
View File
@@ -23,65 +23,6 @@
"type": "github" "type": "github"
} }
}, },
"cachix": {
"inputs": {
"devenv": [
"devenv"
],
"flake-compat": [
"devenv",
"flake-compat"
],
"git-hooks": [
"devenv",
"git-hooks"
],
"nixpkgs": [
"devenv",
"nixpkgs"
]
},
"locked": {
"lastModified": 1760971495,
"narHash": "sha256-IwnNtbNVrlZIHh7h4Wz6VP0Furxg9Hh0ycighvL5cZc=",
"owner": "cachix",
"repo": "cachix",
"rev": "c5bfd933d1033672f51a863c47303fc0e093c2d2",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "latest",
"repo": "cachix",
"type": "github"
}
},
"devenv": {
"inputs": {
"cachix": "cachix",
"flake-compat": "flake-compat",
"flake-parts": "flake-parts",
"git-hooks": "git-hooks",
"nix": "nix",
"nixd": "nixd",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1770304289,
"narHash": "sha256-+g+XMyB1zi50h2N38GE32l7ZONX4oW7Nw6QSXzfNiwk=",
"owner": "cachix",
"repo": "devenv",
"rev": "fd777e39027d393346e4df672d51ad2bf44b2a12",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "devenv",
"type": "github"
}
},
"fenix": { "fenix": {
"inputs": { "inputs": {
"nixpkgs": [ "nixpkgs": [
@@ -104,58 +45,6 @@
"type": "github" "type": "github"
} }
}, },
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1761588595,
"narHash": "sha256-XKUZz9zewJNUj46b4AJdiRZJAvSZ0Dqj2BNfXvFlJC4=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
"devenv",
"nixpkgs"
]
},
"locked": {
"lastModified": 1760948891,
"narHash": "sha256-TmWcdiUUaWk8J4lpjzu4gCGxWY6/Ok7mOK4fIFfBuU4=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "864599284fc7c0ba6357ed89ed5e2cd5040f0c04",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"flake-root": {
"locked": {
"lastModified": 1723604017,
"narHash": "sha256-rBtQ8gg+Dn4Sx/s+pvjdq3CB2wQNzx9XGFq/JVGCB6k=",
"owner": "srid",
"repo": "flake-root",
"rev": "b759a56851e10cb13f6b8e5698af7b59c44be26e",
"type": "github"
},
"original": {
"owner": "srid",
"repo": "flake-root",
"type": "github"
}
},
"flake-utils": { "flake-utils": {
"inputs": { "inputs": {
"systems": "systems" "systems": "systems"
@@ -190,141 +79,25 @@
"type": "github" "type": "github"
} }
}, },
"git-hooks": {
"inputs": {
"flake-compat": [
"devenv",
"flake-compat"
],
"gitignore": "gitignore",
"nixpkgs": [
"devenv",
"nixpkgs"
]
},
"locked": {
"lastModified": 1760663237,
"narHash": "sha256-BflA6U4AM1bzuRMR8QqzPXqh8sWVCNDzOdsxXEguJIc=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "ca5b894d3e3e151ffc1db040b6ce4dcc75d31c37",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"devenv",
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nix": {
"inputs": {
"flake-compat": [
"devenv",
"flake-compat"
],
"flake-parts": [
"devenv",
"flake-parts"
],
"git-hooks-nix": [
"devenv",
"git-hooks"
],
"nixpkgs": [
"devenv",
"nixpkgs"
],
"nixpkgs-23-11": [
"devenv"
],
"nixpkgs-regression": [
"devenv"
]
},
"locked": {
"lastModified": 1769708679,
"narHash": "sha256-uFKkp2/SjIqbu5HtINg/hwHN6qaqcxLIbL/om7dT3kI=",
"owner": "cachix",
"repo": "nix",
"rev": "72bec37fabbfe378d677868ec42eeb83acf07a4c",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "devenv-2.32",
"repo": "nix",
"type": "github"
}
},
"nixd": {
"inputs": {
"flake-parts": [
"devenv",
"flake-parts"
],
"flake-root": "flake-root",
"nixpkgs": [
"devenv",
"nixpkgs"
],
"treefmt-nix": "treefmt-nix"
},
"locked": {
"lastModified": 1763964548,
"narHash": "sha256-JTRoaEWvPsVIMFJWeS4G2isPo15wqXY/otsiHPN0zww=",
"owner": "nix-community",
"repo": "nixd",
"rev": "d4bf15e56540422e2acc7bc26b20b0a0934e3f5e",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixd",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1767052823, "lastModified": 1779877693,
"narHash": "sha256-Fhuljcy7pJ8HacYYATRcm5rdKXx8P6D/0g19ppzDRNY=", "narHash": "sha256-NOF9NAREhxr50bbBfVcVOq+ArCMSoe8dP79Pk2uyARk=",
"owner": "cachix", "owner": "NixOS",
"repo": "devenv-nixpkgs", "repo": "nixpkgs",
"rev": "538a5124359f0b3d466e1160378c87887e3b51a4", "rev": "4100e830e085863741bc69b156ec4ccd53ab5be0",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "cachix", "owner": "NixOS",
"ref": "rolling", "ref": "nixpkgs-unstable",
"repo": "devenv-nixpkgs", "repo": "nixpkgs",
"type": "github" "type": "github"
} }
}, },
"root": { "root": {
"inputs": { "inputs": {
"alejandra": "alejandra", "alejandra": "alejandra",
"devenv": "devenv",
"flake-utils": "flake-utils", "flake-utils": "flake-utils",
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay" "rust-overlay": "rust-overlay"
@@ -354,11 +127,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1770260791, "lastModified": 1779992051,
"narHash": "sha256-ADTBfENFjRVDQMcCycyX/pAy6NFI/Ct6Mrar3gsmXI0=", "narHash": "sha256-4YWGv/0NkAdtTW1MXfaLYpfC9BhpCy9k1pWkR0xI9uw=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "42ec85352e419e601775c57256a52f6d48a39906", "rev": "e93ad0df1073b2c969a8f0c1f10b84e870469d40",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -381,28 +154,6 @@
"repo": "default", "repo": "default",
"type": "github" "type": "github"
} }
},
"treefmt-nix": {
"inputs": {
"nixpkgs": [
"devenv",
"nixd",
"nixpkgs"
]
},
"locked": {
"lastModified": 1734704479,
"narHash": "sha256-MMi74+WckoyEWBRcg/oaGRvXC9BVVxDZNRMpL+72wBI=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "65712f5af67234dad91a5a4baee986a8b62dbf8f",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
} }
}, },
"root": "root", "root": "root",
+15 -31
View File
@@ -2,16 +2,12 @@
description = "Conventional commits for Jujutsu"; description = "Conventional commits for Jujutsu";
inputs = { inputs = {
nixpkgs.url = "github:cachix/devenv-nixpkgs/rolling"; nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
flake-utils.url = "github:numtide/flake-utils"; flake-utils.url = "github:numtide/flake-utils";
alejandra = { alejandra = {
url = "github:kamadorueda/alejandra/4.0.0"; url = "github:kamadorueda/alejandra/4.0.0";
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
}; };
devenv = {
url = "github:cachix/devenv";
inputs.nixpkgs.follows = "nixpkgs";
};
rust-overlay = { rust-overlay = {
url = "github:oxalica/rust-overlay"; url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
@@ -19,8 +15,16 @@
}; };
nixConfig = { nixConfig = {
extra-trusted-public-keys = ["devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw=" "phundrak.cachix.org-1:osJAkYO0ioTOPqaQCIXMfIRz1/+YYlVFkup3R2KSexk="]; extra-trusted-public-keys = [
extra-substituters = ["https://devenv.cachix.org" "https://phundrak.cachix.org"]; "nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs="
"devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw="
"phundrak.cachix.org-1:osJAkYO0ioTOPqaQCIXMfIRz1/+YYlVFkup3R2KSexk="
];
extra-substituters = [
"https://nix-community.cachix.org"
"https://devenv.cachix.org"
"https://phundrak.cachix.org"
];
}; };
outputs = { outputs = {
@@ -29,37 +33,17 @@
rust-overlay, rust-overlay,
alejandra, alejandra,
... ...
} @ inputs: }:
flake-utils.lib.eachDefaultSystem ( flake-utils.lib.eachDefaultSystem (
system: let system: let
overlays = [(import rust-overlay)]; overlays = [(import rust-overlay)];
pkgs = import nixpkgs {inherit system overlays;}; pkgs = import nixpkgs {inherit system overlays;};
rustVersion = pkgs.rust-bin.stable.latest.default; rustVersion = pkgs.rust-bin.stable.latest.default;
rustPlatform = pkgs.makeRustPlatform { packages = import ./nix/packages.nix {inherit pkgs system;};
cargo = rustVersion;
rustc = rustVersion;
};
in { in {
inherit packages;
formatter = alejandra.defaultPackage.${system}; formatter = alejandra.defaultPackage.${system};
packages = let devShell = import ./nix/shell.nix {inherit pkgs rustVersion;};
nativeRustVersion = pkgs.rust-bin.stable.latest.default;
nativeRustPlatform = pkgs.makeRustPlatform {
cargo = nativeRustVersion;
rustc = nativeRustVersion;
};
mingwPkgs = pkgs.pkgsCross.mingwW64;
windowsRustVersion = pkgs.rust-bin.stable.latest.default.override {
targets = ["x86_64-pc-windows-gnu"];
};
windowsRustPlatform = mingwPkgs.makeRustPlatform {
cargo = windowsRustVersion;
rustc = windowsRustVersion;
};
in
import ./nix/package.nix {inherit pkgs nativeRustPlatform windowsRustPlatform;};
devShell = import ./nix/shell.nix {
inherit inputs pkgs rustVersion;
};
} }
); );
} }
+19
View File
@@ -0,0 +1,19 @@
{
bin,
pkgs,
archiveName
}:
pkgs.stdenv.mkDerivation rec {
name = "jj-cz-${archiveName}";
src = pkgs.lib.cleanSource ../.;
nativeBuildInputs = [pkgs.zip];
buildPhase = ''
mkdir -p $out/dist
# zip -j $out/dist/${name}.zip ${bin}/bin/jj-cz* ${src}/README.md ${src}/LICENSE.*
cp ${bin}/bin/jj-cz* $out/dist/
cp ${src}/README.md $out/dist/
cp ${src}/LICENSE.* $out/dist/
'';
installPhase = "";
dontConfigure = true;
}
+28
View File
@@ -0,0 +1,28 @@
{
target,
pkgs,
}: let
cargoToml = fromTOML (builtins.readFile ../Cargo.toml);
name = cargoToml.package.name;
version = cargoToml.package.version;
buildArgs = {
pname = name;
inherit version;
src = pkgs.lib.cleanSource ../.;
# cargoLock.lockFile = ../Cargo.lock;
cargoHash = "sha256-yfKaqc+7lvxDukAXxazc57GFs386rr9vUsDk1pobLRM=";
useNextest = true;
meta = {
inherit (cargoToml.package) description homepage;
};
postBuild = "${pkgs.upx}/bin/upx target/*/release/${name}${target.exeSuffix}";
};
rustVersion = pkgs.rust-bin.stable.latest.default.override {
targets = [target.triple];
};
rustPlatform = target.crossPkgs.makeRustPlatform {
cargo = rustVersion;
rustc = rustVersion;
};
in
rustPlatform.buildRustPackage buildArgs
-33
View File
@@ -1,33 +0,0 @@
{
pkgs,
nativeRustPlatform,
windowsRustPlatform,
...
}: let
cargoToml = fromTOML (builtins.readFile ../Cargo.toml);
name = cargoToml.package.name;
version = cargoToml.package.version;
buildArgs = {
pname = name;
inherit version;
src = pkgs.lib.cleanSource ../.;
cargoLock.lockFile = ../Cargo.lock;
useNextest = true;
meta = {
inherit (cargoToml.package) description homepage;
};
};
nativeBuild =
nativeRustPlatform.buildRustPackage buildArgs
// {
postBuild = "${pkgs.upx}/bin/upx target/*/release/${name}";
};
windowsBuild =
windowsRustPlatform.buildRustPackage buildArgs
// {
postBuild = "${pkgs.upx}/bin/upx target/*/release/${name}.exe";
};
in {
default = nativeBuild;
windows = windowsBuild;
}
+82
View File
@@ -0,0 +1,82 @@
{
pkgs,
system,
...
}: let
mkRustBuild = import ./make-binary.nix;
mkArchive = import ./make-archive.nix;
targets = {
linux-x86_64 = {
crossPkgs = pkgs;
triple = "x86_64-unknown-linux-gnu";
exeSuffix = "";
};
linux-aarch64 = {
crossPkgs = pkgs.pkgsCross.aarch64-multiplatform;
triple = "aarch64-unknown-linux-gnu";
exeSuffix = "";
};
windows-x86_64 = {
crossPkgs = pkgs.pkgsCross.mingwW64;
triple = "x86_64-pc-windows-gnu";
exeSuffix = ".exe";
};
windows-aarch64 = {
crossPkgs = pkgs.pkgsCross.aarch64-windows;
triple = "aarch64-pc-windows-gnu";
exeSuffix = ".exe";
};
macos-x86_64 = {
crossPkgs = pkgs.pkgsCross.x86_64-darwin;
triple = "x86_64-apple-darwin";
exeSuffix = "";
};
macos-aarch64 = {
crossPkgs = pkgs.pkgsCross.aarch64-darwin;
triple = "aarch64-apple-darwin";
exeSuffix = "";
};
};
bins = {
linux-x86_64 = mkRustBuild {
inherit pkgs;
target = targets.linux-x86_64;
};
linux-aarch64 = mkRustBuild {
inherit pkgs;
target = targets.linux-aarch64;
};
windows-x86_64 = mkRustBuild {
inherit pkgs;
target = targets.windows-x86_64;
};
};
packages =
{
linux-x86_64-archive = mkArchive {
inherit pkgs;
bin = bins.linux-x86_64;
archiveName = "x86_64-linux";
};
linux-aarch64-archive = mkArchive {
inherit pkgs;
bin = bins.linux-aarch64;
archiveName = "aarch64-linux";
};
windows-x86_64-archive = mkArchive {
inherit pkgs;
bin = bins.windows-x86_64;
archiveName = "x86_64-windows";
};
}
// bins;
defaultBySystem = {
"x86_64-linux" = packages.linux-x86_64;
"aarch64-linux" = packages.linux-aarch64;
"x86_64-windows" = packages.windows-x86_64;
};
in
packages
// {
default = defaultBySystem.${system} or packages.linux-x86_64;
}
+4 -8
View File
@@ -1,13 +1,8 @@
{ {
inputs,
pkgs, pkgs,
rustVersion, rustVersion,
...
}: }:
inputs.devenv.lib.mkShell { pkgs.mkShell {
inherit inputs pkgs;
modules = [
{
packages = with pkgs; [ packages = with pkgs; [
(rustVersion.override { (rustVersion.override {
extensions = [ extensions = [
@@ -25,7 +20,8 @@ inputs.devenv.lib.mkShell {
git-cliff git-cliff
just just
typos typos
];
} # for CI
jq
]; ];
} }
+95
View File
@@ -18,6 +18,10 @@ pub struct Cli {
/// The revision(s) whose description to edit (default: @) /// The revision(s) whose description to edit (default: @)
#[arg(value_name = "REVSETS")] #[arg(value_name = "REVSETS")]
revsets: Vec<String>, revsets: Vec<String>,
/// Create a new child revision after editing the description
#[arg(short, long)]
new: bool,
} }
impl Cli { impl Cli {
@@ -29,4 +33,95 @@ impl Cli {
self.revsets.iter().map(|s| s.as_str()).collect() self.revsets.iter().map(|s| s.as_str()).collect()
} }
} }
pub fn create_new(&self) -> bool {
self.new
}
pub fn validate(&self) -> Result<(), jj_cz::Error> {
if self.new && self.revsets().len() > 1 {
Err(jj_cz::Error::NewFlagWithMultipleRevisions)
} else {
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn revsets_defaults_to_at() {
let cli = Cli::parse_from(["jj-cz"]);
assert_eq!(cli.revsets(), vec!["@"]);
}
#[test]
fn revsets_returns_provided_values() {
let cli = Cli::parse_from(["jj-cz", "abc", "def"]);
let revsets = cli.revsets();
assert_eq!(revsets, vec!["abc", "def"]);
}
#[test]
fn revsets_single_revset() {
let cli = Cli::parse_from(["jj-cz", "xyz"]);
assert_eq!(cli.revsets(), vec!["xyz"]);
}
#[test]
fn create_new_returns_false_by_default() {
let cli = Cli::parse_from(["jj-cz"]);
assert!(!cli.create_new());
}
#[test]
fn create_new_returns_true_with_flag() {
let cli = Cli::parse_from(["jj-cz", "--new"]);
assert!(cli.create_new());
}
#[test]
fn create_new_returns_true_with_short_flag() {
let cli = Cli::parse_from(["jj-cz", "-n"]);
assert!(cli.create_new());
}
#[test]
fn validate_ok_with_no_args() {
let cli = Cli::parse_from(["jj-cz"]);
assert!(cli.validate().is_ok());
}
#[test]
fn validate_ok_with_new_and_single_revset() {
let cli = Cli::parse_from(["jj-cz", "--new", "@"]);
assert!(cli.validate().is_ok());
}
#[test]
fn validate_ok_with_multiple_revsets_no_new() {
let cli = Cli::parse_from(["jj-cz", "abc", "def"]);
assert!(cli.validate().is_ok());
}
#[test]
fn validate_err_with_new_and_multiple_revsets() {
let cli = Cli::parse_from(["jj-cz", "--new", "abc", "def"]);
let result = cli.validate();
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
jj_cz::Error::NewFlagWithMultipleRevisions
));
}
#[test]
fn cli_derives_debug() {
let cli = Cli::parse_from(["jj-cz", "--new", "@"]);
let debug = format!("{:?}", cli);
assert!(debug.contains("Cli"));
}
} }
+4 -2
View File
@@ -4,10 +4,12 @@ pub trait Footer {
fn as_footer(&self) -> String { fn as_footer(&self) -> String {
let default = format!("{}: {}", self.prefix(), self.note()); let default = format!("{}: {}", self.prefix(), self.note());
if default.chars().count() > 72 { let mut footer = if default.chars().count() > 72 {
textwrap::wrap(&default, 71).join("\n ") textwrap::wrap(&default, 71).join("\n ")
} else { } else {
default default
} };
footer.push('\n');
footer
} }
} }
+27 -2
View File
@@ -1,4 +1,4 @@
use super::{Body, BreakingChange, CommitType, Description, Scope}; use super::{Body, BreakingChange, CommitType, Description, Footer, References, Scope};
use thiserror::Error; use thiserror::Error;
/// Errors that can occur when creating a ConventionalCommit /// Errors that can occur when creating a ConventionalCommit
@@ -23,6 +23,7 @@ pub struct ConventionalCommit {
description: Description, description: Description,
breaking_change: BreakingChange, breaking_change: BreakingChange,
body: Body, body: Body,
references: References,
} }
impl ConventionalCommit { impl ConventionalCommit {
@@ -44,6 +45,7 @@ impl ConventionalCommit {
description: Description, description: Description,
breaking_change: BreakingChange, breaking_change: BreakingChange,
body: Body, body: Body,
references: References,
) -> Result<Self, CommitMessageError> { ) -> Result<Self, CommitMessageError> {
let commit = Self { let commit = Self {
commit_type, commit_type,
@@ -51,6 +53,7 @@ impl ConventionalCommit {
description, description,
breaking_change, breaking_change,
body, body,
references,
}; };
let len = commit.first_line_len(); let len = commit.first_line_len();
if len > Self::FIRST_LINE_MAX_LENGTH { if len > Self::FIRST_LINE_MAX_LENGTH {
@@ -92,6 +95,7 @@ impl ConventionalCommit {
&self.description, &self.description,
&self.breaking_change, &self.breaking_change,
&self.body, &self.body,
&self.references,
) )
} }
@@ -106,14 +110,16 @@ impl ConventionalCommit {
description: &Description, description: &Description,
breaking_change: &BreakingChange, breaking_change: &BreakingChange,
body: &Body, body: &Body,
references: &References,
) -> String { ) -> String {
let scope = scope.header_segment(); let scope = scope.header_segment();
let breaking_change_header = breaking_change.header_segment(); let breaking_change_header = breaking_change.header_segment();
let breaking_change_footer = breaking_change.as_footer(); let breaking_change_footer = breaking_change.as_footer();
let refs_footer = references.as_footer();
format!( format!(
r#"{commit_type}{scope}{breaking_change_header}: {description} r#"{commit_type}{scope}{breaking_change_header}: {description}
{} {}
{breaking_change_footer}"#, {breaking_change_footer}{refs_footer}"#,
body.format() body.format()
) )
.trim() .trim()
@@ -154,6 +160,7 @@ mod tests {
description, description,
breaking_change, breaking_change,
Body::default(), Body::default(),
References::default(),
) )
.expect("test commit should have valid line length") .expect("test commit should have valid line length")
} }
@@ -637,6 +644,7 @@ mod tests {
Description::parse(&desc_44).unwrap(), Description::parse(&desc_44).unwrap(),
BreakingChange::No, BreakingChange::No,
Body::default(), Body::default(),
References::default(),
); );
assert!(result.is_ok()); assert!(result.is_ok());
let commit = result.unwrap(); let commit = result.unwrap();
@@ -667,6 +675,7 @@ mod tests {
Description::parse(&desc_31).unwrap(), Description::parse(&desc_31).unwrap(),
BreakingChange::No, BreakingChange::No,
Body::default(), Body::default(),
References::default(),
); );
assert!(result.is_err()); assert!(result.is_err());
assert_eq!( assert_eq!(
@@ -691,6 +700,7 @@ mod tests {
Description::parse(&desc_40).unwrap(), Description::parse(&desc_40).unwrap(),
BreakingChange::No, BreakingChange::No,
Body::default(), Body::default(),
References::default(),
); );
assert!(result.is_err()); assert!(result.is_err());
assert_eq!( assert_eq!(
@@ -711,6 +721,7 @@ mod tests {
test_description("quick fix"), test_description("quick fix"),
BreakingChange::No, BreakingChange::No,
Body::default(), Body::default(),
References::default(),
); );
assert!(result.is_ok()); assert!(result.is_ok());
} }
@@ -724,6 +735,7 @@ mod tests {
test_description("add feature"), test_description("add feature"),
BreakingChange::No, BreakingChange::No,
Body::default(), Body::default(),
References::default(),
); );
assert!(result.is_ok()); assert!(result.is_ok());
} }
@@ -750,6 +762,7 @@ mod tests {
test_description("test"), test_description("test"),
BreakingChange::No, BreakingChange::No,
Body::default(), Body::default(),
References::default(),
); );
// Just verify it's a Result by using is_ok() // Just verify it's a Result by using is_ok()
assert!(result.is_ok()); assert!(result.is_ok());
@@ -781,6 +794,7 @@ mod tests {
desc, desc,
BreakingChange::No, BreakingChange::No,
Body::default(), Body::default(),
References::default(),
); );
// new() itself calls git_conventional::Commit::parse internally, so // new() itself calls git_conventional::Commit::parse internally, so
// if this is Ok, SC-002 is satisfied for this case. // if this is Ok, SC-002 is satisfied for this case.
@@ -918,6 +932,7 @@ mod tests {
Description::parse(&desc_44).unwrap(), Description::parse(&desc_44).unwrap(),
BreakingChange::Yes, BreakingChange::Yes,
Body::default(), Body::default(),
References::default(),
); );
assert!(result.is_err()); assert!(result.is_err());
assert_eq!( assert_eq!(
@@ -940,6 +955,7 @@ mod tests {
test_description("quick fix"), test_description("quick fix"),
long_note.into(), long_note.into(),
Body::default(), Body::default(),
References::default(),
); );
assert!(result.is_ok()); assert!(result.is_ok());
} }
@@ -959,6 +975,7 @@ mod tests {
&commit.description, &commit.description,
&BreakingChange::No, &BreakingChange::No,
&Body::default(), &Body::default(),
&References::default(),
); );
assert_eq!(preview, commit.format()); assert_eq!(preview, commit.format());
} }
@@ -972,6 +989,7 @@ mod tests {
&test_description("drop legacy API"), &test_description("drop legacy API"),
&"removes legacy endpoint".into(), &"removes legacy endpoint".into(),
&Body::default(), &Body::default(),
&References::default(),
); );
assert_eq!( assert_eq!(
preview, preview,
@@ -988,6 +1006,7 @@ mod tests {
&test_description("drop Node 6"), &test_description("drop Node 6"),
&"Node 6 is no longer supported".into(), &"Node 6 is no longer supported".into(),
&Body::default(), &Body::default(),
&References::default(),
); );
assert_eq!( assert_eq!(
preview, preview,
@@ -1093,6 +1112,7 @@ mod tests {
test_description("add feature"), test_description("add feature"),
BreakingChange::No, BreakingChange::No,
Body::from("This explains the change."), Body::from("This explains the change."),
References::default(),
) )
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
@@ -1110,6 +1130,7 @@ mod tests {
test_description("handle null response"), test_description("handle null response"),
BreakingChange::No, BreakingChange::No,
Body::from("Null responses were previously unhandled."), Body::from("Null responses were previously unhandled."),
References::default(),
) )
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
@@ -1127,6 +1148,7 @@ mod tests {
test_description("update README"), test_description("update README"),
BreakingChange::No, BreakingChange::No,
Body::from("First paragraph.\n\nSecond paragraph."), Body::from("First paragraph.\n\nSecond paragraph."),
References::default(),
) )
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
@@ -1146,6 +1168,7 @@ mod tests {
test_description("drop legacy API"), test_description("drop legacy API"),
"removes legacy endpoint".into(), "removes legacy endpoint".into(),
Body::from("The endpoint was deprecated in v2."), Body::from("The endpoint was deprecated in v2."),
References::default(),
) )
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
@@ -1163,6 +1186,7 @@ mod tests {
&test_description("add feature"), &test_description("add feature"),
&BreakingChange::No, &BreakingChange::No,
&Body::from("This explains the change."), &Body::from("This explains the change."),
&References::default(),
); );
assert_eq!(preview, "feat: add feature\n\nThis explains the change."); assert_eq!(preview, "feat: add feature\n\nThis explains the change.");
} }
@@ -1178,6 +1202,7 @@ mod tests {
&test_description("drop old API"), &test_description("drop old API"),
&"old API removed".into(), &"old API removed".into(),
&Body::from("Migration guide: see CHANGELOG."), &Body::from("Migration guide: see CHANGELOG."),
&References::default(),
); );
assert_eq!( assert_eq!(
preview, preview,
+3
View File
@@ -18,3 +18,6 @@ pub use body::Body;
mod message; mod message;
pub use message::{CommitMessageError, ConventionalCommit}; pub use message::{CommitMessageError, ConventionalCommit};
mod references;
pub use references::References;
+186
View File
@@ -0,0 +1,186 @@
use super::Footer;
#[repr(transparent)]
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct References(Vec<String>);
impl<T> From<T> for References
where
T: ToString,
{
fn from(value: T) -> Self {
let references: Vec<String> = value
.to_string()
.split(",")
.flat_map(|e| match e.trim() {
"" => None,
e => Some(e.to_string()),
})
.collect();
Self(references)
}
}
impl Footer for References {
fn prefix(&self) -> &str {
"Refs: "
}
fn note(&self) -> &str {
""
}
fn as_footer(&self) -> String {
if self.0.is_empty() {
String::new()
} else {
let footers: Vec<String> = self
.0
.iter()
.map(|r| format!("{}{r}\n", self.prefix()))
.collect();
footers.join("")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Default is empty
#[test]
fn default_is_empty() {
let refs = References::default();
assert!(refs.0.is_empty());
}
/// Empty input produces empty references
#[test]
fn from_empty_string() {
assert_eq!(References::from(""), References::default());
}
/// Whitespace-only input produces empty references
#[test]
fn from_whitespace_only() {
assert_eq!(References::from(" "), References::default());
}
/// Single reference without commas
#[test]
fn from_single_reference() {
let refs = References::from("#123");
assert_eq!(refs.0, vec!["#123".to_string()]);
}
/// Comma-separated references are split and trimmed
#[test]
fn from_comma_separated() {
let refs = References::from("#123, #456");
assert_eq!(refs.0, vec!["#123".to_string(), "#456".to_string()]);
}
/// Leading whitespace around references is trimmed
#[test]
fn from_trims_leading_whitespace() {
let refs = References::from(" #123, #456");
assert_eq!(refs.0, vec!["#123".to_string(), "#456".to_string()]);
}
/// Trailing whitespace around references is trimmed
#[test]
fn from_trims_trailing_whitespace() {
let refs = References::from("#123 , #456 ");
assert_eq!(refs.0, vec!["#123".to_string(), "#456".to_string()]);
}
/// Empty segments from consecutive commas are filtered out
#[test]
fn from_filters_empty_segments() {
let refs = References::from("#123,,, #456");
assert_eq!(refs.0, vec!["#123".to_string(), "#456".to_string()]);
}
/// From works with owned String
#[test]
fn from_owned_string() {
let input = "#123, #456".to_string();
let refs = References::from(input);
assert_eq!(refs.0, vec!["#123".to_string(), "#456".to_string()]);
}
/// as_footer returns empty string for empty references
#[test]
fn as_footer_empty() {
let refs = References::default();
assert_eq!(refs.as_footer(), "");
}
/// as_footer returns single line for one reference
#[test]
fn as_footer_single() {
let refs = References::from("#123");
assert_eq!(refs.as_footer(), "Refs: #123\n");
}
/// as_footer returns multiple lines for multiple references
#[test]
fn as_footer_multiple() {
let refs = References::from("#123, #456");
assert_eq!(refs.as_footer(), "Refs: #123\nRefs: #456\n");
}
/// as_footer handles Jira-style references
#[test]
fn as_footer_jira_style() {
let refs = References::from("OPS-456, PROJ-789");
assert_eq!(refs.as_footer(), "Refs: OPS-456\nRefs: PROJ-789\n");
}
/// Footer trait prefix returns correct value
#[test]
fn footer_prefix() {
let refs = References::default();
assert_eq!(refs.prefix(), "Refs: ");
}
/// Footer trait note returns empty string
#[test]
fn footer_note() {
let refs = References::default();
assert_eq!(refs.note(), "");
}
/// Clone produces equal value
#[test]
fn clone_equality() {
let refs = References::from("#123, #456");
let cloned = refs.clone();
assert_eq!(refs, cloned);
}
/// Debug output is available
#[test]
fn debug_output() {
let refs = References::from("#123");
let debug = format!("{:?}", refs);
assert!(debug.contains("References"));
}
/// Different references are not equal
#[test]
fn inequality_different_refs() {
let a = References::from("#123");
let b = References::from("#456");
assert_ne!(a, b);
}
/// Empty vs non-empty are not equal
#[test]
fn inequality_empty_vs_non_empty() {
let empty = References::default();
let non_empty = References::from("#123");
assert_ne!(empty, non_empty);
}
}
+2
View File
@@ -31,6 +31,8 @@ pub enum Error {
RevsetResolutionError { revset: String, context: String }, RevsetResolutionError { revset: String, context: String },
#[error("Revision set '{revset}' resolves to multiple commits; specify a single revision")] #[error("Revision set '{revset}' resolves to multiple commits; specify a single revision")]
MultipleRevisions { revset: String }, MultipleRevisions { revset: String },
#[error("--new cannot be used with multiple revisions")]
NewFlagWithMultipleRevisions,
} }
impl From<ScopeError> for Error { impl From<ScopeError> for Error {
+39 -10
View File
@@ -10,6 +10,7 @@ use std::{
}; };
use etcetera::BaseStrategy; use etcetera::BaseStrategy;
use futures_util::StreamExt;
use jj_lib::{ use jj_lib::{
backend::CommitId, backend::CommitId,
config::{ConfigSource, StackedConfig}, config::{ConfigSource, StackedConfig},
@@ -151,7 +152,7 @@ impl JjLib {
} }
/// Resolve a revset string to a commit ID /// Resolve a revset string to a commit ID
fn get_commit_id(&self, revset: &str) -> Result<CommitId, Error> { async fn get_commit_id(&self, revset: &str) -> Result<CommitId, Error> {
let context = RevsetParseContext { let context = RevsetParseContext {
workspace: Some(RevsetWorkspaceContext { workspace: Some(RevsetWorkspaceContext {
workspace_name: &self.workspace_name, workspace_name: &self.workspace_name,
@@ -179,20 +180,21 @@ impl JjLib {
.map_err(|e| Error::from_revset_resolution_error(revset, e))? .map_err(|e| Error::from_revset_resolution_error(revset, e))?
.evaluate(&*repo) .evaluate(&*repo)
.map_err(|e| Error::from_revset_evaluation_error(revset, e))?; .map_err(|e| Error::from_revset_evaluation_error(revset, e))?;
let mut iter = revision.iter(); let mut all_ids = revision.commit_change_ids();
let commit_id = iter let commit_id = all_ids
.next() .next()
.await
.ok_or(Error::RevsetResolutionError { .ok_or(Error::RevsetResolutionError {
revset: revset.to_string(), revset: revset.into(),
context: "No matching revision".to_string(), context: "No matching revision".to_string(),
})? })?
.map_err(|e| Error::from_revset_evaluation_error(revset, e))?; .map_err(|e| Error::from_revset_evaluation_error(revset, e))?;
if iter.next().is_some() { match all_ids.next().await {
return Err(Error::MultipleRevisions { None => Ok(commit_id.0),
Some(_) => Err(Error::MultipleRevisions {
revset: revset.to_string(), revset: revset.to_string(),
}); }),
} }
Ok(commit_id)
} }
} }
@@ -212,7 +214,7 @@ impl JjExecutor for JjLib {
} }
async fn describe(&self, revset: &str, message: &str) -> Result<(), Error> { async fn describe(&self, revset: &str, message: &str) -> Result<(), Error> {
let commit_id = self.get_commit_id(revset)?; let commit_id = self.get_commit_id(revset).await?;
let repo = self.repo.lock()?.clone(); let repo = self.repo.lock()?.clone();
let mut tx = repo.start_transaction(); let mut tx = repo.start_transaction();
let commit = tx let commit = tx
@@ -251,7 +253,7 @@ impl JjExecutor for JjLib {
} }
async fn get_description(&self, revset: &str) -> Result<String, Error> { async fn get_description(&self, revset: &str) -> Result<String, Error> {
let commit_id = self.get_commit_id(revset)?; let commit_id = self.get_commit_id(revset).await?;
let repo = self.repo.lock()?.clone(); let repo = self.repo.lock()?.clone();
let commit = repo let commit = repo
.store() .store()
@@ -261,6 +263,33 @@ impl JjExecutor for JjLib {
})?; })?;
Ok(commit.description().trim_end().to_string()) Ok(commit.description().trim_end().to_string())
} }
async fn new_revision(&self, revset: &str) -> Result<(), Error> {
let commit_id = self.get_commit_id(revset).await?;
let repo = self.repo.lock()?.clone();
let mut tx = repo.start_transaction();
let parent_commit =
tx.repo()
.store()
.get_commit(&commit_id)
.map_err(|e| Error::JjOperation {
context: e.to_string(),
})?;
tx.repo_mut()
.check_out(self.workspace_name.clone(), &parent_commit)
.await
.map_err(|e| Error::JjOperation {
context: e.to_string(),
})?;
let new_repo =
tx.commit("jj-cz: create new revision")
.await
.map_err(|e| Error::JjOperation {
context: e.to_string(),
})?;
*self.repo.lock()? = new_repo;
Ok(())
}
} }
#[cfg(test)] #[cfg(test)]
+86 -14
View File
@@ -11,18 +11,22 @@ use std::sync::{Mutex, atomic::AtomicBool};
/// Mock implementation of JjExecutor for testing /// Mock implementation of JjExecutor for testing
#[derive(Debug)] #[derive(Debug)]
pub struct MockJjExecutor { pub struct MockJjExecutor {
/// Response to return from is_repository() /// Response to return from `is_repository()`
is_repo_response: Result<bool, Error>, is_repo_response: Result<bool, Error>,
/// Response to return from describe() /// Response to return from `describe()`
describe_response: Result<(), Error>, describe_response: Result<(), Error>,
/// Track described revsets /// Track described revsets
described_revsets: Mutex<Vec<String>>, described_revsets: Mutex<Vec<String>>,
/// Track response to return from get_description() /// Track response to return from `get_description()`
get_description_response: Result<String, Error>, get_description_response: Result<String, Error>,
/// Track calls to is_repository() /// Track calls to `is_repository()`
is_repo_called: AtomicBool, is_repo_called: AtomicBool,
/// Track calls to describe() with the message passed /// Track calls to `describe()` with the message passed
describe_calls: Mutex<Vec<String>>, describe_calls: Mutex<Vec<String>>,
/// Track response to return from `new_revision()`
new_revision_response: Result<(), Error>,
/// Track calls to `new_revision()`
new_revision_calls: Mutex<Vec<String>>,
} }
impl Default for MockJjExecutor { impl Default for MockJjExecutor {
@@ -34,6 +38,8 @@ impl Default for MockJjExecutor {
get_description_response: Ok(String::new()), get_description_response: Ok(String::new()),
is_repo_called: AtomicBool::new(false), is_repo_called: AtomicBool::new(false),
describe_calls: Mutex::new(Vec::new()), describe_calls: Mutex::new(Vec::new()),
new_revision_response: Ok(()),
new_revision_calls: Mutex::new(Vec::new()),
} }
} }
} }
@@ -56,12 +62,6 @@ impl MockJjExecutor {
self self
} }
/// Configure get_description() to return a specific value
pub fn with_get_description_response(mut self, response: Result<String, Error>) -> Self {
self.get_description_response = response;
self
}
/// Check if is_repository() was called /// Check if is_repository() was called
pub fn was_is_repo_called(&self) -> bool { pub fn was_is_repo_called(&self) -> bool {
self.is_repo_called self.is_repo_called
@@ -73,9 +73,13 @@ impl MockJjExecutor {
self.describe_calls.lock().unwrap().clone() self.describe_calls.lock().unwrap().clone()
} }
/// Get all revsets visited pub fn with_new_revision_response(mut self, response: Result<(), Error>) -> Self {
pub fn described_revsets(&self) -> Vec<String> { self.new_revision_response = response;
self.described_revsets.lock().unwrap().clone() self
}
pub fn new_revision_calls(&self) -> Vec<String> {
self.new_revision_calls.lock().unwrap().clone()
} }
} }
@@ -108,6 +112,17 @@ impl JjExecutor for MockJjExecutor {
async fn get_description(&self, _revset: &str) -> Result<String, Error> { async fn get_description(&self, _revset: &str) -> Result<String, Error> {
self.get_description_response.clone() self.get_description_response.clone()
} }
async fn new_revision(&self, revset: &str) -> Result<(), Error> {
self.new_revision_calls
.lock()
.unwrap()
.push(revset.to_string());
match &self.new_revision_response {
Ok(()) => Ok(()),
Err(e) => Err(e.clone()),
}
}
} }
#[cfg(test)] #[cfg(test)]
@@ -256,4 +271,61 @@ mod tests {
mock.is_repository().await.unwrap(); mock.is_repository().await.unwrap();
assert!(mock.was_is_repo_called()); assert!(mock.was_is_repo_called());
} }
/// Test mock new_revision() records the revset
#[tokio::test]
async fn mock_new_revision_records_revset() {
let mock = MockJjExecutor::new();
let result = mock.new_revision("@").await;
assert!(result.is_ok());
let calls = mock.new_revision_calls();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0], "@");
}
/// Test mock new_revision() records multiple calls
#[tokio::test]
async fn mock_new_revision_records_multiple_calls() {
let mock = MockJjExecutor::new();
mock.new_revision("@").await.unwrap();
mock.new_revision("abc").await.unwrap();
mock.new_revision("xyz").await.unwrap();
let calls = mock.new_revision_calls();
assert_eq!(calls.len(), 3);
assert_eq!(calls[0], "@");
assert_eq!(calls[1], "abc");
assert_eq!(calls[2], "xyz");
}
/// Test mock new_revision() returns configured error
#[tokio::test]
async fn mock_new_revision_returns_error() {
let mock = MockJjExecutor::new().with_new_revision_response(Err(Error::RepositoryLocked));
let result = mock.new_revision("@").await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::RepositoryLocked));
}
/// Test mock new_revision() records revset even on error
#[tokio::test]
async fn mock_new_revision_records_revset_on_error() {
let mock = MockJjExecutor::new().with_new_revision_response(Err(Error::JjOperation {
context: "failed".to_string(),
}));
let result = mock.new_revision("abc").await;
assert!(result.is_err());
let calls = mock.new_revision_calls();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0], "abc");
}
/// Test mock new_revision() can be inspected after success
#[tokio::test]
async fn mock_new_revision_returns_ok_and_tracks_revset() {
let mock = MockJjExecutor::new();
let result = mock.new_revision("my-feature").await;
assert!(result.is_ok());
let calls = mock.new_revision_calls();
assert_eq!(calls, vec!["my-feature"]);
}
} }
+5
View File
@@ -21,6 +21,11 @@ pub trait JjExecutor: Send + Sync {
/// Get the current description of a specific revision /// Get the current description of a specific revision
async fn get_description(&self, revset: &str) -> Result<String, Error>; async fn get_description(&self, revset: &str) -> Result<String, Error>;
/// Create a new empty child revision parented on `revset`.
///
/// Equivalent to `jj new <revset>`
async fn new_revision(&self, revset: &str) -> Result<(), Error>;
} }
#[cfg(test)] #[cfg(test)]
+1 -1
View File
@@ -1,5 +1,5 @@
mod commit; mod commit;
mod error; pub mod error;
mod jj; mod jj;
mod prompts; mod prompts;
+17 -13
View File
@@ -10,7 +10,7 @@ const EXIT_CANCELLED: i32 = 130; // Same as SIGINT (Ctrl+C)
const EXIT_ERROR: i32 = 1; const EXIT_ERROR: i32 = 1;
/// Map application errors to appropriate exit codes /// Map application errors to appropriate exit codes
fn error_to_exit_code(error: &Error) -> i32 { fn error_to_exit_code(error: Error) -> i32 {
match error { match error {
Error::Cancelled => EXIT_CANCELLED, Error::Cancelled => EXIT_CANCELLED,
Error::NotARepository => EXIT_ERROR, Error::NotARepository => EXIT_ERROR,
@@ -24,6 +24,7 @@ fn error_to_exit_code(error: &Error) -> i32 {
Error::FailedReadingConfig { .. } => EXIT_ERROR, Error::FailedReadingConfig { .. } => EXIT_ERROR,
Error::RevsetResolutionError { .. } => EXIT_ERROR, Error::RevsetResolutionError { .. } => EXIT_ERROR,
Error::MultipleRevisions { .. } => EXIT_ERROR, Error::MultipleRevisions { .. } => EXIT_ERROR,
Error::NewFlagWithMultipleRevisions => EXIT_ERROR,
} }
} }
@@ -34,25 +35,26 @@ fn is_interactive_terminal() -> bool {
} }
#[tokio::main] #[tokio::main]
async fn main() { async fn main() -> Result<(), ()> {
let cli = cli::Cli::parse(); let cli = cli::Cli::parse();
cli.validate().map_err(exit_on_error)?;
if !is_interactive_terminal() { if !is_interactive_terminal() {
eprintln!("❌ Error: jj-cz requires an interactive terminal (TTY)"); eprintln!("❌ Error: jj-cz requires an interactive terminal (TTY)");
eprintln!(" This tool cannot be used in non-interactive mode or when piping input."); eprintln!(" This tool cannot be used in non-interactive mode or when piping input.");
eprintln!(" Use --help for usage information."); eprintln!(" Use --help for usage information.");
process::exit(EXIT_ERROR); process::exit(EXIT_ERROR);
} }
let executor = match JjLib::new().await { let executor = JjLib::new().await.map_err(exit_on_error)?;
Ok(e) => e,
Err(e) => {
eprintln!("❌ Error: {}", e);
process::exit(EXIT_ERROR);
}
};
let workflow = CommitWorkflow::new(executor); let workflow = CommitWorkflow::new(executor);
for revset in cli.revsets() { for revset in cli.revsets() {
let result = workflow.run_for_revset(revset).await; let result = workflow.run_for_revset(revset).await;
handle_result(result); handle_result(result);
if cli.create_new() {
println!("Creating a new revision after {revset}");
workflow.new_revision(revset).await.map_err(exit_on_error)?;
}
} }
fn handle_result(result: Result<(), Error>) { fn handle_result(result: Result<(), Error>) {
@@ -62,11 +64,13 @@ async fn main() {
println!("🟡 Operation cancelled by user."); println!("🟡 Operation cancelled by user.");
process::exit(EXIT_CANCELLED); process::exit(EXIT_CANCELLED);
} }
Err(e) => { Err(e) => exit_on_error(e),
eprintln!("❌ Error: {}", e);
process::exit(error_to_exit_code(&e));
}
} }
} }
process::exit(EXIT_SUCCESS); process::exit(EXIT_SUCCESS);
} }
fn exit_on_error(e: Error) {
eprintln!("❌ Error: {}", e);
process::exit(error_to_exit_code(e));
}
+59 -1
View File
@@ -8,7 +8,7 @@
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use crate::{ use crate::{
commit::types::{Body, BreakingChange, CommitType, Description, Scope}, commit::types::{Body, BreakingChange, CommitType, Description, References, Scope},
error::Error, error::Error,
prompts::prompter::Prompter, prompts::prompter::Prompter,
}; };
@@ -20,6 +20,7 @@ enum MockResponse {
Scope(Scope), Scope(Scope),
Description(Description), Description(Description),
BreakingChange(BreakingChange), BreakingChange(BreakingChange),
References(References),
Body(Body), Body(Body),
Confirm(bool), Confirm(bool),
Error(Error), Error(Error),
@@ -81,6 +82,15 @@ impl MockPrompts {
self self
} }
/// Configure the mock to return specific references
pub fn with_references(self, references: References) -> Self {
self.responses
.lock()
.unwrap()
.push(MockResponse::References(references));
self
}
/// Configure the mock to return a specific body response /// Configure the mock to return a specific body response
pub fn with_body(self, body: Body) -> Self { pub fn with_body(self, body: Body) -> Self {
self.responses self.responses
@@ -140,6 +150,14 @@ impl MockPrompts {
.contains(&"input_breaking_change".to_string()) .contains(&"input_breaking_change".to_string())
} }
/// Check if input_references was called
pub fn was_references_called(&self) -> bool {
self.prompts_called
.lock()
.unwrap()
.contains(&"input_references".to_string())
}
/// Check if confirm_apply was called /// Check if confirm_apply was called
pub fn was_confirm_called(&self) -> bool { pub fn was_confirm_called(&self) -> bool {
self.prompts_called self.prompts_called
@@ -207,6 +225,18 @@ impl Prompter for MockPrompts {
} }
} }
fn input_references(&self) -> Result<References, Error> {
self.prompts_called
.lock()
.unwrap()
.push("input_references".to_string());
match self.responses.lock().unwrap().remove(0) {
MockResponse::References(r) => Ok(r),
MockResponse::Error(e) => Err(e),
_ => panic!("MockPrompts: Expected References response, got different type"),
}
}
fn input_body(&self) -> Result<Body, Error> { fn input_body(&self) -> Result<Body, Error> {
self.prompts_called self.prompts_called
.lock() .lock()
@@ -336,6 +366,34 @@ mod tests {
assert!(mock.emitted_messages().is_empty()); assert!(mock.emitted_messages().is_empty());
} }
#[test]
fn mock_input_references() {
let refs = References::from("#123, #456");
let mock = MockPrompts::new().with_references(refs.clone());
let result = mock.input_references();
assert!(result.is_ok());
assert_eq!(result.unwrap(), refs);
assert!(mock.was_references_called());
}
#[test]
fn mock_input_references_default() {
let mock = MockPrompts::new().with_references(References::default());
let result = mock.input_references();
assert!(result.is_ok());
assert_eq!(result.unwrap(), References::default());
assert!(mock.was_references_called());
}
#[test]
fn mock_input_references_error() {
let mock = MockPrompts::new().with_error(Error::Cancelled);
let result = mock.input_references();
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::Cancelled));
assert!(mock.was_references_called());
}
#[test] #[test]
fn mock_input_breaking_change_no() { fn mock_input_breaking_change_no() {
let mock = MockPrompts::new().with_breaking_change(BreakingChange::No); let mock = MockPrompts::new().with_breaking_change(BreakingChange::No);
+17 -1
View File
@@ -9,7 +9,7 @@ use inquire::{Confirm, Text};
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
use crate::{ use crate::{
commit::types::{Body, BreakingChange, CommitType, Description, Scope}, commit::types::{Body, BreakingChange, CommitType, Description, References, Scope},
error::Error, error::Error,
}; };
@@ -33,6 +33,9 @@ pub trait Prompter {
/// Prompt the user to optionally add a free-form body via an external editor /// Prompt the user to optionally add a free-form body via an external editor
fn input_body(&self) -> Result<Body, Error>; fn input_body(&self) -> Result<Body, Error>;
/// Prompt the user to optionally add comma-separated ticket references
fn input_references(&self) -> Result<References, Error>;
/// Prompt the user to confirm applying the commit message /// Prompt the user to confirm applying the commit message
fn confirm_apply(&self, message: &str) -> Result<bool, Error>; fn confirm_apply(&self, message: &str) -> Result<bool, Error>;
@@ -91,6 +94,19 @@ impl Prompter for RealPrompts {
} }
} }
fn input_references(&self) -> Result<References, Error> {
let answer = inquire::Text::new("Enter comma-separated references (optional):")
.with_help_message("References are optional. If provided, will become footer(s) in the commit message. References must be comma-separated.")
.with_placeholder("Leave empty if no references")
.prompt_skippable()
.map_err(|_| Error::Cancelled)?;
match answer {
None => Ok(References::default()),
Some(s) if s.trim().is_empty() => Ok(References::default()),
Some(s) => Ok(References::from(s)),
}
}
fn input_description(&self) -> Result<Description, Error> { fn input_description(&self) -> Result<Description, Error> {
loop { loop {
let answer = Text::new("Enter description (required):") let answer = Text::new("Enter description (required):")
+101 -4
View File
@@ -6,7 +6,7 @@
use crate::{ use crate::{
commit::types::{ commit::types::{
Body, BreakingChange, CommitMessageError, CommitType, ConventionalCommit, Description, Body, BreakingChange, CommitMessageError, CommitType, ConventionalCommit, Description,
Scope, References, Scope,
}, },
error::Error, error::Error,
jj::JjExecutor, jj::JjExecutor,
@@ -65,8 +65,16 @@ impl<J: JjExecutor, P: Prompter> CommitWorkflow<J, P> {
let scope = self.scope_input()?; let scope = self.scope_input()?;
let description = self.description_input()?; let description = self.description_input()?;
let breaking_change = self.breaking_change_input()?; let breaking_change = self.breaking_change_input()?;
let references = self.references_input()?;
let body = self.body_input()?; let body = self.body_input()?;
match self.preview_and_confirm(commit_type, scope, description, breaking_change, body) { match self.preview_and_confirm(
commit_type,
scope,
description,
breaking_change,
body,
references,
) {
Ok(conventional_commit) => { Ok(conventional_commit) => {
self.executor self.executor
.describe(revset, &conventional_commit.to_string()) .describe(revset, &conventional_commit.to_string())
@@ -113,6 +121,11 @@ impl<J: JjExecutor, P: Prompter> CommitWorkflow<J, P> {
self.prompts.input_breaking_change() self.prompts.input_breaking_change()
} }
/// Prompt user for references
fn references_input(&self) -> Result<References, Error> {
self.prompts.input_references()
}
/// Prompt user to optionally add a free-form body via an external editor /// Prompt user to optionally add a free-form body via an external editor
fn body_input(&self) -> Result<Body, Error> { fn body_input(&self) -> Result<Body, Error> {
self.prompts.input_body() self.prompts.input_body()
@@ -129,6 +142,7 @@ impl<J: JjExecutor, P: Prompter> CommitWorkflow<J, P> {
description: Description, description: Description,
breaking_change: BreakingChange, breaking_change: BreakingChange,
body: Body, body: Body,
references: References,
) -> Result<ConventionalCommit, Error> { ) -> Result<ConventionalCommit, Error> {
// Format the message for preview // Format the message for preview
let message = ConventionalCommit::format_preview( let message = ConventionalCommit::format_preview(
@@ -137,6 +151,7 @@ impl<J: JjExecutor, P: Prompter> CommitWorkflow<J, P> {
&description, &description,
&breaking_change, &breaking_change,
&body, &body,
&references,
); );
// Try to build the conventional commit (this validates the 72-char limit) // Try to build the conventional commit (this validates the 72-char limit)
@@ -146,6 +161,7 @@ impl<J: JjExecutor, P: Prompter> CommitWorkflow<J, P> {
description.clone(), description.clone(),
breaking_change, breaking_change,
body, body,
references,
) { ) {
Ok(cc) => cc, Ok(cc) => cc,
Err(CommitMessageError::FirstLineTooLong { actual, max }) => { Err(CommitMessageError::FirstLineTooLong { actual, max }) => {
@@ -184,6 +200,10 @@ impl<J: JjExecutor, P: Prompter> CommitWorkflow<J, P> {
Err(Error::Cancelled) Err(Error::Cancelled)
} }
} }
pub async fn new_revision(&self, revset: &str) -> Result<(), Error> {
self.executor.new_revision(revset).await
}
} }
#[cfg(test)] #[cfg(test)]
@@ -272,8 +292,15 @@ mod tests {
let description = Description::parse("test description").unwrap(); let description = Description::parse("test description").unwrap();
let breaking_change = BreakingChange::No; let breaking_change = BreakingChange::No;
let body = Body::default(); let body = Body::default();
let result = let references = References::default();
workflow.preview_and_confirm(commit_type, scope, description, breaking_change, body); let result = workflow.preview_and_confirm(
commit_type,
scope,
description,
breaking_change,
body,
references,
);
assert!(result.is_ok()); assert!(result.is_ok());
} }
@@ -318,6 +345,7 @@ mod tests {
.with_scope(Scope::empty()) .with_scope(Scope::empty())
.with_description(Description::parse("add new feature").unwrap()) .with_description(Description::parse("add new feature").unwrap())
.with_breaking_change(BreakingChange::Yes) .with_breaking_change(BreakingChange::Yes)
.with_references(References::default())
.with_body(Body::default()) .with_body(Body::default())
.with_confirm(true); .with_confirm(true);
@@ -351,6 +379,7 @@ mod tests {
.with_scope(Scope::parse("api").unwrap()) .with_scope(Scope::parse("api").unwrap())
.with_description(Description::parse("fix bug").unwrap()) .with_description(Description::parse("fix bug").unwrap())
.with_breaking_change(BreakingChange::No) .with_breaking_change(BreakingChange::No)
.with_references(References::default())
.with_body(Body::default()) .with_body(Body::default())
.with_confirm(false); // User cancels at confirmation .with_confirm(false); // User cancels at confirmation
@@ -375,11 +404,13 @@ mod tests {
.with_scope(Scope::parse("very-long-scope-name").unwrap()) .with_scope(Scope::parse("very-long-scope-name").unwrap())
.with_description(Description::parse("a".repeat(45)).unwrap()) .with_description(Description::parse("a".repeat(45)).unwrap())
.with_breaking_change(BreakingChange::No) .with_breaking_change(BreakingChange::No)
.with_references(References::default())
.with_body(Body::default()) .with_body(Body::default())
// Second iteration: short enough to succeed // Second iteration: short enough to succeed
.with_scope(Scope::empty()) .with_scope(Scope::empty())
.with_description(Description::parse("short description").unwrap()) .with_description(Description::parse("short description").unwrap())
.with_breaking_change(BreakingChange::No) .with_breaking_change(BreakingChange::No)
.with_references(References::default())
.with_body(Body::default()) .with_body(Body::default())
.with_confirm(true); .with_confirm(true);
@@ -474,6 +505,7 @@ mod tests {
.with_scope(Scope::empty()) .with_scope(Scope::empty())
.with_description(Description::parse("test").unwrap()) .with_description(Description::parse("test").unwrap())
.with_breaking_change(BreakingChange::Yes) .with_breaking_change(BreakingChange::Yes)
.with_references(References::default())
.with_body(Body::default()) .with_body(Body::default())
.with_confirm(true); .with_confirm(true);
@@ -497,6 +529,7 @@ mod tests {
.with_scope(Scope::empty()) .with_scope(Scope::empty())
.with_description(Description::parse("test").unwrap()) .with_description(Description::parse("test").unwrap())
.with_breaking_change(BreakingChange::Yes) .with_breaking_change(BreakingChange::Yes)
.with_references(References::default())
.with_body(Body::default()) .with_body(Body::default())
.with_confirm(true); .with_confirm(true);
@@ -515,6 +548,7 @@ mod tests {
.with_scope(Scope::parse("api").unwrap()) .with_scope(Scope::parse("api").unwrap())
.with_description(Description::parse("test").unwrap()) .with_description(Description::parse("test").unwrap())
.with_breaking_change(BreakingChange::No) .with_breaking_change(BreakingChange::No)
.with_references(References::default())
.with_body(Body::default()) .with_body(Body::default())
.with_confirm(true); .with_confirm(true);
@@ -562,6 +596,7 @@ mod tests {
Description::parse("remove old API").unwrap(), Description::parse("remove old API").unwrap(),
BreakingChange::Yes, BreakingChange::Yes,
Body::default(), Body::default(),
References::default(),
); );
assert!(result.is_ok(), "expected Ok, got: {:?}", result); assert!(result.is_ok(), "expected Ok, got: {:?}", result);
@@ -589,6 +624,7 @@ mod tests {
Description::parse("drop legacy API").unwrap(), Description::parse("drop legacy API").unwrap(),
breaking_change, breaking_change,
Body::default(), Body::default(),
References::default(),
); );
assert!(result.is_ok(), "expected Ok, got: {:?}", result); assert!(result.is_ok(), "expected Ok, got: {:?}", result);
@@ -619,6 +655,7 @@ mod tests {
.with_scope(Scope::empty()) .with_scope(Scope::empty())
.with_description(Description::parse("remove old API").unwrap()) .with_description(Description::parse("remove old API").unwrap())
.with_breaking_change(BreakingChange::Yes) .with_breaking_change(BreakingChange::Yes)
.with_references(References::default())
.with_body(Body::default()) .with_body(Body::default())
.with_confirm(true); .with_confirm(true);
@@ -661,6 +698,7 @@ mod tests {
Description::parse("add feature").unwrap(), Description::parse("add feature").unwrap(),
BreakingChange::No, BreakingChange::No,
Body::from("This explains the change."), Body::from("This explains the change."),
References::default(),
); );
assert!(result.is_ok(), "expected Ok, got: {:?}", result); assert!(result.is_ok(), "expected Ok, got: {:?}", result);
@@ -688,6 +726,7 @@ mod tests {
Description::parse("drop legacy API").unwrap(), Description::parse("drop legacy API").unwrap(),
"removes legacy endpoint".into(), "removes legacy endpoint".into(),
Body::from("The endpoint was deprecated in v2."), Body::from("The endpoint was deprecated in v2."),
References::default(),
); );
assert!(result.is_ok(), "expected Ok, got: {:?}", result); assert!(result.is_ok(), "expected Ok, got: {:?}", result);
@@ -714,6 +753,7 @@ mod tests {
.with_scope(Scope::empty()) .with_scope(Scope::empty())
.with_description(Description::parse("add feature").unwrap()) .with_description(Description::parse("add feature").unwrap())
.with_breaking_change(BreakingChange::No) .with_breaking_change(BreakingChange::No)
.with_references(References::default())
.with_body(Body::from("This explains the change.")) .with_body(Body::from("This explains the change."))
.with_confirm(true); .with_confirm(true);
@@ -746,6 +786,7 @@ mod tests {
.with_scope(Scope::empty()) .with_scope(Scope::empty())
.with_description(Description::parse("fix crash").unwrap()) .with_description(Description::parse("fix crash").unwrap())
.with_breaking_change(BreakingChange::No) .with_breaking_change(BreakingChange::No)
.with_references(References::default())
.with_body(Body::default()) .with_body(Body::default())
.with_confirm(true); .with_confirm(true);
@@ -762,4 +803,60 @@ mod tests {
assert_eq!(messages.len(), 1); assert_eq!(messages.len(), 1);
assert_eq!(messages[0], "fix: fix crash"); assert_eq!(messages[0], "fix: fix crash");
} }
/// Test workflow new_revision() records the revset
#[tokio::test]
async fn workflow_new_revision_records_revset() {
let mock_executor = MockJjExecutor::new();
let workflow = CommitWorkflow::new(mock_executor);
let result = workflow.new_revision("@").await;
assert!(result.is_ok());
let calls = workflow.executor.new_revision_calls();
assert_eq!(calls, vec!["@"]);
}
/// Test workflow new_revision() propagates executor errors
#[tokio::test]
async fn workflow_new_revision_propagates_error() {
let mock_executor =
MockJjExecutor::new().with_new_revision_response(Err(Error::RepositoryLocked));
let workflow = CommitWorkflow::new(mock_executor);
let result = workflow.new_revision("@").await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::RepositoryLocked));
}
/// Test workflow run_for_revset() followed by new_revision() records both
///
/// This mirrors the actual usage pattern in main.rs.
#[tokio::test]
async fn workflow_describe_then_new_revision() {
let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
let mock_prompts = MockPrompts::new()
.with_commit_type(CommitType::Feat)
.with_scope(Scope::empty())
.with_description(Description::parse("add feature").unwrap())
.with_breaking_change(BreakingChange::No)
.with_references(References::default())
.with_body(Body::default())
.with_confirm(true);
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
workflow.run_for_revset("@").await.expect("describe failed");
workflow
.new_revision("@")
.await
.expect("new_revision failed");
let messages = workflow.executor.describe_messages();
assert_eq!(messages.len(), 1);
assert!(messages[0].contains("feat:"));
let calls = workflow.executor.new_revision_calls();
assert_eq!(calls, vec!["@"]);
}
} }
-78
View File
@@ -1,78 +0,0 @@
use assert_fs::TempDir;
#[cfg(feature = "test-utils")]
use jj_cz::{Body, BreakingChange, CommitType, Description, MockJjExecutor, MockPrompts, Scope};
use jj_cz::{CommitWorkflow, Error, JjLib};
#[cfg(feature = "test-utils")]
#[tokio::test]
async fn test_happy_path_integration() {
// T037: Happy path integration test
let mock_executor = MockJjExecutor::new();
let mock_prompts = MockPrompts::new()
.with_commit_type(CommitType::Feat)
.with_scope(Scope::empty())
.with_description(Description::parse("add new feature").unwrap())
.with_breaking_change(BreakingChange::No)
.with_body(Body::default())
.with_confirm(true);
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result = workflow.run_for_revset("@").await;
assert!(
result.is_ok(),
"Workflow should complete successfully: {:?}",
result
);
}
#[tokio::test]
async fn test_not_in_repo() {
// T038: Not-in-repo integration test - with_working_dir itself returns the error
let temp_dir = TempDir::new().unwrap();
let result = JjLib::with_working_dir(temp_dir.path()).await;
assert!(matches!(result, Err(Error::NotARepository)));
}
#[cfg(feature = "test-utils")]
#[tokio::test]
async fn test_cancellation() {
// T039: Cancellation integration test
// This is tricky to test directly without a TTY
// We'll test the error handling path instead
// Create a mock executor that simulates cancellation
struct CancelMock;
#[async_trait::async_trait(?Send)]
impl jj_cz::JjExecutor for CancelMock {
async fn is_repository(&self) -> Result<bool, Error> {
Ok(true)
}
async fn describe(&self, _revset: &str, _message: &str) -> Result<(), Error> {
Err(Error::Cancelled)
}
async fn get_description(&self, _revset: &str) -> Result<String, Error> {
Ok(String::new())
}
}
let executor = CancelMock;
let mock_prompts = MockPrompts::new()
.with_commit_type(CommitType::Feat)
.with_scope(Scope::empty())
.with_description(Description::parse("test").unwrap())
.with_breaking_change(BreakingChange::No)
.with_body(Body::default())
.with_confirm(true);
let workflow = CommitWorkflow::with_prompts(executor, mock_prompts);
let result = workflow.run_for_revset("@").await;
// Should fail with Cancelled error
assert!(matches!(result, Err(Error::Cancelled)));
}
+152
View File
@@ -135,3 +135,155 @@ fn test_jj_operation_context() {
panic!("Expected JjOperation variant"); panic!("Expected JjOperation variant");
} }
} }
/// Test conversion from std::io::Error
#[test]
fn test_from_io_error() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
let error: Error = io_err.into();
assert!(matches!(error, Error::FailedGettingCurrentDir));
}
/// Test conversion from std::sync::PoisonError
#[test]
fn test_from_poison_error() {
let mutex = std::sync::Mutex::new(());
// Poison the mutex by panicking while holding the lock
let poison_err = std::panic::catch_unwind(|| {
let _guard = mutex.lock().unwrap();
panic!("deliberate panic");
});
assert!(poison_err.is_err());
// Now lock should fail with PoisonError
let result = mutex.lock();
assert!(result.is_err());
let error: Error = result.unwrap_err().into();
assert!(matches!(error, Error::JjOperation { .. }));
assert_eq!(
format!("{}", error),
"Repository operation failed: internal lock poisoned"
);
}
/// Test from_revset_evaluation_error constructs RevsetResolutionError
#[test]
fn test_from_revset_evaluation_error() {
let underlying = std::io::Error::other("store failure");
let eval_err = jj_lib::revset::RevsetEvaluationError::Other(Box::new(underlying));
let error = Error::from_revset_evaluation_error("@", eval_err);
assert!(matches!(error, Error::RevsetResolutionError { .. }));
let description = format!("{}", error);
assert!(description.contains("@"));
assert!(description.contains("store failure"));
}
/// Test from_revset_resolution_error constructs RevsetResolutionError
#[test]
fn test_from_revset_resolution_error() {
let resolution_err = jj_lib::revset::RevsetResolutionError::NoSuchRevision {
name: "nonexistent".to_string(),
candidates: Vec::new(),
};
let error = Error::from_revset_resolution_error("@", resolution_err);
assert!(matches!(error, Error::RevsetResolutionError { .. }));
let description = format!("{}", error);
assert!(description.contains("@"));
assert!(description.contains("nonexistent"));
}
/// Test NewFlagWithMultipleRevisions error display
#[test]
fn test_new_flag_with_multiple_revisions() {
let error = Error::NewFlagWithMultipleRevisions;
assert_eq!(
format!("{}", error),
"--new cannot be used with multiple revisions"
);
}
/// Test NonInteractive error display
#[test]
fn test_non_interactive() {
let error = Error::NonInteractive;
assert_eq!(format!("{}", error), "Non-interactive terminal detected");
}
/// Test FailedReadingConfig error display
#[test]
fn test_failed_reading_config() {
let error = Error::FailedReadingConfig {
context: "config parse error".to_string(),
};
let description = format!("{}", error);
assert!(description.contains("config parse error"));
}
/// Test MultipleRevisions error display
#[test]
fn test_multiple_revisions() {
let error = Error::MultipleRevisions {
revset: "abc | def".to_string(),
};
let description = format!("{}", error);
assert!(description.contains("abc | def"));
assert!(description.contains("multiple commits"));
}
/// Test RepositoryLocked error display
#[test]
fn test_repository_locked() {
let error = Error::RepositoryLocked;
assert_eq!(
format!("{}", error),
"Repository is locked by another process"
);
}
/// Test FailedGettingCurrentDir error display
#[test]
fn test_failed_getting_current_dir() {
let error = Error::FailedGettingCurrentDir;
assert_eq!(format!("{}", error), "Could not get current directory");
}
/// Test error matching on all variants
#[test]
fn test_error_matching_all_variants() {
let variants: Vec<Error> = vec![
Error::InvalidScope("s".into()),
Error::InvalidDescription("d".into()),
Error::InvalidCommitMessage("m".into()),
Error::NotARepository,
Error::JjOperation {
context: "c".into(),
},
Error::RepositoryLocked,
Error::FailedGettingCurrentDir,
Error::FailedReadingConfig {
context: "c".into(),
},
Error::Cancelled,
Error::NonInteractive,
Error::RevsetResolutionError {
revset: "@".into(),
context: "c".into(),
},
Error::MultipleRevisions { revset: "@".into() },
Error::NewFlagWithMultipleRevisions,
];
// All variants should be displayable without panicking
for variant in &variants {
let _ = format!("{}", variant);
let _ = format!("{:?}", variant);
}
// Verify all variants can be cloned
let cloned: Vec<Error> = variants.to_vec();
assert_eq!(variants.len(), cloned.len());
for (original, clone) in variants.iter().zip(cloned.iter()) {
assert_eq!(original, clone);
}
}