Compare commits

18 Commits

Author SHA1 Message Date
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
CI Bot 82aeaec37b chore(release): release 1.0.0 [skip ci] 2026-03-25 14:57:29 +00:00
33 changed files with 1512 additions and 1169 deletions
+1 -3
View File
@@ -4,8 +4,6 @@ if ! has nix_direnv_version || ! nix_direnv_version 3.1.0; then
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.1.0/direnvrc" "sha256-yMJ2OVMzrFaDPn7q8nCBZFRYpL/f0RcHzhmw/i6btJM=" source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.1.0/direnvrc" "sha256-yMJ2OVMzrFaDPn7q8nCBZFRYpL/f0RcHzhmw/i6btJM="
fi fi
export DEVENV_IN_DIRENV_SHELL=true
# Load .env file if present # Load .env file if present
dotenv_if_exists dotenv_if_exists
@@ -20,5 +18,5 @@ if [[ -f .envrc.local ]]; then
fi fi
if ! use flake . --no-pure-eval; then if ! use flake . --no-pure-eval; then
echo "Devenv could not be built. The devenv environment was not loaded. Make the necessary changes to flake.nix and hit enter to try again." >&2 echo "Development shell could not be built. The environment was not loaded. Make the necessary changes to flake.nix and hit enter to try again." >&2
fi fi
+31 -27
View File
@@ -1,4 +1,4 @@
name: Publish Docker Images name: Run checks and build archives
on: on:
push: push:
@@ -6,7 +6,7 @@ on:
- main - main
- develop - develop
tags: tags:
- 'v*.*.*' - "v*.*.*"
pull_request: pull_request:
types: [opened, synchronize, reopened] types: [opened, synchronize, reopened]
@@ -56,32 +56,36 @@ jobs:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
- name: Build Linux release binary build:
run: nix build --no-pure-eval --accept-flake-config needs: coverage-and-sonar
strategy:
matrix:
target: ["linux-x86_64", "linux-aarch64", "windows-x86_64"]
- name: Prepare Linux binary runs-on: ubuntu-latest
run: | permissions:
mkdir dist-linux contents: read
cp result/bin/jj-cz dist-linux/ pull-requests: read
cp LICENSE.*.md dist-linux/ steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Upload Linux artifact - name: Install Nix
uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Set up cachix
uses: cachix/cachix-action@v17
with:
name: phundrak
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: Build jj-cz archive
run: nix build .#${{matrix.target}}-archive
- name: Upload artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: jj-cz-x86_64-unknown-linux-gnu name: jj-cz-${{matrix.target}}
path: dist-linux/* path: result/dist/*
- name: Build Windows release binary
run: nix build .#windows --no-pure-eval --accept-flake-config
- name: Prepare Windows binary
run: |
mkdir -p dist-windows
cp result/bin/jj-cz.exe dist-windows/
cp LICENSE.*.md dist-windows/
- name: Upload Windows artifact
uses: actions/upload-artifact@v3
with:
name: jj-cz-x86_64-pc-windows-gnu
path: dist-windows/*
+66 -38
View File
@@ -6,29 +6,12 @@ on:
- main - main
jobs: jobs:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Nix
uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Set up cachix
uses: cachix/cachix-action@v17
with:
name: phundrak
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: Run Checks
shell: bash -c "nix develop --no-pure-eval --accept-flake-config --command {0}"
run: just check-all
release: release:
needs: checks
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs:
release: ${{ steps.releasable.outputs.release }}
release_id: ${{ steps.create_release.outputs.release_id }}
version: ${{ steps.next_version.outputs.version }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
@@ -49,58 +32,103 @@ jobs:
- name: Check for releasable commits - name: Check for releasable commits
id: releasable id: releasable
run: | run: |
COUNT=$(nix develop --no-pure-eval --command just cliff-count) COUNT=$(nix develop --no-pure-eval --accept-flake-config --command just cliff-count)
echo "count=$COUNT" >> $GITHUB_OUTPUT if [ "$COUNT" -gt 0 ]; then
echo "release=true" >> $GITHUB_OUTPUT
else
echo "release=false" >> $GITHUB_OUTPUT
fi
- name: Determine next version - name: Determine next version
if: steps.releasable.outputs.count > 0 if: steps.releasable.outputs.release == 'true'
id: next_version id: next_version
run: | run: |
CLIFF_NEXT_VERSION=$(nix develop --no-pure-eval --command just cliff-next-version) CLIFF_NEXT_VERSION=$(nix develop --no-pure-eval --accept-flake-config --command just cliff-next-version)
echo "version=$CLIFF_NEXT_VERSION" >> $GITHUB_OUTPUT echo "version=$CLIFF_NEXT_VERSION" >> $GITHUB_OUTPUT
- name: Update changelog - name: Update changelog
if: steps.releasable.outputs.count > 0 if: steps.releasable.outputs.release == 'true'
shell: bash -c "nix develop --no-pure-eval --accept-flake-config --command {0}" shell: bash -c "nix develop --no-pure-eval --accept-flake-config --command {0}"
run: just cliff-bump run: just cliff-bump
- name: Create release commit - name: Create release commit
if: steps.releasable.outputs.count > 0 if: steps.releasable.outputs.release == 'true'
env: env:
VERSION: ${{ steps.next_version.outputs.version }} VERSION: ${{ steps.next_version.outputs.version }}
shell: bash -c "nix develop --no-pure-eval --accept-flake-config --command {0}" shell: bash -c "nix develop --no-pure-eval --accept-flake-config --command {0}"
run: just commit-release $VERSION run: just commit-release $VERSION
- name: Create version tag - name: Create version tag
if: steps.releasable.outputs.count > 0 if: steps.releasable.outputs.release == 'true'
env: env:
VERSION: ${{ steps.next_version.outputs.version }} VERSION: ${{ steps.next_version.outputs.version }}
shell: bash -c "nix develop --no-pure-eval --accept-flake-config --command {0}" shell: bash -c "nix develop --no-pure-eval --accept-flake-config --command {0}"
run: just create-release-tag $VERSION run: just create-release-tag $VERSION
- name: Build Linux release binaries - name: Create Gitea release
if: steps.releasable.outputs.count > 0 if: steps.releasable.outputs.release == 'true'
run: nix build id: create_release
env:
- name: Build Windows release binaries VERSION: ${{ steps.next_version.outputs.version }}
if: steps.releasable.outputs.count > 0 CI_TOKEN: ${{ secrets.CI_TOKEN }}
run: nix build .#windows shell: bash -c "nix develop --no-pure-eval --accept-flake-config --command {0}"
run: |
RESPONSE=$(curl -s -X POST \
-H "Authorization: token $CI_TOKEN" \
-H "Content-Type: application/json" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases" \
-d "{\"tag_name\": \"v${VERSION}\", \"name\": \"v${VERSION}\"}")
echo "release_id=$(echo "$RESPONSE" | jq -r '.id')" >> $GITHUB_OUTPUT
- name: Publish on crates.io - name: Publish on crates.io
if: steps.releasable.outputs.count > 0 if: steps.releasable.outputs.release == 'true'
env: env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
shell: bash -c "nix develop --no-pure-eval --accept-flake-config --command {0}" shell: bash -c "nix develop --no-pure-eval --accept-flake-config --command {0}"
run: cargo publish run: cargo publish
- name: Rebase develop onto main - name: Rebase develop onto main
if: steps.releasable.outputs.count > 0 if: steps.releasable.outputs.release == 'true'
shell: bash -c "nix develop --no-pure-eval --accept-flake-config --command {0}" shell: bash -c "nix develop --no-pure-eval --accept-flake-config --command {0}"
run: just rebase-develop run: just rebase-develop
- name: Bump to next dev version - name: Bump to next dev version
if: steps.releasable.outputs.count > 0 if: steps.releasable.outputs.release == 'true'
env: env:
VERSION: ${{ steps.next_version.outputs.version }} VERSION: ${{ steps.next_version.outputs.version }}
shell: bash -c "nix develop --no-pure-eval --accept-flake-config --command {0}" shell: bash -c "nix develop --no-pure-eval --accept-flake-config --command {0}"
run: just update-develop-version $VERSION run: just update-develop-version $VERSION
build:
needs: release
if: needs.release.outputs.release == 'true'
strategy:
matrix:
target: ["linux-x86_64", "linux-aarch64", "windows-x86_64"]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Nix
uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Set up cachix
uses: cachix/cachix-action@v17
with:
name: phundrak
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: Build jj-cz archive
run: nix build .#${{ matrix.target }}-archive
- name: Upload release asset
env:
CI_TOKEN: ${{ secrets.CI_TOKEN }}
RELEASE_ID: ${{ needs.release.outputs.release_id }}
run: |
curl -s -X POST \
-H "Authorization: token $CI_TOKEN" \
-F "attachment=@$(ls result/dist/*.zip)" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets"
+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
+37
View File
@@ -0,0 +1,37 @@
## [1.0.0] - 2026-03-25
### Features
- *(deps)* Add project dependencies
- Create module structure
- *(error)* Create base Error enum
- *(CommitType)* Implement CommitType and tests
- *(Scope)* Implement Scope and tests
- *(Description)* Implement Description and tests
- *(ConventionalCommit)* Implement ConventionalCommit and tests
- *(errors)* Update error types
- *(JjLib)* JjLib implementation
- Complete JjLib describe implementation
- Add interactive conventional commit workflow with jj-lib backend
- Implement breaking change input
- *(prompt)* Add support for wide characters in prompt preview
- Edit body for commit messages
### Bug Fixes
- *(commit)* Limit complete line limit to 72 chars
- *(config)* Load user config
- *(prompt)* Prompt preview padding
- *(message)* Use unicode char count for text width
### Documentation
- Add contributing guidelines
- Actually write README
### Miscellaneous Tasks
- *(build)* Preparing for CI
- *(build)* Add Windows build, store release binaries
- Remove tests, redundant with coverage
- *(artifacts)* Simplify uploaded artifacts
+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-dev" version = "1.0.1-dev"
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 %}\
+47 -1
View File
@@ -24,6 +24,16 @@ 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
```
No explicit revision is simply the equivalent of `jj-cz @`, like
`jj desc`.
## Requirements ## Requirements
- A Jujutsu repository - A Jujutsu repository
@@ -41,8 +51,44 @@ 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 .
+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;
};
} }
); );
} }
+16
View File
@@ -0,0 +1,16 @@
{
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.*
'';
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;
}
+4 -8
View File
@@ -1,13 +1,8 @@
{ {
inputs,
pkgs, pkgs,
rustVersion, rustVersion,
...
}: }:
inputs.devenv.lib.mkShell { pkgs.mkShell {
inherit inputs pkgs;
modules = [
{
packages = with pkgs; [ packages = with pkgs; [
(rustVersion.override { (rustVersion.override {
extensions = [ extensions = [
@@ -25,7 +20,8 @@ inputs.devenv.lib.mkShell {
git-cliff git-cliff
just just
typos typos
];
} # for CI
jq
]; ];
} }
+17 -2
View File
@@ -7,11 +7,26 @@ 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>,
}
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()
}
}
}
+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;
+3 -3
View File
@@ -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 },
@@ -76,7 +76,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()
} }
@@ -932,7 +932,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,
+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
}
} }
} }
+40 -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,17 @@ 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 },
} }
impl From<ScopeError> for Error { impl From<ScopeError> for Error {
@@ -46,7 +52,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(),
}
}
}
+244 -77
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()
.get_wc_commit_id(WorkspaceName::DEFAULT)
.ok_or_else(|| Error::JjOperation {
context: "No working copy commit found".to_string(),
})?
.clone();
let wc_commit =
tx.repo()
.store() .store()
.get_commit(&wc_commit_id) .get_commit(&commit_id)
.map_err(|e| Error::JjOperation { .map_err(|e| Error::JjOperation {
context: e.to_string(), 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,28 @@ 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())
}
} }
#[cfg(test)] #[cfg(test)]
@@ -191,7 +278,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 +294,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 +313,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 +324,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 +336,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 +355,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 +374,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 +393,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 +406,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 +430,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 +442,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 +451,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 {
+22 -8
View File
@@ -15,6 +15,10 @@ pub struct MockJjExecutor {
is_repo_response: Result<bool, Error>, is_repo_response: Result<bool, Error>,
/// Response to return from describe() /// Response to return from describe()
describe_response: Result<(), Error>, describe_response: Result<(), Error>,
/// Track described revsets
described_revsets: Mutex<Vec<String>>,
/// Track response to return from get_description()
get_description_response: Result<String, Error>,
/// Track calls to is_repository() /// Track calls to is_repository()
is_repo_called: AtomicBool, is_repo_called: AtomicBool,
/// Track calls to describe() with the message passed /// Track calls to describe() with the message passed
@@ -26,6 +30,8 @@ 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()),
} }
@@ -73,7 +79,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 +93,10 @@ 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()
}
} }
#[cfg(test)] #[cfg(test)]
@@ -130,7 +144,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 +155,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 +167,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 +178,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 +222,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 +232,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);
} }
+7 -1
View File
@@ -14,7 +14,13 @@ 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>;
} }
#[cfg(test)] #[cfg(test)]
-1
View File
@@ -1,4 +1,3 @@
mod cli;
mod commit; mod commit;
mod error; mod error;
mod jj; mod jj;
+14 -16
View File
@@ -21,7 +21,9 @@ 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,
} }
} }
@@ -33,35 +35,29 @@ fn is_interactive_terminal() -> bool {
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
// Parse CLI arguments; --help and --version are handled automatically by clap let cli = cli::Cli::parse();
cli::Cli::parse();
if !is_interactive_terminal() { if !is_interactive_terminal() {
eprintln!("❌ Error: jj-cz requires an interactive terminal (TTY)"); eprintln!("❌ Error: jj-cz requires an interactive terminal (TTY)");
eprintln!(" This tool cannot be used in non-interactive mode or when piping input."); eprintln!(" This tool cannot be used in non-interactive mode or when piping input.");
eprintln!(" Use --help for usage information."); eprintln!(" Use --help for usage information.");
process::exit(EXIT_ERROR); process::exit(EXIT_ERROR);
} }
let executor = match JjLib::new().await {
// Create the jj executor
let executor = match JjLib::new() {
Ok(e) => e, Ok(e) => e,
Err(e) => { Err(e) => {
eprintln!("❌ Error: {}", e); eprintln!("❌ Error: {}", e);
process::exit(EXIT_ERROR); 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 {
Ok(()) => {
println!("✅ Commit message applied successfully!");
process::exit(EXIT_SUCCESS);
} }
fn handle_result(result: Result<(), Error>) {
match result {
Ok(()) => println!("✅ Commit message applied successfully!"),
Err(Error::Cancelled) => { Err(Error::Cancelled) => {
println!("🟡 Operation cancelled by user."); println!("🟡 Operation cancelled by user.");
process::exit(EXIT_CANCELLED); process::exit(EXIT_CANCELLED);
@@ -72,3 +68,5 @@ async fn main() {
} }
} }
} }
process::exit(EXIT_SUCCESS);
}
+13 -57
View File
@@ -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>;
@@ -66,67 +66,32 @@ 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
Scope::parse(answer_str.trim()).map_err(|e| Error::InvalidScope(e.to_string()))
} }
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 +107,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 +145,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 +158,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 +173,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 +269,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("");
+62 -72
View File
@@ -54,23 +54,22 @@ 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 body = self.body_input()?;
match self match self.preview_and_confirm(commit_type, scope, description, breaking_change, body) {
.preview_and_confirm(commit_type, scope, description, breaking_change, body)
.await
{
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 +85,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 +93,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 +101,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 +109,12 @@ 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 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,7 +122,7 @@ 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,
@@ -208,7 +207,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 +217,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 +272,8 @@ mod tests {
let description = Description::parse("test description").unwrap(); let description = Description::parse("test description").unwrap();
let breaking_change = BreakingChange::No; let breaking_change = BreakingChange::No;
let body = Body::default(); let body = Body::default();
let result = workflow let result =
.preview_and_confirm(commit_type, scope, description, breaking_change, body) workflow.preview_and_confirm(commit_type, scope, description, breaking_change, body);
.await;
assert!(result.is_ok()); assert!(result.is_ok());
} }
@@ -289,7 +287,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();
@@ -327,7 +325,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 +336,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));
@@ -357,7 +355,7 @@ mod tests {
.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));
@@ -388,7 +386,7 @@ mod tests {
// 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 +423,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 +442,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)
@@ -483,7 +481,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);
} }
} }
@@ -507,7 +505,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());
} }
@@ -525,7 +523,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 +550,19 @@ 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(),
) );
.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 +576,20 @@ 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(),
) );
.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();
@@ -629,7 +623,7 @@ mod tests {
.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 +649,19 @@ 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."),
) );
.await;
assert!(result.is_ok(), "expected Ok, got: {:?}", result); assert!(result.is_ok(), "expected Ok, got: {:?}", result);
assert!( assert!(
@@ -684,21 +676,19 @@ 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."),
) );
.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();
@@ -728,7 +718,7 @@ mod tests {
.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(),
@@ -760,7 +750,7 @@ mod tests {
.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(),
-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)));
}
+3 -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;