22 Commits

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

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

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

After

Width:  |  Height:  |  Size: 326 KiB

+7
View File
@@ -38,6 +38,12 @@ command = [
] ]
need_stdout = true need_stdout = true
[jobs.coverage]
command = [
"cargo", "tarpaulin", "--config", ".tarpaulin.local.toml", "--features", "test-utils"
]
need_stdout = true
[jobs.doc] [jobs.doc]
command = ["cargo", "doc", "--color", "always", "--no-deps"] command = ["cargo", "doc", "--color", "always", "--no-deps"]
need_stdout = false need_stdout = false
@@ -82,3 +88,4 @@ allow_warnings = true
[keybindings] [keybindings]
# alt-m = "job:my-job" # alt-m = "job:my-job"
c = "job:clippy-all" # comment this to have 'c' run clippy on only the default target c = "job:clippy-all" # comment this to have 'c' run clippy on only the default target
v = "job:coverage"
+13
View File
@@ -0,0 +1,13 @@
use cargo_lock::Lockfile;
fn main() {
let lockfile = Lockfile::load("Cargo.lock").expect("Cargo.lock not found");
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());
println!("cargo:rustc-env=JJ_LIB_VERSION={version}");
println!("cargo:rerun-if-changed=Cargo.lock");
}
Generated
+11 -260
View File
@@ -23,65 +23,6 @@
"type": "github" "type": "github"
} }
}, },
"cachix": {
"inputs": {
"devenv": [
"devenv"
],
"flake-compat": [
"devenv",
"flake-compat"
],
"git-hooks": [
"devenv",
"git-hooks"
],
"nixpkgs": [
"devenv",
"nixpkgs"
]
},
"locked": {
"lastModified": 1760971495,
"narHash": "sha256-IwnNtbNVrlZIHh7h4Wz6VP0Furxg9Hh0ycighvL5cZc=",
"owner": "cachix",
"repo": "cachix",
"rev": "c5bfd933d1033672f51a863c47303fc0e093c2d2",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "latest",
"repo": "cachix",
"type": "github"
}
},
"devenv": {
"inputs": {
"cachix": "cachix",
"flake-compat": "flake-compat",
"flake-parts": "flake-parts",
"git-hooks": "git-hooks",
"nix": "nix",
"nixd": "nixd",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1770304289,
"narHash": "sha256-+g+XMyB1zi50h2N38GE32l7ZONX4oW7Nw6QSXzfNiwk=",
"owner": "cachix",
"repo": "devenv",
"rev": "fd777e39027d393346e4df672d51ad2bf44b2a12",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "devenv",
"type": "github"
}
},
"fenix": { "fenix": {
"inputs": { "inputs": {
"nixpkgs": [ "nixpkgs": [
@@ -104,58 +45,6 @@
"type": "github" "type": "github"
} }
}, },
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1761588595,
"narHash": "sha256-XKUZz9zewJNUj46b4AJdiRZJAvSZ0Dqj2BNfXvFlJC4=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
"devenv",
"nixpkgs"
]
},
"locked": {
"lastModified": 1760948891,
"narHash": "sha256-TmWcdiUUaWk8J4lpjzu4gCGxWY6/Ok7mOK4fIFfBuU4=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "864599284fc7c0ba6357ed89ed5e2cd5040f0c04",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"flake-root": {
"locked": {
"lastModified": 1723604017,
"narHash": "sha256-rBtQ8gg+Dn4Sx/s+pvjdq3CB2wQNzx9XGFq/JVGCB6k=",
"owner": "srid",
"repo": "flake-root",
"rev": "b759a56851e10cb13f6b8e5698af7b59c44be26e",
"type": "github"
},
"original": {
"owner": "srid",
"repo": "flake-root",
"type": "github"
}
},
"flake-utils": { "flake-utils": {
"inputs": { "inputs": {
"systems": "systems" "systems": "systems"
@@ -190,141 +79,25 @@
"type": "github" "type": "github"
} }
}, },
"git-hooks": {
"inputs": {
"flake-compat": [
"devenv",
"flake-compat"
],
"gitignore": "gitignore",
"nixpkgs": [
"devenv",
"nixpkgs"
]
},
"locked": {
"lastModified": 1760663237,
"narHash": "sha256-BflA6U4AM1bzuRMR8QqzPXqh8sWVCNDzOdsxXEguJIc=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "ca5b894d3e3e151ffc1db040b6ce4dcc75d31c37",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"devenv",
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nix": {
"inputs": {
"flake-compat": [
"devenv",
"flake-compat"
],
"flake-parts": [
"devenv",
"flake-parts"
],
"git-hooks-nix": [
"devenv",
"git-hooks"
],
"nixpkgs": [
"devenv",
"nixpkgs"
],
"nixpkgs-23-11": [
"devenv"
],
"nixpkgs-regression": [
"devenv"
]
},
"locked": {
"lastModified": 1769708679,
"narHash": "sha256-uFKkp2/SjIqbu5HtINg/hwHN6qaqcxLIbL/om7dT3kI=",
"owner": "cachix",
"repo": "nix",
"rev": "72bec37fabbfe378d677868ec42eeb83acf07a4c",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "devenv-2.32",
"repo": "nix",
"type": "github"
}
},
"nixd": {
"inputs": {
"flake-parts": [
"devenv",
"flake-parts"
],
"flake-root": "flake-root",
"nixpkgs": [
"devenv",
"nixpkgs"
],
"treefmt-nix": "treefmt-nix"
},
"locked": {
"lastModified": 1763964548,
"narHash": "sha256-JTRoaEWvPsVIMFJWeS4G2isPo15wqXY/otsiHPN0zww=",
"owner": "nix-community",
"repo": "nixd",
"rev": "d4bf15e56540422e2acc7bc26b20b0a0934e3f5e",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixd",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1767052823, "lastModified": 1779877693,
"narHash": "sha256-Fhuljcy7pJ8HacYYATRcm5rdKXx8P6D/0g19ppzDRNY=", "narHash": "sha256-NOF9NAREhxr50bbBfVcVOq+ArCMSoe8dP79Pk2uyARk=",
"owner": "cachix", "owner": "NixOS",
"repo": "devenv-nixpkgs", "repo": "nixpkgs",
"rev": "538a5124359f0b3d466e1160378c87887e3b51a4", "rev": "4100e830e085863741bc69b156ec4ccd53ab5be0",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "cachix", "owner": "NixOS",
"ref": "rolling", "ref": "nixpkgs-unstable",
"repo": "devenv-nixpkgs", "repo": "nixpkgs",
"type": "github" "type": "github"
} }
}, },
"root": { "root": {
"inputs": { "inputs": {
"alejandra": "alejandra", "alejandra": "alejandra",
"devenv": "devenv",
"flake-utils": "flake-utils", "flake-utils": "flake-utils",
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay" "rust-overlay": "rust-overlay"
@@ -354,11 +127,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1770260791, "lastModified": 1779992051,
"narHash": "sha256-ADTBfENFjRVDQMcCycyX/pAy6NFI/Ct6Mrar3gsmXI0=", "narHash": "sha256-4YWGv/0NkAdtTW1MXfaLYpfC9BhpCy9k1pWkR0xI9uw=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "42ec85352e419e601775c57256a52f6d48a39906", "rev": "e93ad0df1073b2c969a8f0c1f10b84e870469d40",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -381,28 +154,6 @@
"repo": "default", "repo": "default",
"type": "github" "type": "github"
} }
},
"treefmt-nix": {
"inputs": {
"nixpkgs": [
"devenv",
"nixd",
"nixpkgs"
]
},
"locked": {
"lastModified": 1734704479,
"narHash": "sha256-MMi74+WckoyEWBRcg/oaGRvXC9BVVxDZNRMpL+72wBI=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "65712f5af67234dad91a5a4baee986a8b62dbf8f",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
} }
}, },
"root": "root", "root": "root",
+15 -45
View File
@@ -2,16 +2,12 @@
description = "Conventional commits for Jujutsu"; description = "Conventional commits for Jujutsu";
inputs = { inputs = {
nixpkgs.url = "github:cachix/devenv-nixpkgs/rolling"; nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
flake-utils.url = "github:numtide/flake-utils"; flake-utils.url = "github:numtide/flake-utils";
alejandra = { alejandra = {
url = "github:kamadorueda/alejandra/4.0.0"; url = "github:kamadorueda/alejandra/4.0.0";
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
}; };
devenv = {
url = "github:cachix/devenv";
inputs.nixpkgs.follows = "nixpkgs";
};
rust-overlay = { rust-overlay = {
url = "github:oxalica/rust-overlay"; url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
@@ -19,8 +15,16 @@
}; };
nixConfig = { nixConfig = {
extra-trusted-public-keys = ["devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw=" "phundrak.cachix.org-1:osJAkYO0ioTOPqaQCIXMfIRz1/+YYlVFkup3R2KSexk="]; extra-trusted-public-keys = [
extra-substituters = ["https://devenv.cachix.org" "https://phundrak.cachix.org"]; "nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs="
"devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw="
"phundrak.cachix.org-1:osJAkYO0ioTOPqaQCIXMfIRz1/+YYlVFkup3R2KSexk="
];
extra-substituters = [
"https://nix-community.cachix.org"
"https://devenv.cachix.org"
"https://phundrak.cachix.org"
];
}; };
outputs = { outputs = {
@@ -29,51 +33,17 @@
rust-overlay, rust-overlay,
alejandra, alejandra,
... ...
} @ inputs: }:
flake-utils.lib.eachDefaultSystem ( flake-utils.lib.eachDefaultSystem (
system: let system: let
overlays = [(import rust-overlay)]; overlays = [(import rust-overlay)];
pkgs = import nixpkgs {inherit system overlays;}; pkgs = import nixpkgs {inherit system overlays;};
rustVersion = pkgs.rust-bin.stable.latest.default; rustVersion = pkgs.rust-bin.stable.latest.default;
rustPlatform = pkgs.makeRustPlatform { packages = import ./nix/packages.nix {inherit pkgs system;};
cargo = rustVersion;
rustc = rustVersion;
};
in { in {
inherit packages;
formatter = alejandra.defaultPackage.${system}; formatter = alejandra.defaultPackage.${system};
packages = devShell = import ./nix/shell.nix {inherit pkgs rustVersion;};
(import ./nix/package.nix {inherit pkgs rustPlatform;})
// {
windows = let
mingwPkgs = pkgs.pkgsCross.mingwW64;
rustWindows = pkgs.rust-bin.stable.latest.default.override {
targets = ["x86_64-pc-windows-gnu"];
};
rustPlatformWindows = mingwPkgs.makeRustPlatform {
cargo = rustWindows;
rustc = rustWindows;
};
cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml);
in
rustPlatformWindows.buildRustPackage {
pname = cargoToml.package.name;
version = cargoToml.package.version;
src = pkgs.lib.cleanSource ./.;
cargoLock.lockFile = ./Cargo.lock;
nativeBuildInputs = [pkgs.upx];
doCheck = false;
meta = {
description = "Conventional commits for Jujutsu";
homepage = "https://labs.phundrak.com/phundrak/jj-cz";
};
postBuild = ''
${pkgs.upx}/bin/upx target/*/release/jj-cz.exe
'';
};
};
devShell = import ./nix/shell.nix {
inherit inputs pkgs rustVersion;
};
} }
); );
} }
+19
View File
@@ -0,0 +1,19 @@
{
bin,
pkgs,
archiveName
}:
pkgs.stdenv.mkDerivation rec {
name = "jj-cz-${archiveName}";
src = pkgs.lib.cleanSource ../.;
nativeBuildInputs = [pkgs.zip];
buildPhase = ''
mkdir -p $out/dist
# zip -j $out/dist/${name}.zip ${bin}/bin/jj-cz* ${src}/README.md ${src}/LICENSE.*
cp ${bin}/bin/jj-cz* $out/dist/
cp ${src}/README.md $out/dist/
cp ${src}/LICENSE.* $out/dist/
'';
installPhase = "";
dontConfigure = true;
}
+28
View File
@@ -0,0 +1,28 @@
{
target,
pkgs,
}: let
cargoToml = fromTOML (builtins.readFile ../Cargo.toml);
name = cargoToml.package.name;
version = cargoToml.package.version;
buildArgs = {
pname = name;
inherit version;
src = pkgs.lib.cleanSource ../.;
# cargoLock.lockFile = ../Cargo.lock;
cargoHash = "sha256-yfKaqc+7lvxDukAXxazc57GFs386rr9vUsDk1pobLRM=";
useNextest = true;
meta = {
inherit (cargoToml.package) description homepage;
};
postBuild = "${pkgs.upx}/bin/upx target/*/release/${name}${target.exeSuffix}";
};
rustVersion = pkgs.rust-bin.stable.latest.default.override {
targets = [target.triple];
};
rustPlatform = target.crossPkgs.makeRustPlatform {
cargo = rustVersion;
rustc = rustVersion;
};
in
rustPlatform.buildRustPackage buildArgs
-26
View File
@@ -1,26 +0,0 @@
{
pkgs,
rustPlatform,
...
}: let
cargoToml = fromTOML (builtins.readFile ../Cargo.toml);
name = cargoToml.package.name;
version = cargoToml.package.version;
rustBuild = rustPlatform.buildRustPackage {
pname = name;
inherit version;
src = pkgs.lib.cleanSource ../.;
cargoLock.lockFile = ../Cargo.lock;
nativeBuildInputs = [pkgs.upx];
useNextest = true;
meta = {
description = "Conventional commits for Jujutsu";
homepage = "https://labs.phundrak.com/phundrak/jj-cz";
};
postBuild = ''
${pkgs.upx}/bin/upx target/*/release/${name}
'';
};
in {
default = rustBuild;
}
+82
View File
@@ -0,0 +1,82 @@
{
pkgs,
system,
...
}: let
mkRustBuild = import ./make-binary.nix;
mkArchive = import ./make-archive.nix;
targets = {
linux-x86_64 = {
crossPkgs = pkgs;
triple = "x86_64-unknown-linux-gnu";
exeSuffix = "";
};
linux-aarch64 = {
crossPkgs = pkgs.pkgsCross.aarch64-multiplatform;
triple = "aarch64-unknown-linux-gnu";
exeSuffix = "";
};
windows-x86_64 = {
crossPkgs = pkgs.pkgsCross.mingwW64;
triple = "x86_64-pc-windows-gnu";
exeSuffix = ".exe";
};
windows-aarch64 = {
crossPkgs = pkgs.pkgsCross.aarch64-windows;
triple = "aarch64-pc-windows-gnu";
exeSuffix = ".exe";
};
macos-x86_64 = {
crossPkgs = pkgs.pkgsCross.x86_64-darwin;
triple = "x86_64-apple-darwin";
exeSuffix = "";
};
macos-aarch64 = {
crossPkgs = pkgs.pkgsCross.aarch64-darwin;
triple = "aarch64-apple-darwin";
exeSuffix = "";
};
};
bins = {
linux-x86_64 = mkRustBuild {
inherit pkgs;
target = targets.linux-x86_64;
};
linux-aarch64 = mkRustBuild {
inherit pkgs;
target = targets.linux-aarch64;
};
windows-x86_64 = mkRustBuild {
inherit pkgs;
target = targets.windows-x86_64;
};
};
packages =
{
linux-x86_64-archive = mkArchive {
inherit pkgs;
bin = bins.linux-x86_64;
archiveName = "x86_64-linux";
};
linux-aarch64-archive = mkArchive {
inherit pkgs;
bin = bins.linux-aarch64;
archiveName = "aarch64-linux";
};
windows-x86_64-archive = mkArchive {
inherit pkgs;
bin = bins.windows-x86_64;
archiveName = "x86_64-windows";
};
}
// bins;
defaultBySystem = {
"x86_64-linux" = packages.linux-x86_64;
"aarch64-linux" = packages.linux-aarch64;
"x86_64-windows" = packages.windows-x86_64;
};
in
packages
// {
default = defaultBySystem.${system} or packages.linux-x86_64;
}
+20 -24
View File
@@ -1,31 +1,27 @@
{ {
inputs,
pkgs, pkgs,
rustVersion, rustVersion,
...
}: }:
inputs.devenv.lib.mkShell { pkgs.mkShell {
inherit inputs pkgs; packages = with pkgs; [
modules = [ (rustVersion.override {
{ extensions = [
packages = with pkgs; [ "clippy"
(rustVersion.override { "rust-src"
extensions = [ "rust-analyzer"
"clippy" "rustfmt"
"rust-src"
"rust-analyzer"
"rustfmt"
];
})
bacon
cargo-deny
cargo-edit
cargo-nextest
cargo-tarpaulin
git-cliff
just
typos
]; ];
} })
bacon
cargo-deny
cargo-edit
cargo-nextest
cargo-tarpaulin
git-cliff
just
typos
# for CI
jq
]; ];
} }
+112 -2
View File
@@ -7,11 +7,121 @@ use clap::Parser;
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
#[command( #[command(
name = "jj-cz", name = "jj-cz",
version, version = concat!(env!("CARGO_PKG_VERSION"), " (jj-lib ", env!("JJ_LIB_VERSION") ,")"),
about = "Interactive conventional commit tool for Jujutsu", about = "Interactive conventional commit tool for Jujutsu",
long_about = "Guides you through creating a properly formatted conventional \ long_about = "Guides you through creating a properly formatted conventional \
commit message and applies it to the current change in your \ commit message and applies it to the current change in your \
Jujutsu repository.\n\n\ Jujutsu repository.\n\n\
This tool requires an interactive terminal (TTY)." This tool requires an interactive terminal (TTY)."
)] )]
pub struct Cli; pub struct Cli {
/// The revision(s) whose description to edit (default: @)
#[arg(value_name = "REVSETS")]
revsets: Vec<String>,
/// Create a new child revision after editing the description
#[arg(short, long)]
new: bool,
}
impl Cli {
/// Returns the revsets to operate on, defaulting to `["@"]` if none provided
pub fn revsets(&self) -> Vec<&str> {
if self.revsets.is_empty() {
vec!["@"]
} else {
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 -2
View File
@@ -32,7 +32,7 @@ impl Body {
mod tests { mod tests {
use super::*; use super::*;
/// Default produces Body(None) no body /// Default produces Body(None) - no body
#[test] #[test]
fn default_produces_none() { fn default_produces_none() {
assert_eq!(Body::default(), Body(None)); assert_eq!(Body::default(), Body(None));
@@ -71,7 +71,7 @@ mod tests {
); );
} }
/// Leading and internal whitespace is preserved users may write /// Leading and internal whitespace is preserved - users may write
/// indented lists, ASCII art, file trees, etc. /// indented lists, ASCII art, file trees, etc.
#[test] #[test]
fn from_preserves_leading_whitespace() { fn from_preserves_leading_whitespace() {
+2 -2
View File
@@ -31,7 +31,7 @@ pub enum BreakingChange {
} }
impl BreakingChange { impl BreakingChange {
pub fn ignore(&self) -> bool { pub fn is_absent(&self) -> bool {
matches!(self, BreakingChange::No) matches!(self, BreakingChange::No)
} }
@@ -66,7 +66,7 @@ where
mod tests { mod tests {
use super::*; use super::*;
/// Empty string produces Yes(None) no footer, only '!' in the header /// Empty string produces Yes(None) - no footer, only '!' in the header
#[test] #[test]
fn from_empty_string_yields_yes_none() { fn from_empty_string_yields_yes_none() {
assert_eq!(BreakingChange::from(String::new()), BreakingChange::Yes); assert_eq!(BreakingChange::from(String::new()), BreakingChange::Yes);
+1 -1
View File
@@ -6,7 +6,7 @@ impl Description {
/// Soft limit for description length. /// Soft limit for description length.
/// ///
/// Descriptions over this length are warned about at the prompt layer but /// Descriptions over this length are warned about at the prompt layer but
/// are not rejected here the hard limit is the 72-character total first /// are not rejected here - the hard limit is the 72-character total first
/// line enforced by [`crate::ConventionalCommit`]. /// line enforced by [`crate::ConventionalCommit`].
pub const MAX_LENGTH: usize = 50; pub const MAX_LENGTH: usize = 50;
+4 -2
View File
@@ -4,10 +4,12 @@ pub trait Footer {
fn as_footer(&self) -> String { fn as_footer(&self) -> String {
let default = format!("{}: {}", self.prefix(), self.note()); let default = format!("{}: {}", self.prefix(), self.note());
if default.chars().count() > 72 { let mut footer = if default.chars().count() > 72 {
textwrap::wrap(&default, 71).join("\n ") textwrap::wrap(&default, 71).join("\n ")
} else { } else {
default default
} };
footer.push('\n');
footer
} }
} }
+30 -5
View File
@@ -1,4 +1,4 @@
use super::{Body, BreakingChange, CommitType, Description, Scope}; use super::{Body, BreakingChange, CommitType, Description, Footer, References, Scope};
use thiserror::Error; use thiserror::Error;
/// Errors that can occur when creating a ConventionalCommit /// Errors that can occur when creating a ConventionalCommit
@@ -10,7 +10,7 @@ pub enum CommitMessageError {
/// The formatted message is not parseable as a conventional commit /// The formatted message is not parseable as a conventional commit
/// ///
/// This should never occur in normal use it indicates a bug in the /// This should never occur in normal use - it indicates a bug in the
/// formatting logic. /// formatting logic.
#[error("output failed git-conventional validation: {reason}")] #[error("output failed git-conventional validation: {reason}")]
InvalidConventionalFormat { reason: String }, InvalidConventionalFormat { reason: String },
@@ -23,6 +23,7 @@ pub struct ConventionalCommit {
description: Description, description: Description,
breaking_change: BreakingChange, breaking_change: BreakingChange,
body: Body, body: Body,
references: References,
} }
impl ConventionalCommit { impl ConventionalCommit {
@@ -44,6 +45,7 @@ impl ConventionalCommit {
description: Description, description: Description,
breaking_change: BreakingChange, breaking_change: BreakingChange,
body: Body, body: Body,
references: References,
) -> Result<Self, CommitMessageError> { ) -> Result<Self, CommitMessageError> {
let commit = Self { let commit = Self {
commit_type, commit_type,
@@ -51,6 +53,7 @@ impl ConventionalCommit {
description, description,
breaking_change, breaking_change,
body, body,
references,
}; };
let len = commit.first_line_len(); let len = commit.first_line_len();
if len > Self::FIRST_LINE_MAX_LENGTH { if len > Self::FIRST_LINE_MAX_LENGTH {
@@ -76,7 +79,7 @@ impl ConventionalCommit {
pub fn first_line_len(&self) -> usize { pub fn first_line_len(&self) -> usize {
self.commit_type.len() self.commit_type.len()
+ self.scope.header_segment_len() + self.scope.header_segment_len()
+ if self.breaking_change.ignore() { 0 } else { 1 } + if self.breaking_change.is_absent() { 0 } else { 1 }
+ 2 // ": " + 2 // ": "
+ self.description.len() + self.description.len()
} }
@@ -92,6 +95,7 @@ impl ConventionalCommit {
&self.description, &self.description,
&self.breaking_change, &self.breaking_change,
&self.body, &self.body,
&self.references,
) )
} }
@@ -106,14 +110,16 @@ impl ConventionalCommit {
description: &Description, description: &Description,
breaking_change: &BreakingChange, breaking_change: &BreakingChange,
body: &Body, body: &Body,
references: &References,
) -> String { ) -> String {
let scope = scope.header_segment(); let scope = scope.header_segment();
let breaking_change_header = breaking_change.header_segment(); let breaking_change_header = breaking_change.header_segment();
let breaking_change_footer = breaking_change.as_footer(); let breaking_change_footer = breaking_change.as_footer();
let refs_footer = references.as_footer();
format!( format!(
r#"{commit_type}{scope}{breaking_change_header}: {description} r#"{commit_type}{scope}{breaking_change_header}: {description}
{} {}
{breaking_change_footer}"#, {breaking_change_footer}{refs_footer}"#,
body.format() body.format()
) )
.trim() .trim()
@@ -154,6 +160,7 @@ mod tests {
description, description,
breaking_change, breaking_change,
Body::default(), Body::default(),
References::default(),
) )
.expect("test commit should have valid line length") .expect("test commit should have valid line length")
} }
@@ -637,6 +644,7 @@ mod tests {
Description::parse(&desc_44).unwrap(), Description::parse(&desc_44).unwrap(),
BreakingChange::No, BreakingChange::No,
Body::default(), Body::default(),
References::default(),
); );
assert!(result.is_ok()); assert!(result.is_ok());
let commit = result.unwrap(); let commit = result.unwrap();
@@ -667,6 +675,7 @@ mod tests {
Description::parse(&desc_31).unwrap(), Description::parse(&desc_31).unwrap(),
BreakingChange::No, BreakingChange::No,
Body::default(), Body::default(),
References::default(),
); );
assert!(result.is_err()); assert!(result.is_err());
assert_eq!( assert_eq!(
@@ -691,6 +700,7 @@ mod tests {
Description::parse(&desc_40).unwrap(), Description::parse(&desc_40).unwrap(),
BreakingChange::No, BreakingChange::No,
Body::default(), Body::default(),
References::default(),
); );
assert!(result.is_err()); assert!(result.is_err());
assert_eq!( assert_eq!(
@@ -711,6 +721,7 @@ mod tests {
test_description("quick fix"), test_description("quick fix"),
BreakingChange::No, BreakingChange::No,
Body::default(), Body::default(),
References::default(),
); );
assert!(result.is_ok()); assert!(result.is_ok());
} }
@@ -724,6 +735,7 @@ mod tests {
test_description("add feature"), test_description("add feature"),
BreakingChange::No, BreakingChange::No,
Body::default(), Body::default(),
References::default(),
); );
assert!(result.is_ok()); assert!(result.is_ok());
} }
@@ -750,6 +762,7 @@ mod tests {
test_description("test"), test_description("test"),
BreakingChange::No, BreakingChange::No,
Body::default(), Body::default(),
References::default(),
); );
// Just verify it's a Result by using is_ok() // Just verify it's a Result by using is_ok()
assert!(result.is_ok()); assert!(result.is_ok());
@@ -781,6 +794,7 @@ mod tests {
desc, desc,
BreakingChange::No, BreakingChange::No,
Body::default(), Body::default(),
References::default(),
); );
// new() itself calls git_conventional::Commit::parse internally, so // new() itself calls git_conventional::Commit::parse internally, so
// if this is Ok, SC-002 is satisfied for this case. // if this is Ok, SC-002 is satisfied for this case.
@@ -918,6 +932,7 @@ mod tests {
Description::parse(&desc_44).unwrap(), Description::parse(&desc_44).unwrap(),
BreakingChange::Yes, BreakingChange::Yes,
Body::default(), Body::default(),
References::default(),
); );
assert!(result.is_err()); assert!(result.is_err());
assert_eq!( assert_eq!(
@@ -932,7 +947,7 @@ mod tests {
/// Breaking change footer does not count toward the 72-character first-line limit /// Breaking change footer does not count toward the 72-character first-line limit
#[test] #[test]
fn breaking_change_footer_does_not_count_toward_line_limit() { fn breaking_change_footer_does_not_count_toward_line_limit() {
// First line is short; the note itself is long should still be accepted. // First line is short; the note itself is long - should still be accepted.
let long_note = "x".repeat(200); let long_note = "x".repeat(200);
let result = ConventionalCommit::new( let result = ConventionalCommit::new(
CommitType::Fix, CommitType::Fix,
@@ -940,6 +955,7 @@ mod tests {
test_description("quick fix"), test_description("quick fix"),
long_note.into(), long_note.into(),
Body::default(), Body::default(),
References::default(),
); );
assert!(result.is_ok()); assert!(result.is_ok());
} }
@@ -959,6 +975,7 @@ mod tests {
&commit.description, &commit.description,
&BreakingChange::No, &BreakingChange::No,
&Body::default(), &Body::default(),
&References::default(),
); );
assert_eq!(preview, commit.format()); assert_eq!(preview, commit.format());
} }
@@ -972,6 +989,7 @@ mod tests {
&test_description("drop legacy API"), &test_description("drop legacy API"),
&"removes legacy endpoint".into(), &"removes legacy endpoint".into(),
&Body::default(), &Body::default(),
&References::default(),
); );
assert_eq!( assert_eq!(
preview, preview,
@@ -988,6 +1006,7 @@ mod tests {
&test_description("drop Node 6"), &test_description("drop Node 6"),
&"Node 6 is no longer supported".into(), &"Node 6 is no longer supported".into(),
&Body::default(), &Body::default(),
&References::default(),
); );
assert_eq!( assert_eq!(
preview, preview,
@@ -1093,6 +1112,7 @@ mod tests {
test_description("add feature"), test_description("add feature"),
BreakingChange::No, BreakingChange::No,
Body::from("This explains the change."), Body::from("This explains the change."),
References::default(),
) )
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
@@ -1110,6 +1130,7 @@ mod tests {
test_description("handle null response"), test_description("handle null response"),
BreakingChange::No, BreakingChange::No,
Body::from("Null responses were previously unhandled."), Body::from("Null responses were previously unhandled."),
References::default(),
) )
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
@@ -1127,6 +1148,7 @@ mod tests {
test_description("update README"), test_description("update README"),
BreakingChange::No, BreakingChange::No,
Body::from("First paragraph.\n\nSecond paragraph."), Body::from("First paragraph.\n\nSecond paragraph."),
References::default(),
) )
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
@@ -1146,6 +1168,7 @@ mod tests {
test_description("drop legacy API"), test_description("drop legacy API"),
"removes legacy endpoint".into(), "removes legacy endpoint".into(),
Body::from("The endpoint was deprecated in v2."), Body::from("The endpoint was deprecated in v2."),
References::default(),
) )
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
@@ -1163,6 +1186,7 @@ mod tests {
&test_description("add feature"), &test_description("add feature"),
&BreakingChange::No, &BreakingChange::No,
&Body::from("This explains the change."), &Body::from("This explains the change."),
&References::default(),
); );
assert_eq!(preview, "feat: add feature\n\nThis explains the change."); assert_eq!(preview, "feat: add feature\n\nThis explains the change.");
} }
@@ -1178,6 +1202,7 @@ mod tests {
&test_description("drop old API"), &test_description("drop old API"),
&"old API removed".into(), &"old API removed".into(),
&Body::from("Migration guide: see CHANGELOG."), &Body::from("Migration guide: see CHANGELOG."),
&References::default(),
); );
assert_eq!( assert_eq!(
preview, preview,
+3
View File
@@ -18,3 +18,6 @@ pub use body::Body;
mod message; mod message;
pub use message::{CommitMessageError, ConventionalCommit}; pub use message::{CommitMessageError, ConventionalCommit};
mod references;
pub use references::References;
+186
View File
@@ -0,0 +1,186 @@
use super::Footer;
#[repr(transparent)]
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct References(Vec<String>);
impl<T> From<T> for References
where
T: ToString,
{
fn from(value: T) -> Self {
let references: Vec<String> = value
.to_string()
.split(",")
.flat_map(|e| match e.trim() {
"" => None,
e => Some(e.to_string()),
})
.collect();
Self(references)
}
}
impl Footer for References {
fn prefix(&self) -> &str {
"Refs: "
}
fn note(&self) -> &str {
""
}
fn as_footer(&self) -> String {
if self.0.is_empty() {
String::new()
} else {
let footers: Vec<String> = self
.0
.iter()
.map(|r| format!("{}{r}\n", self.prefix()))
.collect();
footers.join("")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Default is empty
#[test]
fn default_is_empty() {
let refs = References::default();
assert!(refs.0.is_empty());
}
/// Empty input produces empty references
#[test]
fn from_empty_string() {
assert_eq!(References::from(""), References::default());
}
/// Whitespace-only input produces empty references
#[test]
fn from_whitespace_only() {
assert_eq!(References::from(" "), References::default());
}
/// Single reference without commas
#[test]
fn from_single_reference() {
let refs = References::from("#123");
assert_eq!(refs.0, vec!["#123".to_string()]);
}
/// Comma-separated references are split and trimmed
#[test]
fn from_comma_separated() {
let refs = References::from("#123, #456");
assert_eq!(refs.0, vec!["#123".to_string(), "#456".to_string()]);
}
/// Leading whitespace around references is trimmed
#[test]
fn from_trims_leading_whitespace() {
let refs = References::from(" #123, #456");
assert_eq!(refs.0, vec!["#123".to_string(), "#456".to_string()]);
}
/// Trailing whitespace around references is trimmed
#[test]
fn from_trims_trailing_whitespace() {
let refs = References::from("#123 , #456 ");
assert_eq!(refs.0, vec!["#123".to_string(), "#456".to_string()]);
}
/// Empty segments from consecutive commas are filtered out
#[test]
fn from_filters_empty_segments() {
let refs = References::from("#123,,, #456");
assert_eq!(refs.0, vec!["#123".to_string(), "#456".to_string()]);
}
/// From works with owned String
#[test]
fn from_owned_string() {
let input = "#123, #456".to_string();
let refs = References::from(input);
assert_eq!(refs.0, vec!["#123".to_string(), "#456".to_string()]);
}
/// as_footer returns empty string for empty references
#[test]
fn as_footer_empty() {
let refs = References::default();
assert_eq!(refs.as_footer(), "");
}
/// as_footer returns single line for one reference
#[test]
fn as_footer_single() {
let refs = References::from("#123");
assert_eq!(refs.as_footer(), "Refs: #123\n");
}
/// as_footer returns multiple lines for multiple references
#[test]
fn as_footer_multiple() {
let refs = References::from("#123, #456");
assert_eq!(refs.as_footer(), "Refs: #123\nRefs: #456\n");
}
/// as_footer handles Jira-style references
#[test]
fn as_footer_jira_style() {
let refs = References::from("OPS-456, PROJ-789");
assert_eq!(refs.as_footer(), "Refs: OPS-456\nRefs: PROJ-789\n");
}
/// Footer trait prefix returns correct value
#[test]
fn footer_prefix() {
let refs = References::default();
assert_eq!(refs.prefix(), "Refs: ");
}
/// Footer trait note returns empty string
#[test]
fn footer_note() {
let refs = References::default();
assert_eq!(refs.note(), "");
}
/// Clone produces equal value
#[test]
fn clone_equality() {
let refs = References::from("#123, #456");
let cloned = refs.clone();
assert_eq!(refs, cloned);
}
/// Debug output is available
#[test]
fn debug_output() {
let refs = References::from("#123");
let debug = format!("{:?}", refs);
assert!(debug.contains("References"));
}
/// Different references are not equal
#[test]
fn inequality_different_refs() {
let a = References::from("#123");
let b = References::from("#456");
assert_ne!(a, b);
}
/// Empty vs non-empty are not equal
#[test]
fn inequality_empty_vs_non_empty() {
let empty = References::default();
let non_empty = References::from("#123");
assert_ne!(empty, non_empty);
}
}
+5 -1
View File
@@ -61,7 +61,11 @@ impl Scope {
/// Returns the visible length of the header segment /// Returns the visible length of the header segment
pub fn header_segment_len(&self) -> usize { pub fn header_segment_len(&self) -> usize {
self.header_segment().chars().count() if self.is_empty() {
0
} else {
self.0.chars().count() + 2
}
} }
} }
+42 -3
View File
@@ -1,3 +1,5 @@
use jj_lib::revset::{RevsetEvaluationError, RevsetParseError, RevsetResolutionError};
use crate::commit::types::{CommitMessageError, DescriptionError, ScopeError}; use crate::commit::types::{CommitMessageError, DescriptionError, ScopeError};
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
@@ -18,13 +20,19 @@ pub enum Error {
RepositoryLocked, RepositoryLocked,
#[error("Could not get current directory")] #[error("Could not get current directory")]
FailedGettingCurrentDir, FailedGettingCurrentDir,
#[error("Could not load Jujutsu configuration")] #[error("Could not load Jujutsu configuration: {context}")]
FailedReadingConfig, FailedReadingConfig { context: String },
// Application errors // Application errors
#[error("Operation cancelled by user")] #[error("Operation cancelled by user")]
Cancelled, Cancelled,
#[error("Non-interactive terminal detected")] #[error("Non-interactive terminal detected")]
NonInteractive, NonInteractive,
#[error("Failed to resolve revision '{revset}': {context}")]
RevsetResolutionError { revset: String, context: String },
#[error("Revision set '{revset}' resolves to multiple commits; specify a single revision")]
MultipleRevisions { revset: String },
#[error("--new cannot be used with multiple revisions")]
NewFlagWithMultipleRevisions,
} }
impl From<ScopeError> for Error { impl From<ScopeError> for Error {
@@ -46,7 +54,38 @@ impl From<CommitMessageError> for Error {
} }
impl From<std::io::Error> for Error { impl From<std::io::Error> for Error {
fn from(_value: std::io::Error) -> Self { fn from(_: std::io::Error) -> Self {
Self::FailedGettingCurrentDir Self::FailedGettingCurrentDir
} }
} }
impl<T> From<std::sync::PoisonError<T>> for Error {
fn from(_: std::sync::PoisonError<T>) -> Self {
Self::JjOperation {
context: "internal lock poisoned".to_string(),
}
}
}
impl Error {
pub fn from_revset_parse_error(revset: &str, error: RevsetParseError) -> Self {
Self::RevsetResolutionError {
revset: revset.to_string(),
context: error.to_string(),
}
}
pub fn from_revset_resolution_error(revset: &str, error: RevsetResolutionError) -> Self {
Self::RevsetResolutionError {
revset: revset.to_string(),
context: error.to_string(),
}
}
pub fn from_revset_evaluation_error(revset: &str, error: RevsetEvaluationError) -> Self {
Self::RevsetResolutionError {
revset: revset.to_string(),
context: error.to_string(),
}
}
}
+276 -82
View File
@@ -3,13 +3,25 @@
//! This implementation uses jj-lib 0.39.0 directly for repository detection //! This implementation uses jj-lib 0.39.0 directly for repository detection
//! and commit description, replacing the earlier shell-out approach. //! and commit description, replacing the earlier shell-out approach.
use std::path::{Path, PathBuf}; use std::{
collections::HashMap,
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use etcetera::BaseStrategy; use etcetera::BaseStrategy;
use futures_util::StreamExt;
use jj_lib::{ use jj_lib::{
backend::CommitId,
config::{ConfigSource, StackedConfig}, config::{ConfigSource, StackedConfig},
ref_name::WorkspaceName, fileset::FilesetAliasesMap,
repo::{Repo, StoreFactories}, ref_name::WorkspaceNameBuf,
repo::{ReadonlyRepo, Repo, StoreFactories},
repo_path::RepoPathUiConverter,
revset::{
self, RevsetAliasesMap, RevsetDiagnostics, RevsetExtensions, RevsetParseContext,
RevsetWorkspaceContext, SymbolResolver, SymbolResolverExtension,
},
settings::UserSettings, settings::UserSettings,
workspace::{Workspace, default_working_copy_factories}, workspace::{Workspace, default_working_copy_factories},
}; };
@@ -21,36 +33,80 @@ use crate::jj::JjExecutor;
#[derive(Debug)] #[derive(Debug)]
pub struct JjLib { pub struct JjLib {
working_dir: PathBuf, working_dir: PathBuf,
repo: Mutex<Arc<ReadonlyRepo>>,
workspace_name: WorkspaceNameBuf,
workspace_root: PathBuf,
} }
impl JjLib { impl JjLib {
/// Create a new JjLib instance using the current working directory /// Create a new JjLib instance using the current working directory
pub fn new() -> Result<Self, Error> { pub async fn new() -> Result<Self, Error> {
let working_dir = std::env::current_dir()?; let working_dir = std::env::current_dir()?;
Ok(Self { working_dir }) let (repo, workspace_name, workspace_root) = Self::load_repo(&working_dir).await?;
Ok(Self {
working_dir,
repo: repo.into(),
workspace_name,
workspace_root,
})
} }
/// Create a new JjLib instance with a specific working directory /// Create a new JjLib instance with a specific working directory
pub fn with_working_dir(path: impl AsRef<Path>) -> Self { pub async fn with_working_dir(path: impl AsRef<Path>) -> Result<Self, Error> {
Self { let (repo, workspace_name, workspace_root) = Self::load_repo(path.as_ref()).await?;
Ok(Self {
working_dir: path.as_ref().to_path_buf(), working_dir: path.as_ref().to_path_buf(),
} repo: repo.into(),
workspace_name,
workspace_root,
})
}
/// Load the repo from the given working directory
async fn load_repo(
working_dir: &Path,
) -> Result<(Arc<ReadonlyRepo>, WorkspaceNameBuf, PathBuf), Error> {
let settings = Self::load_settings()?;
let store_factories = StoreFactories::default();
let wc_factories = default_working_copy_factories();
let workspace = Workspace::load(&settings, working_dir, &store_factories, &wc_factories)
.map_err(|_| Error::NotARepository)?;
let repo =
workspace
.repo_loader()
.load_at_head()
.await
.map_err(|e| Error::JjOperation {
context: e.to_string(),
})?;
Ok((
repo,
workspace.workspace_name().to_owned(),
workspace.workspace_root().to_path_buf(),
))
} }
fn load_settings() -> Result<UserSettings, Error> { fn load_settings() -> Result<UserSettings, Error> {
let mut config = StackedConfig::with_defaults(); let mut config = StackedConfig::with_defaults();
for path in Self::user_config_paths() { for path in Self::user_config_paths() {
if path.is_dir() { if path.is_dir() {
config config.load_dir(ConfigSource::User, &path).map_err(|e| {
.load_dir(ConfigSource::User, &path) Error::FailedReadingConfig {
.map_err(|_| Error::FailedReadingConfig)?; context: e.to_string(),
}
})?;
} else if path.exists() { } else if path.exists() {
config config.load_file(ConfigSource::User, path).map_err(|e| {
.load_file(ConfigSource::User, path) Error::FailedReadingConfig {
.map_err(|_| Error::FailedReadingConfig)?; context: e.to_string(),
}
})?;
} }
} }
UserSettings::from_config(config).map_err(|_| Error::FailedReadingConfig) UserSettings::from_config(config).map_err(|e| Error::FailedReadingConfig {
context: e.to_string(),
})
} }
/// Resolves user config file paths following the same logic as the jj CLI: /// Resolves user config file paths following the same logic as the jj CLI:
@@ -94,6 +150,52 @@ impl JjLib {
paths paths
} }
/// Resolve a revset string to a commit ID
async fn get_commit_id(&self, revset: &str) -> Result<CommitId, Error> {
let context = RevsetParseContext {
workspace: Some(RevsetWorkspaceContext {
workspace_name: &self.workspace_name,
path_converter: &RepoPathUiConverter::Fs {
cwd: self.working_dir.clone(),
base: self.workspace_root.clone(),
},
}),
aliases_map: &RevsetAliasesMap::new(),
fileset_aliases_map: &FilesetAliasesMap::new(),
local_variables: HashMap::new(),
user_email: "",
date_pattern_context: chrono::Local::now().into(),
default_ignored_remote: None,
use_glob_by_default: false,
extensions: &RevsetExtensions::default(),
};
let mut diagnostic = RevsetDiagnostics::new();
let repo = self.repo.lock()?.clone();
let symbol_resolver =
SymbolResolver::new(&*repo, &([] as [Box<dyn SymbolResolverExtension>; 0]));
let revision = revset::parse(&mut diagnostic, revset, &context)
.map_err(|e| Error::from_revset_parse_error(revset, e))?
.resolve_user_expression(&*repo, &symbol_resolver)
.map_err(|e| Error::from_revset_resolution_error(revset, e))?
.evaluate(&*repo)
.map_err(|e| Error::from_revset_evaluation_error(revset, e))?;
let mut all_ids = revision.commit_change_ids();
let commit_id = all_ids
.next()
.await
.ok_or(Error::RevsetResolutionError {
revset: revset.into(),
context: "No matching revision".to_string(),
})?
.map_err(|e| Error::from_revset_evaluation_error(revset, e))?;
match all_ids.next().await {
None => Ok(commit_id.0),
Some(_) => Err(Error::MultipleRevisions {
revset: revset.to_string(),
}),
}
}
} }
#[async_trait::async_trait(?Send)] #[async_trait::async_trait(?Send)]
@@ -111,49 +213,20 @@ impl JjExecutor for JjLib {
.is_ok()) .is_ok())
} }
async fn describe(&self, message: &str) -> Result<(), Error> { async fn describe(&self, revset: &str, message: &str) -> Result<(), Error> {
let settings = Self::load_settings()?; let commit_id = self.get_commit_id(revset).await?;
let store_factories = StoreFactories::default(); let repo = self.repo.lock()?.clone();
let wc_factories = default_working_copy_factories();
let workspace = Workspace::load(
&settings,
&self.working_dir,
&store_factories,
&wc_factories,
)
.map_err(|_| Error::NotARepository)?;
let repo =
workspace
.repo_loader()
.load_at_head()
.await
.map_err(|e| Error::JjOperation {
context: e.to_string(),
})?;
let mut tx = repo.start_transaction(); let mut tx = repo.start_transaction();
let commit = tx
let wc_commit_id = tx
.repo() .repo()
.view() .store()
.get_wc_commit_id(WorkspaceName::DEFAULT) .get_commit(&commit_id)
.ok_or_else(|| Error::JjOperation { .map_err(|e| Error::JjOperation {
context: "No working copy commit found".to_string(), context: e.to_string(),
})? })?;
.clone();
let wc_commit =
tx.repo()
.store()
.get_commit(&wc_commit_id)
.map_err(|e| Error::JjOperation {
context: e.to_string(),
})?;
tx.repo_mut() tx.repo_mut()
.rewrite_commit(&wc_commit) .rewrite_commit(&commit)
.set_description(message) .set_description(message)
.write() .write()
.await .await
@@ -168,14 +241,55 @@ impl JjExecutor for JjLib {
context: format!("{e:?}"), context: format!("{e:?}"),
})?; })?;
tx.commit("jj-cz: update commit description") let new_repo = tx
.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(),
})?; })?;
*self.repo.lock()? = new_repo;
Ok(()) Ok(())
} }
async fn get_description(&self, revset: &str) -> Result<String, Error> {
let commit_id = self.get_commit_id(revset).await?;
let repo = self.repo.lock()?.clone();
let commit = repo
.store()
.get_commit(&commit_id)
.map_err(|e| Error::JjOperation {
context: e.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)]
@@ -191,7 +305,6 @@ mod tests {
.map_err(|e| format!("Failed to init jj repo: {e}")) .map_err(|e| format!("Failed to init jj repo: {e}"))
} }
/// Get the current commit description from a jj repository using jj-lib
async fn get_commit_description(dir: &Path) -> Result<String, String> { async fn get_commit_description(dir: &Path) -> Result<String, String> {
let settings = JjLib::load_settings().map_err(|e| e.to_string())?; let settings = JjLib::load_settings().map_err(|e| e.to_string())?;
let store_factories = StoreFactories::default(); let store_factories = StoreFactories::default();
@@ -208,7 +321,7 @@ mod tests {
let wc_commit_id = repo let wc_commit_id = repo
.view() .view()
.get_wc_commit_id(WorkspaceName::DEFAULT) .get_wc_commit_id(jj_lib::ref_name::WorkspaceName::DEFAULT)
.ok_or_else(|| "No working copy commit found".to_string())? .ok_or_else(|| "No working copy commit found".to_string())?
.clone(); .clone();
@@ -227,7 +340,7 @@ mod tests {
.await .await
.expect("Failed to init jj repo"); .expect("Failed to init jj repo");
let executor = JjLib::with_working_dir(temp_dir.path()); let executor = JjLib::with_working_dir(temp_dir.path()).await.unwrap();
let result = executor.is_repository().await; let result = executor.is_repository().await;
assert!(result.is_ok()); assert!(result.is_ok());
@@ -238,11 +351,8 @@ mod tests {
async fn is_repository_returns_false_outside_jj_repo() { async fn is_repository_returns_false_outside_jj_repo() {
let temp_dir = assert_fs::TempDir::new().unwrap(); let temp_dir = assert_fs::TempDir::new().unwrap();
let executor = JjLib::with_working_dir(temp_dir.path()); let executor = JjLib::with_working_dir(temp_dir.path()).await;
let result = executor.is_repository().await; assert!(executor.is_err());
assert!(result.is_ok());
assert!(!result.unwrap());
} }
#[tokio::test] #[tokio::test]
@@ -253,9 +363,9 @@ mod tests {
.expect("Failed to init jj repo"); .expect("Failed to init jj repo");
let test_message = "test: initial commit"; let test_message = "test: initial commit";
let executor = JjLib::with_working_dir(temp_dir.path()); let executor = JjLib::with_working_dir(temp_dir.path()).await.unwrap();
let result = executor.describe(test_message).await; let result = executor.describe("@", test_message).await;
assert!(result.is_ok(), "describe failed: {result:?}"); assert!(result.is_ok(), "describe failed: {result:?}");
let actual = get_commit_description(temp_dir.path()) let actual = get_commit_description(temp_dir.path())
@@ -272,9 +382,9 @@ mod tests {
.expect("Failed to init jj repo"); .expect("Failed to init jj repo");
let test_message = "feat: add feature with special chars !@#$%^&*()"; let test_message = "feat: add feature with special chars !@#$%^&*()";
let executor = JjLib::with_working_dir(temp_dir.path()); let executor = JjLib::with_working_dir(temp_dir.path()).await.unwrap();
let result = executor.describe(test_message).await; let result = executor.describe("@", test_message).await;
assert!(result.is_ok()); assert!(result.is_ok());
let actual = get_commit_description(temp_dir.path()) let actual = get_commit_description(temp_dir.path())
@@ -291,9 +401,9 @@ mod tests {
.expect("Failed to init jj repo"); .expect("Failed to init jj repo");
let test_message = "docs: add unicode support 🎉 🚀"; let test_message = "docs: add unicode support 🎉 🚀";
let executor = JjLib::with_working_dir(temp_dir.path()); let executor = JjLib::with_working_dir(temp_dir.path()).await.unwrap();
let result = executor.describe(test_message).await; let result = executor.describe("@", test_message).await;
assert!(result.is_ok()); assert!(result.is_ok());
let actual = get_commit_description(temp_dir.path()) let actual = get_commit_description(temp_dir.path())
@@ -310,9 +420,9 @@ mod tests {
.expect("Failed to init jj repo"); .expect("Failed to init jj repo");
let test_message = "feat: add feature\n\nThis is a multiline\ndescription"; let test_message = "feat: add feature\n\nThis is a multiline\ndescription";
let executor = JjLib::with_working_dir(temp_dir.path()); let executor = JjLib::with_working_dir(temp_dir.path()).await.unwrap();
let result = executor.describe(test_message).await; let result = executor.describe("@", test_message).await;
assert!(result.is_ok()); assert!(result.is_ok());
let actual = get_commit_description(temp_dir.path()) let actual = get_commit_description(temp_dir.path())
@@ -323,13 +433,21 @@ 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();
let executor = JjLib::with_working_dir(temp_dir.path()).await;
assert!(executor.is_err());
let executor = JjLib::with_working_dir(temp_dir.path()); let valid_dir = assert_fs::TempDir::new().unwrap();
let result = executor.describe("test: should fail").await; init_jj_repo(valid_dir.path())
.await
.expect("Failed to init jj repo");
let executor = JjLib::with_working_dir(valid_dir.path()).await.unwrap();
let result = executor
.describe("this-bookmark-does-not-exist", "test: should fail")
.await;
assert!(result.is_err()); assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::NotARepository));
} }
#[tokio::test] #[tokio::test]
@@ -339,10 +457,10 @@ mod tests {
.await .await
.expect("Failed to init jj repo"); .expect("Failed to init jj repo");
let executor = JjLib::with_working_dir(temp_dir.path()); let executor = JjLib::with_working_dir(temp_dir.path()).await.unwrap();
executor executor
.describe("feat: first commit") .describe("@", "feat: first commit")
.await .await
.expect("First describe failed"); .expect("First describe failed");
let desc1 = get_commit_description(temp_dir.path()) let desc1 = get_commit_description(temp_dir.path())
@@ -351,7 +469,7 @@ mod tests {
assert_eq!(desc1, "feat: first commit"); assert_eq!(desc1, "feat: first commit");
executor executor
.describe("feat: updated commit") .describe("@", "feat: updated commit")
.await .await
.expect("Second describe failed"); .expect("Second describe failed");
let desc2 = get_commit_description(temp_dir.path()) let desc2 = get_commit_description(temp_dir.path())
@@ -360,11 +478,87 @@ mod tests {
assert_eq!(desc2, "feat: updated commit"); assert_eq!(desc2, "feat: updated commit");
} }
#[test] #[tokio::test]
fn jj_lib_implements_jj_executor_trait() { async fn get_description_returns_empty_for_fresh_commit() {
let lib = JjLib::with_working_dir(std::path::Path::new(".")); let temp_dir = assert_fs::TempDir::new().unwrap();
fn accepts_executor(_: impl JjExecutor) {} init_jj_repo(temp_dir.path())
accepts_executor(lib); .await
.expect("Failed to init jj repo");
let executor = JjLib::with_working_dir(temp_dir.path()).await.unwrap();
let desc = executor
.get_description("@")
.await
.expect("get_description failed");
assert_eq!(desc, "");
}
#[tokio::test]
async fn get_description_reflects_describe_on_same_executor() {
let temp_dir = assert_fs::TempDir::new().unwrap();
init_jj_repo(temp_dir.path())
.await
.expect("Failed to init jj repo");
let executor = JjLib::with_working_dir(temp_dir.path()).await.unwrap();
let message = "feat: test get_description";
executor
.describe("@", message)
.await
.expect("describe failed");
let desc = executor
.get_description("@")
.await
.expect("get_description failed");
assert_eq!(desc, message);
}
#[tokio::test]
async fn multiple_revisions_error_for_multi_commit_revset() {
let temp_dir = assert_fs::TempDir::new().unwrap();
init_jj_repo(temp_dir.path())
.await
.expect("Failed to init jj repo");
let executor = JjLib::with_working_dir(temp_dir.path()).await.unwrap();
let result = executor.describe("@ | root()", "test").await;
assert!(matches!(result, Err(Error::MultipleRevisions { .. })));
}
#[tokio::test]
async fn empty_revset_returns_resolution_error() {
let temp_dir = assert_fs::TempDir::new().unwrap();
init_jj_repo(temp_dir.path())
.await
.expect("Failed to init jj repo");
let executor = JjLib::with_working_dir(temp_dir.path()).await.unwrap();
let result = executor.describe("none()", "test").await;
assert!(matches!(result, Err(Error::RevsetResolutionError { .. })));
}
#[tokio::test]
async fn invalid_revset_syntax_returns_resolution_error() {
let temp_dir = assert_fs::TempDir::new().unwrap();
init_jj_repo(temp_dir.path())
.await
.expect("Failed to init jj repo");
let executor = JjLib::with_working_dir(temp_dir.path()).await.unwrap();
let result = executor.describe("(((invalid", "test").await;
assert!(matches!(result, Err(Error::RevsetResolutionError { .. })));
}
#[tokio::test]
async fn jj_lib_implements_jj_executor_trait() {
fn assert_implements<T: JjExecutor>() {}
assert_implements::<JjLib>();
} }
mod user_config_paths_tests { mod user_config_paths_tests {
+109 -12
View File
@@ -11,14 +11,22 @@ use std::sync::{Mutex, atomic::AtomicBool};
/// Mock implementation of JjExecutor for testing /// Mock implementation of JjExecutor for testing
#[derive(Debug)] #[derive(Debug)]
pub struct MockJjExecutor { pub struct MockJjExecutor {
/// Response to return from is_repository() /// Response to return from `is_repository()`
is_repo_response: Result<bool, Error>, is_repo_response: Result<bool, Error>,
/// Response to return from describe() /// Response to return from `describe()`
describe_response: Result<(), Error>, describe_response: Result<(), Error>,
/// Track calls to is_repository() /// Track described revsets
described_revsets: Mutex<Vec<String>>,
/// Track response to return from `get_description()`
get_description_response: Result<String, Error>,
/// 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 {
@@ -26,8 +34,12 @@ impl Default for MockJjExecutor {
Self { Self {
is_repo_response: Ok(true), is_repo_response: Ok(true),
describe_response: Ok(()), describe_response: Ok(()),
described_revsets: Mutex::new(Vec::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()),
} }
} }
} }
@@ -60,6 +72,15 @@ impl MockJjExecutor {
pub fn describe_messages(&self) -> Vec<String> { pub fn describe_messages(&self) -> Vec<String> {
self.describe_calls.lock().unwrap().clone() self.describe_calls.lock().unwrap().clone()
} }
pub fn with_new_revision_response(mut self, response: Result<(), Error>) -> Self {
self.new_revision_response = response;
self
}
pub fn new_revision_calls(&self) -> Vec<String> {
self.new_revision_calls.lock().unwrap().clone()
}
} }
#[async_trait(?Send)] #[async_trait(?Send)]
@@ -73,7 +94,11 @@ impl JjExecutor for MockJjExecutor {
} }
} }
async fn describe(&self, message: &str) -> Result<(), Error> { async fn describe(&self, revset: &str, message: &str) -> Result<(), Error> {
self.described_revsets
.lock()
.unwrap()
.push(revset.to_string());
self.describe_calls self.describe_calls
.lock() .lock()
.unwrap() .unwrap()
@@ -83,6 +108,21 @@ impl JjExecutor for MockJjExecutor {
Err(e) => Err(e.clone()), Err(e) => Err(e.clone()),
} }
} }
async fn get_description(&self, _revset: &str) -> Result<String, Error> {
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)]
@@ -130,7 +170,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn mock_describe_records_message() { async fn mock_describe_records_message() {
let mock = MockJjExecutor::new(); let mock = MockJjExecutor::new();
let result = mock.describe("test message").await; let result = mock.describe("@", "test message").await;
assert!(result.is_ok()); assert!(result.is_ok());
let messages = mock.describe_messages(); let messages = mock.describe_messages();
assert_eq!(messages.len(), 1); assert_eq!(messages.len(), 1);
@@ -141,8 +181,8 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn mock_describe_records_multiple_messages() { async fn mock_describe_records_multiple_messages() {
let mock = MockJjExecutor::new(); let mock = MockJjExecutor::new();
mock.describe("first message").await.unwrap(); mock.describe("@", "first message").await.unwrap();
mock.describe("second message").await.unwrap(); mock.describe("@", "second message").await.unwrap();
let messages = mock.describe_messages(); let messages = mock.describe_messages();
assert_eq!(messages.len(), 2); assert_eq!(messages.len(), 2);
assert_eq!(messages[0], "first message"); assert_eq!(messages[0], "first message");
@@ -153,7 +193,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn mock_describe_returns_error() { async fn mock_describe_returns_error() {
let mock = MockJjExecutor::new().with_describe_response(Err(Error::RepositoryLocked)); let mock = MockJjExecutor::new().with_describe_response(Err(Error::RepositoryLocked));
let result = mock.describe("test").await; let result = mock.describe("@", "test").await;
assert!(result.is_err()); assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::RepositoryLocked)); assert!(matches!(result.unwrap_err(), Error::RepositoryLocked));
} }
@@ -164,7 +204,7 @@ mod tests {
let mock = MockJjExecutor::new().with_describe_response(Err(Error::JjOperation { let mock = MockJjExecutor::new().with_describe_response(Err(Error::JjOperation {
context: "transaction failed".to_string(), context: "transaction failed".to_string(),
})); }));
let result = mock.describe("test").await; let result = mock.describe("@", "test").await;
assert!(result.is_err()); assert!(result.is_err());
match result.unwrap_err() { match result.unwrap_err() {
Error::JjOperation { context } => { Error::JjOperation { context } => {
@@ -208,7 +248,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn mock_describe_accepts_empty_message() { async fn mock_describe_accepts_empty_message() {
let mock = MockJjExecutor::new(); let mock = MockJjExecutor::new();
let result = mock.describe("").await; let result = mock.describe("@", "").await;
assert!(result.is_ok()); assert!(result.is_ok());
assert_eq!(mock.describe_messages()[0], ""); assert_eq!(mock.describe_messages()[0], "");
} }
@@ -218,7 +258,7 @@ mod tests {
async fn mock_describe_accepts_long_message() { async fn mock_describe_accepts_long_message() {
let mock = MockJjExecutor::new(); let mock = MockJjExecutor::new();
let long_message = "a".repeat(1000); let long_message = "a".repeat(1000);
let result = mock.describe(&long_message).await; let result = mock.describe("@", &long_message).await;
assert!(result.is_ok()); assert!(result.is_ok());
assert_eq!(mock.describe_messages()[0].len(), 1000); assert_eq!(mock.describe_messages()[0].len(), 1000);
} }
@@ -231,4 +271,61 @@ mod tests {
mock.is_repository().await.unwrap(); mock.is_repository().await.unwrap();
assert!(mock.was_is_repo_called()); assert!(mock.was_is_repo_called());
} }
/// Test mock new_revision() records the revset
#[tokio::test]
async fn mock_new_revision_records_revset() {
let mock = MockJjExecutor::new();
let result = mock.new_revision("@").await;
assert!(result.is_ok());
let calls = mock.new_revision_calls();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0], "@");
}
/// Test mock new_revision() records multiple calls
#[tokio::test]
async fn mock_new_revision_records_multiple_calls() {
let mock = MockJjExecutor::new();
mock.new_revision("@").await.unwrap();
mock.new_revision("abc").await.unwrap();
mock.new_revision("xyz").await.unwrap();
let calls = mock.new_revision_calls();
assert_eq!(calls.len(), 3);
assert_eq!(calls[0], "@");
assert_eq!(calls[1], "abc");
assert_eq!(calls[2], "xyz");
}
/// Test mock new_revision() returns configured error
#[tokio::test]
async fn mock_new_revision_returns_error() {
let mock = MockJjExecutor::new().with_new_revision_response(Err(Error::RepositoryLocked));
let result = mock.new_revision("@").await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::RepositoryLocked));
}
/// Test mock new_revision() records revset even on error
#[tokio::test]
async fn mock_new_revision_records_revset_on_error() {
let mock = MockJjExecutor::new().with_new_revision_response(Err(Error::JjOperation {
context: "failed".to_string(),
}));
let result = mock.new_revision("abc").await;
assert!(result.is_err());
let calls = mock.new_revision_calls();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0], "abc");
}
/// Test mock new_revision() can be inspected after success
#[tokio::test]
async fn mock_new_revision_returns_ok_and_tracks_revset() {
let mock = MockJjExecutor::new();
let result = mock.new_revision("my-feature").await;
assert!(result.is_ok());
let calls = mock.new_revision_calls();
assert_eq!(calls, vec!["my-feature"]);
}
} }
+12 -1
View File
@@ -14,7 +14,18 @@ pub trait JjExecutor: Send + Sync {
async fn is_repository(&self) -> Result<bool, Error>; async fn is_repository(&self) -> Result<bool, Error>;
/// Set the description of the current change /// Set the description of the current change
async fn describe(&self, message: &str) -> Result<(), Error>; ///
/// The revset parameter should resolve to a single commit (e.g.,
/// `"@"`, `"xs"`, bookmark name)
async fn describe(&self, revset: &str, message: &str) -> Result<(), Error>;
/// Get the current description of a specific revision
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 -2
View File
@@ -1,6 +1,5 @@
mod cli;
mod commit; mod commit;
mod error; pub mod error;
mod jj; mod jj;
mod prompts; mod prompts;
+33 -31
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,
@@ -21,7 +21,10 @@ fn error_to_exit_code(error: &Error) -> i32 {
Error::InvalidCommitMessage(_) => EXIT_ERROR, Error::InvalidCommitMessage(_) => EXIT_ERROR,
Error::NonInteractive => EXIT_ERROR, Error::NonInteractive => EXIT_ERROR,
Error::FailedGettingCurrentDir => EXIT_ERROR, Error::FailedGettingCurrentDir => EXIT_ERROR,
Error::FailedReadingConfig => EXIT_ERROR, Error::FailedReadingConfig { .. } => EXIT_ERROR,
Error::RevsetResolutionError { .. } => EXIT_ERROR,
Error::MultipleRevisions { .. } => EXIT_ERROR,
Error::NewFlagWithMultipleRevisions => EXIT_ERROR,
} }
} }
@@ -32,9 +35,10 @@ fn is_interactive_terminal() -> bool {
} }
#[tokio::main] #[tokio::main]
async fn main() { async fn main() -> Result<(), ()> {
// Parse CLI arguments; --help and --version are handled automatically by clap let cli = cli::Cli::parse();
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)");
@@ -42,33 +46,31 @@ async fn main() {
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)?;
// Create the jj executor
let executor = match JjLib::new() {
Ok(e) => e,
Err(e) => {
eprintln!("❌ Error: {}", e);
process::exit(EXIT_ERROR);
}
};
// Create and run the workflow
let workflow = CommitWorkflow::new(executor); let workflow = CommitWorkflow::new(executor);
let result = workflow.run().await; for revset in cli.revsets() {
let result = workflow.run_for_revset(revset).await;
// Handle the result handle_result(result);
match result { if cli.create_new() {
Ok(()) => { println!("Creating a new revision after {revset}");
println!("✅ Commit message applied successfully!"); workflow.new_revision(revset).await.map_err(exit_on_error)?;
process::exit(EXIT_SUCCESS);
}
Err(Error::Cancelled) => {
println!("🟡 Operation cancelled by user.");
process::exit(EXIT_CANCELLED);
}
Err(e) => {
eprintln!("❌ Error: {}", e);
process::exit(error_to_exit_code(&e));
} }
} }
fn handle_result(result: Result<(), Error>) {
match result {
Ok(()) => println!("✅ Commit message applied successfully!"),
Err(Error::Cancelled) => {
println!("🟡 Operation cancelled by user.");
process::exit(EXIT_CANCELLED);
}
Err(e) => exit_on_error(e),
}
}
process::exit(EXIT_SUCCESS);
}
fn exit_on_error(e: Error) {
eprintln!("❌ Error: {}", e);
process::exit(error_to_exit_code(e));
} }
+59 -1
View File
@@ -8,7 +8,7 @@
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use crate::{ use crate::{
commit::types::{Body, BreakingChange, CommitType, Description, Scope}, commit::types::{Body, BreakingChange, CommitType, Description, References, Scope},
error::Error, error::Error,
prompts::prompter::Prompter, prompts::prompter::Prompter,
}; };
@@ -20,6 +20,7 @@ enum MockResponse {
Scope(Scope), Scope(Scope),
Description(Description), Description(Description),
BreakingChange(BreakingChange), BreakingChange(BreakingChange),
References(References),
Body(Body), Body(Body),
Confirm(bool), Confirm(bool),
Error(Error), Error(Error),
@@ -81,6 +82,15 @@ impl MockPrompts {
self self
} }
/// Configure the mock to return specific references
pub fn with_references(self, references: References) -> Self {
self.responses
.lock()
.unwrap()
.push(MockResponse::References(references));
self
}
/// Configure the mock to return a specific body response /// Configure the mock to return a specific body response
pub fn with_body(self, body: Body) -> Self { pub fn with_body(self, body: Body) -> Self {
self.responses self.responses
@@ -140,6 +150,14 @@ impl MockPrompts {
.contains(&"input_breaking_change".to_string()) .contains(&"input_breaking_change".to_string())
} }
/// Check if input_references was called
pub fn was_references_called(&self) -> bool {
self.prompts_called
.lock()
.unwrap()
.contains(&"input_references".to_string())
}
/// Check if confirm_apply was called /// Check if confirm_apply was called
pub fn was_confirm_called(&self) -> bool { pub fn was_confirm_called(&self) -> bool {
self.prompts_called self.prompts_called
@@ -207,6 +225,18 @@ impl Prompter for MockPrompts {
} }
} }
fn input_references(&self) -> Result<References, Error> {
self.prompts_called
.lock()
.unwrap()
.push("input_references".to_string());
match self.responses.lock().unwrap().remove(0) {
MockResponse::References(r) => Ok(r),
MockResponse::Error(e) => Err(e),
_ => panic!("MockPrompts: Expected References response, got different type"),
}
}
fn input_body(&self) -> Result<Body, Error> { fn input_body(&self) -> Result<Body, Error> {
self.prompts_called self.prompts_called
.lock() .lock()
@@ -336,6 +366,34 @@ mod tests {
assert!(mock.emitted_messages().is_empty()); assert!(mock.emitted_messages().is_empty());
} }
#[test]
fn mock_input_references() {
let refs = References::from("#123, #456");
let mock = MockPrompts::new().with_references(refs.clone());
let result = mock.input_references();
assert!(result.is_ok());
assert_eq!(result.unwrap(), refs);
assert!(mock.was_references_called());
}
#[test]
fn mock_input_references_default() {
let mock = MockPrompts::new().with_references(References::default());
let result = mock.input_references();
assert!(result.is_ok());
assert_eq!(result.unwrap(), References::default());
assert!(mock.was_references_called());
}
#[test]
fn mock_input_references_error() {
let mock = MockPrompts::new().with_error(Error::Cancelled);
let result = mock.input_references();
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::Cancelled));
assert!(mock.was_references_called());
}
#[test] #[test]
fn mock_input_breaking_change_no() { fn mock_input_breaking_change_no() {
let mock = MockPrompts::new().with_breaking_change(BreakingChange::No); let mock = MockPrompts::new().with_breaking_change(BreakingChange::No);
+29 -57
View File
@@ -9,7 +9,7 @@ use inquire::{Confirm, Text};
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
use crate::{ use crate::{
commit::types::{Body, BreakingChange, CommitType, Description, Scope}, commit::types::{Body, BreakingChange, CommitType, Description, References, Scope},
error::Error, error::Error,
}; };
@@ -17,7 +17,7 @@ use crate::{
/// ///
/// Implement this trait to supply a custom front-end (interactive TUI, mock, /// Implement this trait to supply a custom front-end (interactive TUI, mock,
/// headless, etc.) to [`CommitWorkflow`](super::CommitWorkflow). /// headless, etc.) to [`CommitWorkflow`](super::CommitWorkflow).
pub trait Prompter: Send + Sync { pub trait Prompter {
/// Prompt the user to select a commit type /// Prompt the user to select a commit type
fn select_commit_type(&self) -> Result<CommitType, Error>; fn select_commit_type(&self) -> Result<CommitType, Error>;
@@ -33,6 +33,9 @@ pub trait Prompter: Send + Sync {
/// 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>;
@@ -66,67 +69,45 @@ pub struct RealPrompts;
impl Prompter for RealPrompts { impl Prompter for RealPrompts {
fn select_commit_type(&self) -> Result<CommitType, Error> { fn select_commit_type(&self) -> Result<CommitType, Error> {
use inquire::Select; inquire::Select::new("Select commit type:", CommitType::all().to_vec())
let options: Vec<_> = CommitType::all()
.iter()
.map(|ct| format!("{}: {}", ct, ct.description()))
.collect();
let answer = Select::new("Select commit type:", options)
.with_page_size(11) .with_page_size(11)
.with_help_message( .with_help_message(
"Use arrow keys to navigate, Enter to select. See https://www.conventionalcommits.org/ for details.", "Use arrow keys to navigate, Enter to select. See https://www.conventionalcommits.org/ for details.",
) )
.with_formatter(&|option| format!("{}: {}", option.value.as_str(), option.value.description()))
.prompt() .prompt()
.map_err(|_| Error::Cancelled)?; .map_err(|_| Error::Cancelled)
// Extract the commit type from the selected option
let selected_type = answer
.split(':')
.next()
.ok_or_else(|| Error::JjOperation {
context: "Failed to parse selected commit type".to_string(),
})?
.trim();
CommitType::all()
.iter()
.find(|ct| ct.as_str() == selected_type)
.copied()
.ok_or_else(|| Error::JjOperation {
context: format!("Unknown commit type: {}", selected_type),
})
} }
fn input_scope(&self) -> Result<Scope, Error> { fn input_scope(&self) -> Result<Scope, Error> {
use inquire::Text; let answer = inquire::Text::new("Enter scope (optional):")
let answer = Text::new("Enter scope (optional):")
.with_help_message( .with_help_message(
"Scope is optional. If provided, it should be a noun representing the section of code affected (e.g., 'api', 'ui', 'config'). Max 30 characters.", "Scope is optional. If provided, it should be a noun representing the section of code affected (e.g., 'api', 'ui', 'config'). Max 30 characters.",
) )
.with_placeholder("Leave empty if no scope") .with_placeholder("Leave empty if no scope")
.prompt_skippable() .prompt_skippable()
.map_err(|_| Error::Cancelled)?; .map_err(|_| Error::Cancelled)?;
match answer {
// Empty input is valid (no scope) Some(s) if s.trim().is_empty() => Ok(Scope::empty()),
let answer_str = match answer { Some(s) => Scope::parse(s.trim()).map_err(|e| Error::InvalidScope(e.to_string())),
Some(s) => s, None => Ok(Scope::empty()),
None => return Ok(Scope::empty()),
};
if answer_str.trim().is_empty() {
return Ok(Scope::empty());
} }
}
// Parse and validate the scope fn input_references(&self) -> Result<References, Error> {
Scope::parse(answer_str.trim()).map_err(|e| Error::InvalidScope(e.to_string())) 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> {
use inquire::Text;
loop { loop {
let answer = Text::new("Enter description (required):") let answer = Text::new("Enter description (required):")
.with_help_message( .with_help_message(
@@ -142,7 +123,7 @@ impl Prompter for RealPrompts {
continue; continue;
} }
// parse() only fails on empty already handled above // parse() only fails on empty - already handled above
let Ok(desc) = Description::parse(trimmed) else { let Ok(desc) = Description::parse(trimmed) else {
println!("❌ Description cannot be empty. Please provide a description."); println!("❌ Description cannot be empty. Please provide a description.");
continue; continue;
@@ -180,13 +161,10 @@ impl Prompter for RealPrompts {
} }
fn input_body(&self) -> Result<Body, Error> { fn input_body(&self) -> Result<Body, Error> {
use inquire::Editor;
let wants_body = Confirm::new("Add a body?") let wants_body = Confirm::new("Add a body?")
.with_default(false) .with_default(false)
.prompt() .prompt()
.map_err(|_| Error::Cancelled)?; .map_err(|_| Error::Cancelled)?;
if !wants_body { if !wants_body {
return Ok(Body::default()); return Ok(Body::default());
} }
@@ -196,12 +174,11 @@ JJ: Body (optional). Markdown is supported.\n\
JJ: Wrap prose lines at 72 characters where possible.\n\ JJ: Wrap prose lines at 72 characters where possible.\n\
JJ: Lines starting with \"JJ:\" will be removed.\n"; JJ: Lines starting with \"JJ:\" will be removed.\n";
let raw = Editor::new("Body:") let raw = inquire::Editor::new("Body:")
.with_predefined_text(template) .with_predefined_text(template)
.with_file_extension(".md") .with_file_extension(".md")
.prompt() .prompt()
.map_err(|_| Error::Cancelled)?; .map_err(|_| Error::Cancelled)?;
let stripped: String = raw let stripped: String = raw
.lines() .lines()
.filter(|line| !line.starts_with("JJ:")) .filter(|line| !line.starts_with("JJ:"))
@@ -212,16 +189,11 @@ JJ: Lines starting with \"JJ:\" will be removed.\n";
} }
fn confirm_apply(&self, message: &str) -> Result<bool, Error> { fn confirm_apply(&self, message: &str) -> Result<bool, Error> {
use inquire::Confirm;
// Show preview
println!( println!(
"\n📝 Commit Message Preview:\n{}\n", "\n📝 Commit Message Preview:\n{}\n",
format_message_box(message) format_message_box(message)
); );
inquire::Confirm::new("Apply this commit message?")
// Get confirmation
Confirm::new("Apply this commit message?")
.with_default(true) .with_default(true)
.with_help_message("Select 'No' to cancel and start over") .with_help_message("Select 'No' to cancel and start over")
.prompt() .prompt()
@@ -313,7 +285,7 @@ mod tests {
} }
/// A single CJK character (display width 2) is padded as if it occupies 2 columns, /// A single CJK character (display width 2) is padded as if it occupies 2 columns,
/// not 1 so the right-hand padding is 70 spaces, not 71 /// not 1 - so the right-hand padding is 70 spaces, not 71
#[test] #[test]
fn format_message_box_single_cjk_char() { fn format_message_box_single_cjk_char() {
let result = format_message_box(""); let result = format_message_box("");
+180 -93
View File
@@ -6,7 +6,7 @@
use crate::{ use crate::{
commit::types::{ commit::types::{
Body, BreakingChange, CommitMessageError, CommitType, ConventionalCommit, Description, Body, BreakingChange, CommitMessageError, CommitType, ConventionalCommit, Description,
Scope, References, Scope,
}, },
error::Error, error::Error,
jj::JjExecutor, jj::JjExecutor,
@@ -54,23 +54,30 @@ impl<J: JjExecutor, P: Prompter> CommitWorkflow<J, P> {
/// - User cancels the workflow /// - User cancels the workflow
/// - Repository operation fails /// - Repository operation fails
/// - Message validation fails /// - Message validation fails
pub async fn run(&self) -> Result<(), Error> { pub async fn run_for_revset(&self, revset: &str) -> Result<(), Error> {
if !self.executor.is_repository().await? { if !self.executor.is_repository().await? {
return Err(Error::NotARepository); return Err(Error::NotARepository);
} }
let commit_type = self.type_selection().await?; // For future reference
let _existing_desc = self.executor.get_description(revset).await.ok();
let commit_type = self.type_selection()?;
loop { loop {
let scope = self.scope_input().await?; let scope = self.scope_input()?;
let description = self.description_input().await?; let description = self.description_input()?;
let breaking_change = self.breaking_change_input().await?; let breaking_change = self.breaking_change_input()?;
let body = self.body_input().await?; let references = self.references_input()?;
match self let body = self.body_input()?;
.preview_and_confirm(commit_type, scope, description, breaking_change, body) match self.preview_and_confirm(
.await commit_type,
{ scope,
description,
breaking_change,
body,
references,
) {
Ok(conventional_commit) => { Ok(conventional_commit) => {
self.executor self.executor
.describe(&conventional_commit.to_string()) .describe(revset, &conventional_commit.to_string())
.await?; .await?;
return Ok(()); return Ok(());
} }
@@ -86,7 +93,7 @@ impl<J: JjExecutor, P: Prompter> CommitWorkflow<J, P> {
} }
/// Prompt user to select a commit type from the 11 available options /// Prompt user to select a commit type from the 11 available options
async fn type_selection(&self) -> Result<CommitType, Error> { fn type_selection(&self) -> Result<CommitType, Error> {
self.prompts.select_commit_type() self.prompts.select_commit_type()
} }
@@ -94,7 +101,7 @@ impl<J: JjExecutor, P: Prompter> CommitWorkflow<J, P> {
/// ///
/// Returns Ok(Scope) with the validated scope, or /// Returns Ok(Scope) with the validated scope, or
/// Error::Cancelled if user cancels /// Error::Cancelled if user cancels
async fn scope_input(&self) -> Result<Scope, Error> { fn scope_input(&self) -> Result<Scope, Error> {
self.prompts.input_scope() self.prompts.input_scope()
} }
@@ -102,7 +109,7 @@ impl<J: JjExecutor, P: Prompter> CommitWorkflow<J, P> {
/// ///
/// Returns Ok(Description) with the validated description, or /// Returns Ok(Description) with the validated description, or
/// Error::Cancelled if user cancels /// Error::Cancelled if user cancels
async fn description_input(&self) -> Result<Description, Error> { fn description_input(&self) -> Result<Description, Error> {
self.prompts.input_description() self.prompts.input_description()
} }
@@ -110,12 +117,17 @@ impl<J: JjExecutor, P: Prompter> CommitWorkflow<J, P> {
/// ///
/// Returns Ok(BreakingChange) with the validated breaking change, /// Returns Ok(BreakingChange) with the validated breaking change,
/// or Error::Cancel if user cancels /// or Error::Cancel if user cancels
async fn breaking_change_input(&self) -> Result<BreakingChange, Error> { fn breaking_change_input(&self) -> Result<BreakingChange, Error> {
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
async fn body_input(&self) -> Result<Body, Error> { fn body_input(&self) -> Result<Body, Error> {
self.prompts.input_body() self.prompts.input_body()
} }
@@ -123,13 +135,14 @@ impl<J: JjExecutor, P: Prompter> CommitWorkflow<J, P> {
/// ///
/// This method also validates that the complete first line /// This method also validates that the complete first line
/// doesn't exceed 72 characters /// doesn't exceed 72 characters
async fn preview_and_confirm( fn preview_and_confirm(
&self, &self,
commit_type: CommitType, commit_type: CommitType,
scope: Scope, scope: Scope,
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(
@@ -138,6 +151,7 @@ impl<J: JjExecutor, P: Prompter> CommitWorkflow<J, P> {
&description, &description,
&breaking_change, &breaking_change,
&body, &body,
&references,
); );
// Try to build the conventional commit (this validates the 72-char limit) // Try to build the conventional commit (this validates the 72-char limit)
@@ -147,6 +161,7 @@ impl<J: JjExecutor, P: Prompter> CommitWorkflow<J, P> {
description.clone(), description.clone(),
breaking_change, breaking_change,
body, body,
references,
) { ) {
Ok(cc) => cc, Ok(cc) => cc,
Err(CommitMessageError::FirstLineTooLong { actual, max }) => { Err(CommitMessageError::FirstLineTooLong { actual, max }) => {
@@ -185,6 +200,10 @@ impl<J: JjExecutor, P: Prompter> CommitWorkflow<J, P> {
Err(Error::Cancelled) Err(Error::Cancelled)
} }
} }
pub async fn new_revision(&self, revset: &str) -> Result<(), Error> {
self.executor.new_revision(revset).await
}
} }
#[cfg(test)] #[cfg(test)]
@@ -208,7 +227,7 @@ mod tests {
async fn workflow_returns_not_a_repository() { async fn workflow_returns_not_a_repository() {
let mock = MockJjExecutor::new().with_is_repo_response(Ok(false)); let mock = MockJjExecutor::new().with_is_repo_response(Ok(false));
let workflow = CommitWorkflow::new(mock); let workflow = CommitWorkflow::new(mock);
let result: Result<(), Error> = workflow.run().await; let result: Result<(), Error> = workflow.run_for_revset("@").await;
assert!(result.is_err()); assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::NotARepository)); assert!(matches!(result.unwrap_err(), Error::NotARepository));
} }
@@ -218,52 +237,52 @@ mod tests {
async fn workflow_returns_repository_error() { async fn workflow_returns_repository_error() {
let mock = MockJjExecutor::new().with_is_repo_response(Err(Error::NotARepository)); let mock = MockJjExecutor::new().with_is_repo_response(Err(Error::NotARepository));
let workflow = CommitWorkflow::new(mock); let workflow = CommitWorkflow::new(mock);
let result: Result<(), Error> = workflow.run().await; let result: Result<(), Error> = workflow.run_for_revset("@").await;
assert!(result.is_err()); assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::NotARepository)); assert!(matches!(result.unwrap_err(), Error::NotARepository));
} }
/// Test that type_selection returns a valid CommitType /// Test that type_selection returns a valid CommitType
#[tokio::test] #[test]
async fn type_selection_returns_valid_type() { fn type_selection_returns_valid_type() {
// Updated to use mock prompts to avoid TUI hanging // Updated to use mock prompts to avoid TUI hanging
let mock_executor = MockJjExecutor::new(); let mock_executor = MockJjExecutor::new();
let mock_prompts = MockPrompts::new().with_commit_type(CommitType::Feat); let mock_prompts = MockPrompts::new().with_commit_type(CommitType::Feat);
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
// Now we can actually test the method with mock prompts // Now we can actually test the method with mock prompts
let result = workflow.type_selection().await; let result = workflow.type_selection();
assert!(result.is_ok()); assert!(result.is_ok());
assert_eq!(result.unwrap(), CommitType::Feat); assert_eq!(result.unwrap(), CommitType::Feat);
} }
/// Test that scope_input returns a valid Scope /// Test that scope_input returns a valid Scope
#[tokio::test] #[test]
async fn scope_input_returns_valid_scope() { fn scope_input_returns_valid_scope() {
let mock_executor = MockJjExecutor::new(); let mock_executor = MockJjExecutor::new();
let mock_prompts = MockPrompts::new().with_scope(Scope::parse("test").unwrap()); let mock_prompts = MockPrompts::new().with_scope(Scope::parse("test").unwrap());
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result = workflow.scope_input().await; let result = workflow.scope_input();
assert!(result.is_ok()); assert!(result.is_ok());
assert_eq!(result.unwrap(), Scope::parse("test").unwrap()); assert_eq!(result.unwrap(), Scope::parse("test").unwrap());
} }
/// Test that description_input returns a valid Description /// Test that description_input returns a valid Description
#[tokio::test] #[test]
async fn description_input_returns_valid_description() { fn description_input_returns_valid_description() {
let mock_executor = MockJjExecutor::new(); let mock_executor = MockJjExecutor::new();
let mock_prompts = MockPrompts::new().with_description(Description::parse("test").unwrap()); let mock_prompts = MockPrompts::new().with_description(Description::parse("test").unwrap());
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result = workflow.description_input().await; let result = workflow.description_input();
assert!(result.is_ok()); assert!(result.is_ok());
assert_eq!(result.unwrap(), Description::parse("test").unwrap()); assert_eq!(result.unwrap(), Description::parse("test").unwrap());
} }
/// Test that preview_and_confirm returns a ConventionalCommit /// Test that preview_and_confirm returns a ConventionalCommit
#[tokio::test] #[test]
async fn preview_and_confirm_returns_conventional_commit() { fn preview_and_confirm_returns_conventional_commit() {
let mock_executor = MockJjExecutor::new(); let mock_executor = MockJjExecutor::new();
let mock_prompts = MockPrompts::new().with_confirm(true); let mock_prompts = MockPrompts::new().with_confirm(true);
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
@@ -273,9 +292,15 @@ mod tests {
let description = Description::parse("test description").unwrap(); let description = Description::parse("test description").unwrap();
let breaking_change = BreakingChange::No; let breaking_change = BreakingChange::No;
let body = Body::default(); let body = Body::default();
let result = workflow let references = References::default();
.preview_and_confirm(commit_type, scope, description, breaking_change, body) let result = workflow.preview_and_confirm(
.await; commit_type,
scope,
description,
breaking_change,
body,
references,
);
assert!(result.is_ok()); assert!(result.is_ok());
} }
@@ -289,7 +314,7 @@ mod tests {
// Verify the mock behaves as expected // Verify the mock behaves as expected
assert!(mock.is_repository().await.is_ok()); assert!(mock.is_repository().await.is_ok());
assert!(mock.describe("test").await.is_err()); assert!(mock.describe("@", "test").await.is_err());
// Also test with a working mock // Also test with a working mock
let working_mock = MockJjExecutor::new(); let working_mock = MockJjExecutor::new();
@@ -320,6 +345,7 @@ mod tests {
.with_scope(Scope::empty()) .with_scope(Scope::empty())
.with_description(Description::parse("add new feature").unwrap()) .with_description(Description::parse("add new feature").unwrap())
.with_breaking_change(BreakingChange::Yes) .with_breaking_change(BreakingChange::Yes)
.with_references(References::default())
.with_body(Body::default()) .with_body(Body::default())
.with_confirm(true); .with_confirm(true);
@@ -327,7 +353,7 @@ mod tests {
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
// Run the workflow - should succeed // Run the workflow - should succeed
let result: Result<(), Error> = workflow.run().await; let result: Result<(), Error> = workflow.run_for_revset("@").await;
assert!(result.is_ok()); assert!(result.is_ok());
} }
@@ -338,7 +364,7 @@ mod tests {
let mock_prompts = MockPrompts::new().with_error(Error::Cancelled); let mock_prompts = MockPrompts::new().with_error(Error::Cancelled);
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result: Result<(), Error> = workflow.run().await; let result: Result<(), Error> = workflow.run_for_revset("@").await;
assert!(result.is_err()); assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::Cancelled)); assert!(matches!(result.unwrap_err(), Error::Cancelled));
@@ -353,11 +379,12 @@ 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
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result: Result<(), Error> = workflow.run().await; let result: Result<(), Error> = workflow.run_for_revset("@").await;
assert!(result.is_err()); assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::Cancelled)); assert!(matches!(result.unwrap_err(), Error::Cancelled));
@@ -377,18 +404,20 @@ 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);
// Clone before moving into workflow so we can inspect emitted messages after // Clone before moving into workflow so we can inspect emitted messages after
let mock_prompts_handle = mock_prompts.clone(); let mock_prompts_handle = mock_prompts.clone();
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result: Result<(), Error> = workflow.run().await; let result: Result<(), Error> = workflow.run_for_revset("@").await;
// Should succeed after the retry // Should succeed after the retry
assert!( assert!(
@@ -425,7 +454,7 @@ mod tests {
)); ));
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result: Result<(), Error> = workflow.run().await; let result: Result<(), Error> = workflow.run_for_revset("@").await;
assert!(result.is_err()); assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::InvalidScope(_))); assert!(matches!(result.unwrap_err(), Error::InvalidScope(_)));
@@ -444,15 +473,15 @@ mod tests {
)); ));
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result: Result<(), Error> = workflow.run().await; let result: Result<(), Error> = workflow.run_for_revset("@").await;
assert!(result.is_err()); assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::InvalidDescription(_))); assert!(matches!(result.unwrap_err(), Error::InvalidDescription(_)));
} }
/// Test that mock prompts track method calls correctly /// Test that mock prompts track method calls correctly
#[tokio::test] #[test]
async fn test_mock_prompts_track_calls() { fn test_mock_prompts_track_calls() {
let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true)); let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
let mock_prompts = MockPrompts::new() let mock_prompts = MockPrompts::new()
.with_commit_type(CommitType::Feat) .with_commit_type(CommitType::Feat)
@@ -476,6 +505,7 @@ mod tests {
.with_scope(Scope::empty()) .with_scope(Scope::empty())
.with_description(Description::parse("test").unwrap()) .with_description(Description::parse("test").unwrap())
.with_breaking_change(BreakingChange::Yes) .with_breaking_change(BreakingChange::Yes)
.with_references(References::default())
.with_body(Body::default()) .with_body(Body::default())
.with_confirm(true); .with_confirm(true);
@@ -483,7 +513,7 @@ mod tests {
MockJjExecutor::new().with_is_repo_response(Ok(true)), MockJjExecutor::new().with_is_repo_response(Ok(true)),
mock_prompts, mock_prompts,
); );
let result: Result<(), Error> = workflow.run().await; let result: Result<(), Error> = workflow.run_for_revset("@").await;
assert!(result.is_ok(), "Failed for commit type: {:?}", commit_type); assert!(result.is_ok(), "Failed for commit type: {:?}", commit_type);
} }
} }
@@ -499,6 +529,7 @@ mod tests {
.with_scope(Scope::empty()) .with_scope(Scope::empty())
.with_description(Description::parse("test").unwrap()) .with_description(Description::parse("test").unwrap())
.with_breaking_change(BreakingChange::Yes) .with_breaking_change(BreakingChange::Yes)
.with_references(References::default())
.with_body(Body::default()) .with_body(Body::default())
.with_confirm(true); .with_confirm(true);
@@ -507,7 +538,7 @@ mod tests {
mock_prompts, mock_prompts,
); );
{ {
let result: Result<(), Error> = workflow.run().await; let result: Result<(), Error> = workflow.run_for_revset("@").await;
assert!(result.is_ok()); assert!(result.is_ok());
} }
@@ -517,6 +548,7 @@ mod tests {
.with_scope(Scope::parse("api").unwrap()) .with_scope(Scope::parse("api").unwrap())
.with_description(Description::parse("test").unwrap()) .with_description(Description::parse("test").unwrap())
.with_breaking_change(BreakingChange::No) .with_breaking_change(BreakingChange::No)
.with_references(References::default())
.with_body(Body::default()) .with_body(Body::default())
.with_confirm(true); .with_confirm(true);
@@ -525,7 +557,7 @@ mod tests {
mock_prompts, mock_prompts,
); );
{ {
let result: Result<(), Error> = workflow.run().await; let result: Result<(), Error> = workflow.run_for_revset("@").await;
assert!(result.is_ok()); assert!(result.is_ok());
} }
} }
@@ -552,21 +584,20 @@ mod tests {
/// BreakingChange::No was hard-coded, so a confirmed /// BreakingChange::No was hard-coded, so a confirmed
/// breaking-change commit was silently applied without the '!' /// breaking-change commit was silently applied without the '!'
/// marker. /// marker.
#[tokio::test] #[test]
async fn preview_and_confirm_forwards_breaking_change_yes() { fn preview_and_confirm_forwards_breaking_change_yes() {
let mock_executor = MockJjExecutor::new(); let mock_executor = MockJjExecutor::new();
let mock_prompts = MockPrompts::new().with_confirm(true); let mock_prompts = MockPrompts::new().with_confirm(true);
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result = workflow let result = workflow.preview_and_confirm(
.preview_and_confirm( CommitType::Feat,
CommitType::Feat, Scope::empty(),
Scope::empty(), Description::parse("remove old API").unwrap(),
Description::parse("remove old API").unwrap(), BreakingChange::Yes,
BreakingChange::Yes, Body::default(),
Body::default(), References::default(),
) );
.await;
assert!(result.is_ok(), "expected Ok, got: {:?}", result); assert!(result.is_ok(), "expected Ok, got: {:?}", result);
let message = result.unwrap().to_string(); let message = result.unwrap().to_string();
@@ -580,22 +611,21 @@ mod tests {
/// Preview_and_confirm must forward BreakingChange::WithNote, /// Preview_and_confirm must forward BreakingChange::WithNote,
/// producing a commit with both the '!' header marker and the /// producing a commit with both the '!' header marker and the
/// BREAKING CHANGE footer. /// BREAKING CHANGE footer.
#[tokio::test] #[test]
async fn preview_and_confirm_forwards_breaking_change_with_note() { fn preview_and_confirm_forwards_breaking_change_with_note() {
let mock_executor = MockJjExecutor::new(); let mock_executor = MockJjExecutor::new();
let mock_prompts = MockPrompts::new().with_confirm(true); let mock_prompts = MockPrompts::new().with_confirm(true);
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let breaking_change: BreakingChange = "removes legacy endpoint".into(); let breaking_change: BreakingChange = "removes legacy endpoint".into();
let result = workflow let result = workflow.preview_and_confirm(
.preview_and_confirm( CommitType::Feat,
CommitType::Feat, Scope::empty(),
Scope::empty(), Description::parse("drop legacy API").unwrap(),
Description::parse("drop legacy API").unwrap(), breaking_change,
breaking_change, Body::default(),
Body::default(), References::default(),
) );
.await;
assert!(result.is_ok(), "expected Ok, got: {:?}", result); assert!(result.is_ok(), "expected Ok, got: {:?}", result);
let message = result.unwrap().to_string(); let message = result.unwrap().to_string();
@@ -625,11 +655,12 @@ 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);
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result: Result<(), Error> = workflow.run().await; let result: Result<(), Error> = workflow.run_for_revset("@").await;
assert!( assert!(
result.is_ok(), result.is_ok(),
@@ -655,21 +686,20 @@ mod tests {
/// ///
/// Currently the implementation passes Body::default() instead of the /// Currently the implementation passes Body::default() instead of the
/// received body, so this test will fail until that is fixed. /// received body, so this test will fail until that is fixed.
#[tokio::test] #[test]
async fn preview_and_confirm_forwards_body() { fn preview_and_confirm_forwards_body() {
let mock_executor = MockJjExecutor::new(); let mock_executor = MockJjExecutor::new();
let mock_prompts = MockPrompts::new().with_confirm(true); let mock_prompts = MockPrompts::new().with_confirm(true);
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result = workflow let result = workflow.preview_and_confirm(
.preview_and_confirm( CommitType::Feat,
CommitType::Feat, Scope::empty(),
Scope::empty(), 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(),
) );
.await;
assert!(result.is_ok(), "expected Ok, got: {:?}", result); assert!(result.is_ok(), "expected Ok, got: {:?}", result);
assert!( assert!(
@@ -684,21 +714,20 @@ mod tests {
/// preview_and_confirm must forward the body even when a breaking change is present /// preview_and_confirm must forward the body even when a breaking change is present
/// ///
/// Expected format: "type!: desc\n\nbody\n\nBREAKING CHANGE: note" /// Expected format: "type!: desc\n\nbody\n\nBREAKING CHANGE: note"
#[tokio::test] #[test]
async fn preview_and_confirm_forwards_body_with_breaking_change() { fn preview_and_confirm_forwards_body_with_breaking_change() {
let mock_executor = MockJjExecutor::new(); let mock_executor = MockJjExecutor::new();
let mock_prompts = MockPrompts::new().with_confirm(true); let mock_prompts = MockPrompts::new().with_confirm(true);
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result = workflow let result = workflow.preview_and_confirm(
.preview_and_confirm( CommitType::Feat,
CommitType::Feat, Scope::empty(),
Scope::empty(), 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(),
) );
.await;
assert!(result.is_ok(), "expected Ok, got: {:?}", result); assert!(result.is_ok(), "expected Ok, got: {:?}", result);
let message = result.unwrap().to_string(); let message = result.unwrap().to_string();
@@ -724,11 +753,12 @@ 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);
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result: Result<(), Error> = workflow.run().await; let result: Result<(), Error> = workflow.run_for_revset("@").await;
assert!( assert!(
result.is_ok(), result.is_ok(),
@@ -756,11 +786,12 @@ 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);
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result: Result<(), Error> = workflow.run().await; let result: Result<(), Error> = workflow.run_for_revset("@").await;
assert!( assert!(
result.is_ok(), result.is_ok(),
@@ -772,4 +803,60 @@ mod tests {
assert_eq!(messages.len(), 1); assert_eq!(messages.len(), 1);
assert_eq!(messages[0], "fix: fix crash"); assert_eq!(messages[0], "fix: fix crash");
} }
/// Test workflow new_revision() records the revset
#[tokio::test]
async fn workflow_new_revision_records_revset() {
let mock_executor = MockJjExecutor::new();
let workflow = CommitWorkflow::new(mock_executor);
let result = workflow.new_revision("@").await;
assert!(result.is_ok());
let calls = workflow.executor.new_revision_calls();
assert_eq!(calls, vec!["@"]);
}
/// Test workflow new_revision() propagates executor errors
#[tokio::test]
async fn workflow_new_revision_propagates_error() {
let mock_executor =
MockJjExecutor::new().with_new_revision_response(Err(Error::RepositoryLocked));
let workflow = CommitWorkflow::new(mock_executor);
let result = workflow.new_revision("@").await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::RepositoryLocked));
}
/// Test workflow run_for_revset() followed by new_revision() records both
///
/// This mirrors the actual usage pattern in main.rs.
#[tokio::test]
async fn workflow_describe_then_new_revision() {
let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
let mock_prompts = MockPrompts::new()
.with_commit_type(CommitType::Feat)
.with_scope(Scope::empty())
.with_description(Description::parse("add feature").unwrap())
.with_breaking_change(BreakingChange::No)
.with_references(References::default())
.with_body(Body::default())
.with_confirm(true);
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
workflow.run_for_revset("@").await.expect("describe failed");
workflow
.new_revision("@")
.await
.expect("new_revision failed");
let messages = workflow.executor.describe_messages();
assert_eq!(messages.len(), 1);
assert!(messages[0].contains("feat:"));
let calls = workflow.executor.new_revision_calls();
assert_eq!(calls, vec!["@"]);
}
} }
-99
View File
@@ -1,99 +0,0 @@
use assert_fs::TempDir;
#[cfg(feature = "test-utils")]
use jj_cz::{Body, BreakingChange, CommitType, Description, MockPrompts, Scope};
use jj_cz::{CommitWorkflow, Error, JjLib};
#[cfg(feature = "test-utils")]
use jj_lib::{config::StackedConfig, settings::UserSettings, workspace::Workspace};
/// Helper to initialize a temporary jj repository using jj-lib directly (no CLI required)
#[cfg(feature = "test-utils")]
async fn init_jj_repo(temp_dir: &TempDir) {
let config = StackedConfig::with_defaults();
let settings = UserSettings::from_config(config).expect("Failed to create settings");
Workspace::init_internal_git(&settings, temp_dir.path())
.await
.expect("Failed to initialize jj repository");
}
#[cfg(feature = "test-utils")]
#[tokio::test]
async fn test_happy_path_integration() {
// T037: Happy path integration test
let temp_dir = TempDir::new().unwrap();
init_jj_repo(&temp_dir).await;
// Create mock prompts that simulate a successful workflow
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);
// Create a mock executor that tracks calls
let executor = JjLib::with_working_dir(temp_dir.path());
let workflow = CommitWorkflow::with_prompts(executor, mock_prompts);
let result = workflow.run().await;
// The workflow should complete successfully
assert!(
result.is_ok(),
"Workflow should complete successfully: {:?}",
result
);
}
#[tokio::test]
async fn test_not_in_repo() {
// T038: Not-in-repo integration test
let temp_dir = TempDir::new().unwrap();
// Don't initialize jj repo
// Create executor with the temp directory (which is not a jj repo)
let executor = JjLib::with_working_dir(temp_dir.path());
let workflow = CommitWorkflow::new(executor);
let result = workflow.run().await;
// Should fail with NotARepository error
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, _message: &str) -> Result<(), Error> {
Err(Error::Cancelled)
}
}
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().await;
// Should fail with Cancelled error
assert!(matches!(result, Err(Error::Cancelled)));
}
+155 -1
View File
@@ -20,7 +20,9 @@ fn test_all_error_variants() {
}; };
let _repo_locked = Error::RepositoryLocked; let _repo_locked = Error::RepositoryLocked;
let _failed_dir = Error::FailedGettingCurrentDir; let _failed_dir = Error::FailedGettingCurrentDir;
let _failed_config = Error::FailedReadingConfig; let _failed_config = Error::FailedReadingConfig {
context: "test".to_string(),
};
// Application errors // Application errors
let cancelled = Error::Cancelled; let cancelled = Error::Cancelled;
@@ -133,3 +135,155 @@ fn test_jj_operation_context() {
panic!("Expected JjOperation variant"); panic!("Expected JjOperation variant");
} }
} }
/// Test conversion from std::io::Error
#[test]
fn test_from_io_error() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
let error: Error = io_err.into();
assert!(matches!(error, Error::FailedGettingCurrentDir));
}
/// Test conversion from std::sync::PoisonError
#[test]
fn test_from_poison_error() {
let mutex = std::sync::Mutex::new(());
// Poison the mutex by panicking while holding the lock
let poison_err = std::panic::catch_unwind(|| {
let _guard = mutex.lock().unwrap();
panic!("deliberate panic");
});
assert!(poison_err.is_err());
// Now lock should fail with PoisonError
let result = mutex.lock();
assert!(result.is_err());
let error: Error = result.unwrap_err().into();
assert!(matches!(error, Error::JjOperation { .. }));
assert_eq!(
format!("{}", error),
"Repository operation failed: internal lock poisoned"
);
}
/// Test from_revset_evaluation_error constructs RevsetResolutionError
#[test]
fn test_from_revset_evaluation_error() {
let underlying = std::io::Error::other("store failure");
let eval_err = jj_lib::revset::RevsetEvaluationError::Other(Box::new(underlying));
let error = Error::from_revset_evaluation_error("@", eval_err);
assert!(matches!(error, Error::RevsetResolutionError { .. }));
let description = format!("{}", error);
assert!(description.contains("@"));
assert!(description.contains("store failure"));
}
/// Test from_revset_resolution_error constructs RevsetResolutionError
#[test]
fn test_from_revset_resolution_error() {
let resolution_err = jj_lib::revset::RevsetResolutionError::NoSuchRevision {
name: "nonexistent".to_string(),
candidates: Vec::new(),
};
let error = Error::from_revset_resolution_error("@", resolution_err);
assert!(matches!(error, Error::RevsetResolutionError { .. }));
let description = format!("{}", error);
assert!(description.contains("@"));
assert!(description.contains("nonexistent"));
}
/// Test NewFlagWithMultipleRevisions error display
#[test]
fn test_new_flag_with_multiple_revisions() {
let error = Error::NewFlagWithMultipleRevisions;
assert_eq!(
format!("{}", error),
"--new cannot be used with multiple revisions"
);
}
/// Test NonInteractive error display
#[test]
fn test_non_interactive() {
let error = Error::NonInteractive;
assert_eq!(format!("{}", error), "Non-interactive terminal detected");
}
/// Test FailedReadingConfig error display
#[test]
fn test_failed_reading_config() {
let error = Error::FailedReadingConfig {
context: "config parse error".to_string(),
};
let description = format!("{}", error);
assert!(description.contains("config parse error"));
}
/// Test MultipleRevisions error display
#[test]
fn test_multiple_revisions() {
let error = Error::MultipleRevisions {
revset: "abc | def".to_string(),
};
let description = format!("{}", error);
assert!(description.contains("abc | def"));
assert!(description.contains("multiple commits"));
}
/// Test RepositoryLocked error display
#[test]
fn test_repository_locked() {
let error = Error::RepositoryLocked;
assert_eq!(
format!("{}", error),
"Repository is locked by another process"
);
}
/// Test FailedGettingCurrentDir error display
#[test]
fn test_failed_getting_current_dir() {
let error = Error::FailedGettingCurrentDir;
assert_eq!(format!("{}", error), "Could not get current directory");
}
/// Test error matching on all variants
#[test]
fn test_error_matching_all_variants() {
let variants: Vec<Error> = vec![
Error::InvalidScope("s".into()),
Error::InvalidDescription("d".into()),
Error::InvalidCommitMessage("m".into()),
Error::NotARepository,
Error::JjOperation {
context: "c".into(),
},
Error::RepositoryLocked,
Error::FailedGettingCurrentDir,
Error::FailedReadingConfig {
context: "c".into(),
},
Error::Cancelled,
Error::NonInteractive,
Error::RevsetResolutionError {
revset: "@".into(),
context: "c".into(),
},
Error::MultipleRevisions { revset: "@".into() },
Error::NewFlagWithMultipleRevisions,
];
// All variants should be displayable without panicking
for variant in &variants {
let _ = format!("{}", variant);
let _ = format!("{:?}", variant);
}
// Verify all variants can be cloned
let cloned: Vec<Error> = variants.to_vec();
assert_eq!(variants.len(), cloned.len());
for (original, clone) in variants.iter().zip(cloned.iter()) {
assert_eq!(original, clone);
}
}