8 Commits

Author SHA1 Message Date
phundrak 0ec750eb66 feat(cli): add jj-lib version to version output
Publish Docker Images / coverage-and-sonar (push) Failing after 6m2s
2026-04-21 21:02:14 +02:00
phundrak c65e493571 feat: set message for multiple revsets
Allows to set the revision message of multiple revisions by passing
them as arguments. This only supports simple revisions, such as `@`,
`@-`, `xs`, and so on. Comple revisions such as `@..@-` are not
supported.

Fixes: #5
2026-04-21 20:50:52 +02:00
phundrak 3da214ae4c refactor(prompter): simplify commit type selection
Publish Docker Images / coverage-and-sonar (push) Failing after 14m51s
2026-04-05 16:41:11 +02:00
phundrak 64652fc81d fix(scope): no new string allocation to count characters 2026-04-05 16:41:11 +02:00
phundrak 0ef1f61613 feat(errors): preserve jj-emitted errors when loading config 2026-04-05 16:41:11 +02:00
phundrak 52f0667777 refactor(BreakingChange): rename method ignore to is_absent
Method `ignore` did not carry its meaning well by the way it is named.
This commit renames it to `is_absent` to clearly state this method
returns whether we have a breaking change.
2026-04-05 16:41:11 +02:00
phundrak 61288e8f49 refactor(workflow): remove unnecessary async declarations 2026-04-05 16:41:11 +02:00
phundrak 1c983f3a8d refactor(nix): simplify package declaration 2026-04-05 15:56:24 +02:00
36 changed files with 940 additions and 1824 deletions
+3 -1
View File
@@ -4,6 +4,8 @@ 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
@@ -18,5 +20,5 @@ if [[ -f .envrc.local ]]; then
fi fi
if ! use flake . --no-pure-eval; then if ! use flake . --no-pure-eval; then
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 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
fi fi
+27 -31
View File
@@ -1,4 +1,4 @@
name: Run checks and build archives name: Publish Docker Images
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,36 +56,32 @@ 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 }}
build: - name: Build Linux release binary
needs: coverage-and-sonar run: nix build --no-pure-eval --accept-flake-config
strategy:
matrix:
target: ["linux-x86_64", "linux-aarch64", "windows-x86_64"]
runs-on: ubuntu-latest - name: Prepare Linux binary
permissions: run: |
contents: read mkdir dist-linux
pull-requests: read cp result/bin/jj-cz dist-linux/
steps: cp LICENSE.*.md dist-linux/
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Nix - name: Upload Linux artifact
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-${{matrix.target}} name: jj-cz-x86_64-unknown-linux-gnu
path: result/dist/* path: dist-linux/*
- 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/*
+39 -67
View File
@@ -6,12 +6,29 @@ on:
- main - main
jobs: jobs:
release: 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:
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:
@@ -32,103 +49,58 @@ jobs:
- name: Check for releasable commits - name: Check for releasable commits
id: releasable id: releasable
run: | run: |
COUNT=$(nix develop --no-pure-eval --accept-flake-config --command just cliff-count) COUNT=$(nix develop --no-pure-eval --command just cliff-count)
if [ "$COUNT" -gt 0 ]; then echo "count=$COUNT" >> $GITHUB_OUTPUT
echo "release=true" >> $GITHUB_OUTPUT
else
echo "release=false" >> $GITHUB_OUTPUT
fi
- name: Determine next version - name: Determine next version
if: steps.releasable.outputs.release == 'true' if: steps.releasable.outputs.count > 0
id: next_version id: next_version
run: | run: |
CLIFF_NEXT_VERSION=$(nix develop --no-pure-eval --accept-flake-config --command just cliff-next-version) CLIFF_NEXT_VERSION=$(nix develop --no-pure-eval --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.release == 'true' if: steps.releasable.outputs.count > 0
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.release == 'true' if: steps.releasable.outputs.count > 0
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.release == 'true' if: steps.releasable.outputs.count > 0
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: Create Gitea release - name: Build Linux release binaries
if: steps.releasable.outputs.release == 'true' if: steps.releasable.outputs.count > 0
id: create_release run: nix build
env:
VERSION: ${{ steps.next_version.outputs.version }} - name: Build Windows release binaries
CI_TOKEN: ${{ secrets.CI_TOKEN }} if: steps.releasable.outputs.count > 0
shell: bash -c "nix develop --no-pure-eval --accept-flake-config --command {0}" run: nix build .#windows
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.release == 'true' if: steps.releasable.outputs.count > 0
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.release == 'true' if: steps.releasable.outputs.count > 0
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.release == 'true' if: steps.releasable.outputs.count > 0
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"
+1 -36
View File
@@ -47,8 +47,7 @@ accepted, provided you
assistance); assistance);
3. are prepared to discuss it directly with human reviewers. 3. are prepared to discuss it directly with human reviewers.
**All AI usage requires explicit disclosure** (see Attribution section **All AI usage requires explicit disclosure**, except in these cases:
for commit message requirements), except in these cases:
- Trivial tab autocompletion, but only for completion that you have - Trivial tab autocompletion, but only for completion that you have
already conceptualized in your mind. already conceptualized in your mind.
- Asking the AI about knowledge that is not directly related to your - Asking the AI about knowledge that is not directly related to your
@@ -63,40 +62,6 @@ the AI **MUST** be included in the repository. AI **MAY** generate the
initial output, but the final specification **MUST** be entirely initial output, but the final specification **MUST** be entirely
reviewed and understood by a human. reviewed and understood by a human.
### Attribution
<!-- Inspired by the Linux Kernel AI Coding Assistants guidelines -->
When using AI assistance in contributions:
- **AI cannot be a commit author.** All commits must be authored by a
human contributor.
- **AI cannot sign off commits.** Only humans can legally certify
commits by adding a `Signed-off-by:` tag. AI tools MUST NOT add
`Signed-off-by` tags.
- **The human author bears full responsibility.** The human
contributor is responsible for:
- Reviewing all AI-generated or AI-assisted code
- Ensuring compliance with licensing requirements
- Taking full responsibility for the contribution
- **AI-assisted commits must include an `Assisted-by:` footer**. The
format is:
```
Assisted-by: AGENT_NAME:MODEL_VERSION [TOOL1] [TOOL2]
```
Where:
- `AGENT_NAME` is the name of the AI tool or framework
- `MODEL_VERSION` is the specific model version used
- `[TOOL1] [TOOL2]` are optional specialized analysis tools used
(not basic tools like git, cargo, Nix, editors)
Example:
```
Assisted-by: Claude:claude-3-sonnet
```
--- ---
## Guidelines for AI Agents ## Guidelines for AI Agents
-32
View File
@@ -1,35 +1,3 @@
## [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
+1 -38
View File
@@ -73,44 +73,7 @@ adhere to the following requirements:
(bug reports, feature requests, pull request descriptions, (bug reports, feature requests, pull request descriptions,
responding to humans, ...). responding to humans, ...).
### Attribution For more info, please refer to the [AGENTS.md](AGENTS.md) file.
When using AI assistance in contributions:
- **AI cannot be a commit author.** All commits must be authored by a
human contributor.
- **AI cannot sign off commits.** Only humans can legally certify
commits by adding a `Signed-off-by:` tag. AI tools MUST NOT add
`Signed-off-by` tags.
- **The human author bears full responsibility.** The human
contributor is responsible for:
- Reviewing all AI-generated or AI-assisted code
- Ensuring compliance with licensing requirements
- Taking full responsibility for the contribution
- **AI-assisted commits must include an `Assisted-by:` footer**. The
format is:
```
Assisted-by: AGENT_NAME:MODEL_VERSION [TOOL1] [TOOL2]
```
Where:
- `AGENT_NAME` is the name of the AI tool or framework
- `MODEL_VERSION` is the specific model version used
- `[TOOL1] [TOOL2]` are optional specialized analysis tools used
(not basic tools like git, cargo, Nix, editors)
Example:
```
Assisted-by: Claude:claude-3-sonnet
```
See the [AGENTS.md](AGENTS.md#attribution) file for the full format
specification.
For more info, please refer to the [AGENTS.md](AGENTS.md)
file.
## Code of Conduct ## Code of Conduct
Generated
+361 -300
View File
File diff suppressed because it is too large Load Diff
+11 -12
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "jj-cz" name = "jj-cz"
version = "1.1.0" version = "1.0.1-dev"
description = "Conventional commits for Jujutsu" description = "Conventional commits for Jujutsu"
edition = "2024" edition = "2024"
publish = true publish = true
@@ -24,22 +24,21 @@ 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.6.1", features = ["derive"] } clap = { version = "4.5.57", features = ["derive"] }
git-conventional = "1.1.0" git-conventional = "0.12.9"
inquire = { version = "0.9.4", features = ["editor"] } inquire = { version = "0.9.2", features = ["editor"] }
jj-lib = "0.42.0" jj-lib = "0.39.0"
lazy-regex = { version = "3.6.0", features = ["lite"] } lazy-regex = { version = "3.5.1", features = ["lite"] }
thiserror = "2.0.18" thiserror = "2.0.18"
tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread"] } tokio = { version = "1.49.0", 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.45" chrono = "0.4.44"
futures-util = "0.3.32"
[dev-dependencies] [dev-dependencies]
assert_cmd = "2.2.2" assert_cmd = "2.1.2"
assert_fs = "1.1.4" assert_fs = "1.1.3"
predicates = "3.1.4" predicates = "3.1.3"
[lints.rust] [lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] } unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] }
+7 -123
View File
@@ -1,45 +1,13 @@
--- # jj-cz: Conventional Commits for Jujutsu
include_toc: true
gitea: none
---
An interactive CLI tool that guides Jujutsu users through creating
<h1 align="center">jj-cz: Conventional Commits for Jujutsu</h1> [conventional commit](https://www.conventionalcommits.org/) messages.
<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, breaking changes, ticket references, and description - Interactive prompts for type, scope, and description
- All 11 commit types with descriptions (feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert) - All 11 commit types with descriptions (feat, fix, docs, style,
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
@@ -56,27 +24,6 @@ jj-cz
The tool detects whether you're in a Jujutsu repository, guides you The tool detects whether you're in a Jujutsu repository, guides you
through the commit message, and applies it to your current change. through the commit message, and applies it to your current change.
You can also set the revision message of a few revisions at once, or
target a single revision other than the current one.
```sh
jj-cz @- xs develop # assuming the revision xs and the bookmark develop exist
```
No explicit revision is simply the equivalent of `jj-cz @`, like
`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
@@ -94,72 +41,9 @@ what `jj-cz` alone would be good for without `jj`.
| 130 | Interrupted | | 130 | Interrupted |
## Installation ## Installation
### From crates.io
Simply run the following command:
``` You can install jj-cz with Cargo by building it from source.
cargo install jj-cz
```
Done! `jj-cz` is now available!
### With Nix Flakes
Notice how theres a `flake.nix` file? This means you can run the
project using this repository as one of your flakes inputs. In fact,
thats how I install it in my own NixOS configuration! Add this
repository to your configuration:
```nix
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
jj-cz = {
url = "git+https://labs.phundrak.com/phundrak/jj-cz";
inputs.nixpkgs.follows = "nixpkgs";
};
};
}
```
And tadah! you can now install
`inputs.jj-cz.packages.${pkgs.stdenv.hostPlatform.system}.default`
among your other packages. Take a look at my
[`jujutsu.nix`](https://labs.phundrak.com/phundrak/nix-config/src/branch/main/users/modules/dev/vcs/jujutsu.nix)
module if you need some inspiration.
### From source
You can also install `jj-cz` with Cargo by building it from source.
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
@@ -1,190 +0,0 @@
{"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.

Before

Width:  |  Height:  |  Size: 326 KiB

-7
View File
@@ -38,12 +38,6 @@ 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
@@ -88,4 +82,3 @@ 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"
+1 -6
View File
@@ -2,12 +2,7 @@ use cargo_lock::Lockfile;
fn main() { fn main() {
let lockfile = Lockfile::load("Cargo.lock").expect("Cargo.lock not found"); let lockfile = Lockfile::load("Cargo.lock").expect("Cargo.lock not found");
let version = lockfile let version = lockfile.packages.iter().find(|p| p.name.as_str() == "jj-lib").map(|p| p.version.to_string()).unwrap_or_else(|| "unknown".to_string());
.packages
.iter()
.find(|p| p.name.as_str() == "jj-lib")
.map(|p| p.version.to_string())
.unwrap_or_else(|| "unknown".to_string());
println!("cargo:rustc-env=JJ_LIB_VERSION={version}"); println!("cargo:rustc-env=JJ_LIB_VERSION={version}");
println!("cargo:rerun-if-changed=Cargo.lock"); println!("cargo:rerun-if-changed=Cargo.lock");
} }
Generated
+261 -12
View File
@@ -23,6 +23,65 @@
"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": [
@@ -45,6 +104,58 @@
"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"
@@ -79,25 +190,141 @@
"type": "github" "type": "github"
} }
}, },
"nixpkgs": { "git-hooks": {
"inputs": {
"flake-compat": [
"devenv",
"flake-compat"
],
"gitignore": "gitignore",
"nixpkgs": [
"devenv",
"nixpkgs"
]
},
"locked": { "locked": {
"lastModified": 1779877693, "lastModified": 1760663237,
"narHash": "sha256-NOF9NAREhxr50bbBfVcVOq+ArCMSoe8dP79Pk2uyARk=", "narHash": "sha256-BflA6U4AM1bzuRMR8QqzPXqh8sWVCNDzOdsxXEguJIc=",
"owner": "NixOS", "owner": "cachix",
"repo": "nixpkgs", "repo": "git-hooks.nix",
"rev": "4100e830e085863741bc69b156ec4ccd53ab5be0", "rev": "ca5b894d3e3e151ffc1db040b6ce4dcc75d31c37",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "NixOS", "owner": "cachix",
"ref": "nixpkgs-unstable", "repo": "git-hooks.nix",
"repo": "nixpkgs", "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": {
"locked": {
"lastModified": 1767052823,
"narHash": "sha256-Fhuljcy7pJ8HacYYATRcm5rdKXx8P6D/0g19ppzDRNY=",
"owner": "cachix",
"repo": "devenv-nixpkgs",
"rev": "538a5124359f0b3d466e1160378c87887e3b51a4",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "rolling",
"repo": "devenv-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"
@@ -127,11 +354,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1779992051, "lastModified": 1770260791,
"narHash": "sha256-4YWGv/0NkAdtTW1MXfaLYpfC9BhpCy9k1pWkR0xI9uw=", "narHash": "sha256-ADTBfENFjRVDQMcCycyX/pAy6NFI/Ct6Mrar3gsmXI0=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "e93ad0df1073b2c969a8f0c1f10b84e870469d40", "rev": "42ec85352e419e601775c57256a52f6d48a39906",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -154,6 +381,28 @@
"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",
+31 -15
View File
@@ -2,12 +2,16 @@
description = "Conventional commits for Jujutsu"; description = "Conventional commits for Jujutsu";
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; nixpkgs.url = "github:cachix/devenv-nixpkgs/rolling";
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";
@@ -15,16 +19,8 @@
}; };
nixConfig = { nixConfig = {
extra-trusted-public-keys = [ extra-trusted-public-keys = ["devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw=" "phundrak.cachix.org-1:osJAkYO0ioTOPqaQCIXMfIRz1/+YYlVFkup3R2KSexk="];
"nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs=" extra-substituters = ["https://devenv.cachix.org" "https://phundrak.cachix.org"];
"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 = {
@@ -33,17 +29,37 @@
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;
packages = import ./nix/packages.nix {inherit pkgs system;}; rustPlatform = pkgs.makeRustPlatform {
cargo = rustVersion;
rustc = rustVersion;
};
in { in {
inherit packages;
formatter = alejandra.defaultPackage.${system}; formatter = alejandra.defaultPackage.${system};
devShell = import ./nix/shell.nix {inherit pkgs rustVersion;}; packages = let
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
@@ -1,19 +0,0 @@
{
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
@@ -1,28 +0,0 @@
{
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
+36
View File
@@ -0,0 +1,36 @@
{
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;
};
postBuild = ''
${pkgs.upx}/bin/upx target/*/release/${name}
'';
};
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
@@ -1,82 +0,0 @@
{
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;
}
+8 -4
View File
@@ -1,8 +1,13 @@
{ {
inputs,
pkgs, pkgs,
rustVersion, rustVersion,
...
}: }:
pkgs.mkShell { inputs.devenv.lib.mkShell {
inherit inputs pkgs;
modules = [
{
packages = with pkgs; [ packages = with pkgs; [
(rustVersion.override { (rustVersion.override {
extensions = [ extensions = [
@@ -20,8 +25,7 @@ pkgs.mkShell {
git-cliff git-cliff
just just
typos typos
];
# for CI }
jq
]; ];
} }
-95
View File
@@ -18,10 +18,6 @@ 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 {
@@ -33,95 +29,4 @@ 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"));
}
} }
+2 -4
View File
@@ -4,12 +4,10 @@ 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());
let mut footer = if default.chars().count() > 72 { 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
} }
} }
+2 -27
View File
@@ -1,4 +1,4 @@
use super::{Body, BreakingChange, CommitType, Description, Footer, References, Scope}; use super::{Body, BreakingChange, CommitType, Description, Scope};
use thiserror::Error; use thiserror::Error;
/// Errors that can occur when creating a ConventionalCommit /// Errors that can occur when creating a ConventionalCommit
@@ -23,7 +23,6 @@ pub struct ConventionalCommit {
description: Description, description: Description,
breaking_change: BreakingChange, breaking_change: BreakingChange,
body: Body, body: Body,
references: References,
} }
impl ConventionalCommit { impl ConventionalCommit {
@@ -45,7 +44,6 @@ 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,
@@ -53,7 +51,6 @@ 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 {
@@ -95,7 +92,6 @@ impl ConventionalCommit {
&self.description, &self.description,
&self.breaking_change, &self.breaking_change,
&self.body, &self.body,
&self.references,
) )
} }
@@ -110,16 +106,14 @@ 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}{refs_footer}"#, {breaking_change_footer}"#,
body.format() body.format()
) )
.trim() .trim()
@@ -160,7 +154,6 @@ 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")
} }
@@ -644,7 +637,6 @@ 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();
@@ -675,7 +667,6 @@ 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!(
@@ -700,7 +691,6 @@ 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!(
@@ -721,7 +711,6 @@ 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());
} }
@@ -735,7 +724,6 @@ 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());
} }
@@ -762,7 +750,6 @@ 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());
@@ -794,7 +781,6 @@ 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.
@@ -932,7 +918,6 @@ 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!(
@@ -955,7 +940,6 @@ 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());
} }
@@ -975,7 +959,6 @@ 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());
} }
@@ -989,7 +972,6 @@ 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,
@@ -1006,7 +988,6 @@ 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,
@@ -1112,7 +1093,6 @@ 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!(
@@ -1130,7 +1110,6 @@ 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!(
@@ -1148,7 +1127,6 @@ 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!(
@@ -1168,7 +1146,6 @@ 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!(
@@ -1186,7 +1163,6 @@ 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.");
} }
@@ -1202,7 +1178,6 @@ 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,6 +18,3 @@ 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
@@ -1,186 +0,0 @@
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,8 +31,6 @@ 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 {
+19 -48
View File
@@ -10,7 +10,6 @@ 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},
@@ -152,7 +151,7 @@ impl JjLib {
} }
/// Resolve a revset string to a commit ID /// Resolve a revset string to a commit ID
async fn get_commit_id(&self, revset: &str) -> Result<CommitId, Error> { 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,
@@ -180,21 +179,20 @@ 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 all_ids = revision.commit_change_ids(); let mut iter = revision.iter();
let commit_id = all_ids let commit_id = iter
.next() .next()
.await
.ok_or(Error::RevsetResolutionError { .ok_or(Error::RevsetResolutionError {
revset: revset.into(), revset: revset.to_string(),
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))?;
match all_ids.next().await { if iter.next().is_some() {
None => Ok(commit_id.0), return Err(Error::MultipleRevisions {
Some(_) => Err(Error::MultipleRevisions {
revset: revset.to_string(), revset: revset.to_string(),
}), });
} }
Ok(commit_id)
} }
} }
@@ -214,7 +212,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).await?; let commit_id = self.get_commit_id(revset)?;
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
@@ -241,8 +239,7 @@ impl JjExecutor for JjLib {
context: format!("{e:?}"), context: format!("{e:?}"),
})?; })?;
let new_repo = tx let new_repo = tx.commit("jj-cz: update commit description")
.commit("jj-cz: update commit description")
.await .await
.map_err(|e| Error::JjOperation { .map_err(|e| Error::JjOperation {
context: e.to_string(), context: e.to_string(),
@@ -253,7 +250,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).await?; let commit_id = self.get_commit_id(revset)?;
let repo = self.repo.lock()?.clone(); let repo = self.repo.lock()?.clone();
let commit = repo let commit = repo
.store() .store()
@@ -263,33 +260,6 @@ 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)]
@@ -433,16 +403,16 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn describe_fails_outside_repo() { async fn describe_fails_outside_repo() {
// with_working_dir returns Err when not in a repo
let temp_dir = assert_fs::TempDir::new().unwrap(); let temp_dir = assert_fs::TempDir::new().unwrap();
// with_working_dir returns Err when not in a repo
let executor = JjLib::with_working_dir(temp_dir.path()).await; let executor = JjLib::with_working_dir(temp_dir.path()).await;
assert!(executor.is_err()); assert!(executor.is_err());
let valid_dir = assert_fs::TempDir::new().unwrap(); // Use an executor from a valid repo and try to describe a non-existent revset
init_jj_repo(valid_dir.path()) let executor = JjLib::with_working_dir(std::path::Path::new("."))
.await .await
.expect("Failed to init jj repo"); .unwrap();
let executor = JjLib::with_working_dir(valid_dir.path()).await.unwrap();
let result = executor let result = executor
.describe("this-bookmark-does-not-exist", "test: should fail") .describe("this-bookmark-does-not-exist", "test: should fail")
.await; .await;
@@ -557,8 +527,9 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn jj_lib_implements_jj_executor_trait() { async fn jj_lib_implements_jj_executor_trait() {
fn assert_implements<T: JjExecutor>() {} let lib = JjLib::with_working_dir(std::path::Path::new(".")).await;
assert_implements::<JjLib>(); fn accepts_executor(_: impl JjExecutor) {}
accepts_executor(lib.unwrap());
} }
mod user_config_paths_tests { mod user_config_paths_tests {
+14 -86
View File
@@ -11,22 +11,18 @@ 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 {
@@ -38,8 +34,6 @@ 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()),
} }
} }
} }
@@ -62,6 +56,12 @@ 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,13 +73,9 @@ impl MockJjExecutor {
self.describe_calls.lock().unwrap().clone() self.describe_calls.lock().unwrap().clone()
} }
pub fn with_new_revision_response(mut self, response: Result<(), Error>) -> Self { /// Get all revsets visited
self.new_revision_response = response; pub fn described_revsets(&self) -> Vec<String> {
self self.described_revsets.lock().unwrap().clone()
}
pub fn new_revision_calls(&self) -> Vec<String> {
self.new_revision_calls.lock().unwrap().clone()
} }
} }
@@ -112,17 +108,6 @@ 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)]
@@ -271,61 +256,4 @@ 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,11 +21,6 @@ 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;
pub mod error; mod error;
mod jj; mod jj;
mod prompts; mod prompts;
+13 -17
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,7 +24,6 @@ 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,
} }
} }
@@ -35,26 +34,25 @@ fn is_interactive_terminal() -> bool {
} }
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), ()> { async fn main() {
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 = JjLib::new().await.map_err(exit_on_error)?; let executor = match JjLib::new().await {
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>) {
@@ -64,13 +62,11 @@ async fn main() -> Result<(), ()> {
println!("🟡 Operation cancelled by user."); println!("🟡 Operation cancelled by user.");
process::exit(EXIT_CANCELLED); process::exit(EXIT_CANCELLED);
} }
Err(e) => exit_on_error(e), Err(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));
}
+1 -59
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, References, Scope}, commit::types::{Body, BreakingChange, CommitType, Description, Scope},
error::Error, error::Error,
prompts::prompter::Prompter, prompts::prompter::Prompter,
}; };
@@ -20,7 +20,6 @@ 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),
@@ -82,15 +81,6 @@ 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
@@ -150,14 +140,6 @@ 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
@@ -225,18 +207,6 @@ 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()
@@ -366,34 +336,6 @@ 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);
+1 -17
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, References, Scope}, commit::types::{Body, BreakingChange, CommitType, Description, Scope},
error::Error, error::Error,
}; };
@@ -33,9 +33,6 @@ 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>;
@@ -94,19 +91,6 @@ 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):")
+4 -101
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,
References, Scope, Scope,
}, },
error::Error, error::Error,
jj::JjExecutor, jj::JjExecutor,
@@ -65,16 +65,8 @@ 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( match self.preview_and_confirm(commit_type, scope, description, breaking_change, body) {
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())
@@ -121,11 +113,6 @@ 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()
@@ -142,7 +129,6 @@ 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(
@@ -151,7 +137,6 @@ 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)
@@ -161,7 +146,6 @@ 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 }) => {
@@ -200,10 +184,6 @@ 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)]
@@ -292,15 +272,8 @@ 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 references = References::default(); let result =
let result = workflow.preview_and_confirm( workflow.preview_and_confirm(commit_type, scope, description, breaking_change, body);
commit_type,
scope,
description,
breaking_change,
body,
references,
);
assert!(result.is_ok()); assert!(result.is_ok());
} }
@@ -345,7 +318,6 @@ 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);
@@ -379,7 +351,6 @@ 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
@@ -404,13 +375,11 @@ 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);
@@ -505,7 +474,6 @@ 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);
@@ -529,7 +497,6 @@ 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);
@@ -548,7 +515,6 @@ 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);
@@ -596,7 +562,6 @@ 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);
@@ -624,7 +589,6 @@ 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);
@@ -655,7 +619,6 @@ 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);
@@ -698,7 +661,6 @@ 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);
@@ -726,7 +688,6 @@ 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);
@@ -753,7 +714,6 @@ 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);
@@ -786,7 +746,6 @@ 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);
@@ -803,60 +762,4 @@ 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
@@ -0,0 +1,78 @@
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,155 +135,3 @@ 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);
}
}