4 Commits

Author SHA1 Message Date
520a54aae4 feat: implement breaking change input
Some checks failed
Publish Docker Images / coverage-and-sonar (push) Failing after 16m17s
2026-03-13 23:44:31 +01:00
d51760fc9f fix(message): use unicode char count for text width
All checks were successful
Publish Docker Images / coverage-and-sonar (push) Successful in 18m13s
2026-03-12 10:12:54 +01:00
8a61d6b2dd fix(prompt): prompt preview padding
All checks were successful
Publish Docker Images / coverage-and-sonar (push) Successful in 20m43s
2026-03-12 09:03:12 +01:00
0b00ec30a3 docs: add contributing guidelines 2026-03-09 22:37:19 +01:00
14 changed files with 122 additions and 834 deletions

View File

@@ -36,6 +36,14 @@ jobs:
run: |
nix develop --no-pure-eval --accept-flake-config --command just audit
- name: Build
run: |
nix develop --no-pure-eval --accept-flake-config --command just build-release
- name: Tests
run: |
nix develop --no-pure-eval --accept-flake-config --command just test
- name: Coverage
run: |
nix develop --no-pure-eval --accept-flake-config --command just coverage-ci
@@ -53,29 +61,23 @@ jobs:
- name: Build Linux release binary
run: nix build --no-pure-eval --accept-flake-config
- name: Prepare Linux binary
run: |
mkdir dist-linux
cp result/bin/jj-cz dist-linux/
cp LICENSE.*.md dist-linux/
- name: Package Linux binary
run: zip jj-cz-linux-x86_64.zip result/bin/jj-cz LICENSE.GPL.md LICENSE.MIT.md
- name: Upload Linux artifact
uses: actions/upload-artifact@v3
with:
name: jj-cz-x86_64-unknown-linux-gnu
path: dist-linux/*
name: jj-cz-linux-x86_64
path: jj-cz-linux-x86_64.zip
- 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: Package Windows binary
run: zip jj-cz-windows-x86_64.zip result/bin/jj-cz.exe LICENSE.GPL.md LICENSE.MIT.md
- name: Upload Windows artifact
uses: actions/upload-artifact@v3
with:
name: jj-cz-x86_64-pc-windows-gnu
path: dist-windows/*
name: jj-cz-windows-x86_64
path: jj-cz-windows-x86_64.zip

View File

@@ -1 +0,0 @@
AGENTS.md

1
CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
IMPORTANT: Ensure youve thoroughly reviewed the [AGENTS.md](/AGENTS.md) file before beginning any work.

2
Cargo.lock generated
View File

@@ -1571,7 +1571,6 @@ dependencies = [
"crossterm",
"dyn-clone",
"fuzzy-matcher",
"tempfile",
"unicode-segmentation",
"unicode-width",
]
@@ -1665,7 +1664,6 @@ dependencies = [
"textwrap",
"thiserror",
"tokio",
"unicode-width",
]
[[package]]

View File

@@ -25,13 +25,12 @@ async-trait = "0.1.89"
etcetera = "0.11.0"
clap = { version = "4.5.57", features = ["derive"] }
git-conventional = "0.12.9"
inquire = { version = "0.9.2", features = ["editor"] }
inquire = "0.9.2"
jj-lib = "0.39.0"
lazy-regex = { version = "3.5.1", features = ["lite"] }
thiserror = "2.0.18"
tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] }
textwrap = "0.16.2"
unicode-width = "0.2.2"
[dev-dependencies]
assert_cmd = "2.1.2"

View File

@@ -1,185 +0,0 @@
#[derive(Debug, Default, Clone, PartialEq, Eq)]
#[repr(transparent)]
pub struct Body(Option<String>);
impl<T: ToString> From<T> for Body {
fn from(value: T) -> Self {
let value = value.to_string();
let lines: Vec<&str> = value
.trim_end()
.lines()
.map(|line| line.trim_end())
.skip_while(|line| line.is_empty())
.collect();
match lines.join("\n").as_str() {
"" => Self::default(),
value => Self(Some(value.into())),
}
}
}
impl Body {
pub fn format(&self) -> String {
match &self.0 {
None => String::new(),
Some(value) if value.trim().is_empty() => String::new(),
Some(body) => format!("\n{body}\n"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Default produces Body(None) — no body
#[test]
fn default_produces_none() {
assert_eq!(Body::default(), Body(None));
}
/// Empty string produces Body(None)
#[test]
fn from_empty_string_produces_none() {
assert_eq!(Body::from(""), Body(None));
}
/// Whitespace-only string produces Body(None)
#[test]
fn from_whitespace_only_produces_none() {
assert_eq!(Body::from(" "), Body(None));
}
/// Tabs and newlines only produce Body(None)
#[test]
fn from_tab_and_newline_only_produces_none() {
assert_eq!(Body::from("\t\n "), Body(None));
}
/// A single newline (typical empty-editor save) produces Body(None)
#[test]
fn from_single_newline_produces_none() {
assert_eq!(Body::from("\n"), Body(None));
}
/// Non-empty string produces Body(Some(...)) with content preserved
#[test]
fn from_non_empty_string_produces_some() {
assert_eq!(
Body::from("some body text"),
Body(Some("some body text".to_string())),
);
}
/// Leading and internal whitespace is preserved — users may write
/// indented lists, ASCII art, file trees, etc.
#[test]
fn from_preserves_leading_whitespace() {
assert_eq!(
Body::from(" content "),
Body(Some(" content".to_string())),
);
}
/// Leading whitespace on individual lines is preserved
#[test]
fn from_preserves_per_line_leading_whitespace() {
let input = "- item one\n - nested item\n - deeply nested";
assert_eq!(Body::from(input), Body(Some(input.to_string())),);
}
/// Trailing newline (typical editor output) is stripped
#[test]
fn from_trims_trailing_newline() {
assert_eq!(
Body::from("editor content\n"),
Body(Some("editor content".to_string())),
);
}
/// Leading blank lines (e.g. from editor artefacts after JJ: comment
/// stripping) are dropped
#[test]
fn from_drops_leading_blank_lines() {
assert_eq!(Body::from("\n\ncontent"), Body(Some("content".to_string())),);
}
/// Windows-style CRLF line endings are normalised to LF
#[test]
fn from_normalises_crlf_to_lf() {
assert_eq!(
Body::from("line one\r\nline two"),
Body(Some("line one\nline two".to_string())),
);
}
/// Internal newlines are preserved for multi-line bodies
#[test]
fn from_preserves_internal_newlines() {
assert_eq!(
Body::from("line one\nline two"),
Body(Some("line one\nline two".to_string())),
);
}
/// Into<Body> conversion works via `.into()`
#[test]
fn into_conversion_works() {
let body: Body = "content".into();
assert_eq!(body, Body(Some("content".to_string())));
}
/// Clone produces a value equal to the original
#[test]
fn clone_produces_equal_value() {
let body = Body::from("content");
assert_eq!(body.clone(), body);
}
/// Two bodies constructed from the same string are equal
#[test]
fn equality_same_content() {
assert_eq!(Body::from("same"), Body::from("same"));
}
/// Bodies with different content are not equal
#[test]
fn inequality_different_content() {
assert_ne!(Body::from("first"), Body::from("second"));
}
/// None body is not equal to a body with content
#[test]
fn inequality_none_vs_some() {
assert_ne!(Body::default(), Body::from("content"));
}
/// Debug output is available and mentions Body
#[test]
fn debug_output_is_available() {
let body = Body::from("test");
assert!(format!("{:?}", body).contains("Body"));
}
/// format() on a None body returns an empty string
#[test]
fn format_none_returns_empty_string() {
assert_eq!(Body::default().format(), "");
}
/// format() on a Some body returns "\ncontent\n"
/// (leading \n creates the blank line after the commit header;
/// trailing \n creates the blank line before the footer)
#[test]
fn format_some_returns_newline_wrapped_content() {
let body = Body::from("some body text");
assert_eq!(body.format(), "\nsome body text\n");
}
/// format() preserves internal newlines in multi-line bodies
#[test]
fn format_some_multiline_preserves_content() {
let body = Body::from("line one\nline two");
assert_eq!(body.format(), "\nline one\nline two\n");
}
}

View File

@@ -2,7 +2,10 @@ pub trait Footer {
fn prefix(&self) -> &str;
fn note(&self) -> &str;
fn as_footer(&self) -> String {
fn as_footer(&self) -> String
where
Self: std::fmt::Debug,
{
let default = format!("{}: {}", self.prefix(), self.note());
if default.chars().count() > 72 {
textwrap::wrap(&default, 71).join("\n ")

View File

@@ -1,4 +1,4 @@
use super::{Body, BreakingChange, CommitType, Description, Scope};
use super::{BreakingChange, CommitType, Description, Scope};
use thiserror::Error;
/// Errors that can occur when creating a ConventionalCommit
@@ -22,7 +22,6 @@ pub struct ConventionalCommit {
scope: Scope,
description: Description,
breaking_change: BreakingChange,
body: Body,
}
impl ConventionalCommit {
@@ -43,14 +42,12 @@ impl ConventionalCommit {
scope: Scope,
description: Description,
breaking_change: BreakingChange,
body: Body,
) -> Result<Self, CommitMessageError> {
let commit = Self {
commit_type,
scope,
description,
breaking_change,
body,
};
let len = commit.first_line_len();
if len > Self::FIRST_LINE_MAX_LENGTH {
@@ -91,7 +88,6 @@ impl ConventionalCommit {
&self.scope,
&self.description,
&self.breaking_change,
&self.body,
)
}
@@ -105,20 +101,33 @@ impl ConventionalCommit {
scope: &Scope,
description: &Description,
breaking_change: &BreakingChange,
body: &Body,
) -> String {
let scope = scope.header_segment();
let breaking_change_header = breaking_change.header_segment();
let breaking_change_footer = breaking_change.as_footer();
format!(
r#"{commit_type}{scope}{breaking_change_header}: {description}
{}
{breaking_change_footer}"#,
body.format()
)
.trim()
.to_string()
}
/// Returns the commit type
pub fn commit_type(&self) -> CommitType {
self.commit_type
}
/// Returns a reference to the scope
pub fn scope(&self) -> &Scope {
&self.scope
}
/// Returns a reference to the description
pub fn description(&self) -> &Description {
&self.description
}
}
impl std::fmt::Display for ConventionalCommit {
@@ -148,13 +157,7 @@ mod tests {
description: Description,
breaking_change: BreakingChange,
) -> ConventionalCommit {
ConventionalCommit::new(
commit_type,
scope,
description,
breaking_change,
Body::default(),
)
ConventionalCommit::new(commit_type, scope, description, breaking_change)
.expect("test commit should have valid line length")
}
@@ -167,9 +170,9 @@ mod tests {
test_description("add new feature"),
BreakingChange::No,
);
assert_eq!(commit.commit_type, CommitType::Feat);
assert_eq!(commit.scope.as_str(), "cli");
assert_eq!(commit.description.as_str(), "add new feature");
assert_eq!(commit.commit_type(), CommitType::Feat);
assert_eq!(commit.scope().as_str(), "cli");
assert_eq!(commit.description().as_str(), "add new feature");
}
/// Test that ConventionalCommit::new() works with empty scope
@@ -181,9 +184,9 @@ mod tests {
test_description("fix critical bug"),
BreakingChange::No,
);
assert_eq!(commit.commit_type, CommitType::Fix);
assert!(commit.scope.is_empty());
assert_eq!(commit.description.as_str(), "fix critical bug");
assert_eq!(commit.commit_type(), CommitType::Fix);
assert!(commit.scope().is_empty());
assert_eq!(commit.description().as_str(), "fix critical bug");
}
/// Test that format() produces "type(scope): description" when scope is non-empty
@@ -402,6 +405,56 @@ mod tests {
}
}
/// Test commit_type() returns the correct type
#[test]
fn commit_type_accessor_returns_correct_type() {
for commit_type in CommitType::all() {
let commit = test_commit(
*commit_type,
Scope::empty(),
test_description("test"),
BreakingChange::No,
);
assert_eq!(commit.commit_type(), *commit_type);
}
}
/// Test scope() returns reference to scope
#[test]
fn scope_accessor_returns_reference() {
let commit = test_commit(
CommitType::Feat,
test_scope("auth"),
test_description("add feature"),
BreakingChange::No,
);
assert_eq!(commit.scope().as_str(), "auth");
}
/// Test scope() returns reference to empty scope
#[test]
fn scope_accessor_returns_empty_scope() {
let commit = test_commit(
CommitType::Feat,
Scope::empty(),
test_description("add feature"),
BreakingChange::No,
);
assert!(commit.scope().is_empty());
}
/// Test description() returns reference to description
#[test]
fn description_accessor_returns_reference() {
let commit = test_commit(
CommitType::Feat,
Scope::empty(),
test_description("add new authentication flow"),
BreakingChange::No,
);
assert_eq!(commit.description().as_str(), "add new authentication flow");
}
/// Test Clone trait
#[test]
fn conventional_commit_is_cloneable() {
@@ -636,7 +689,6 @@ mod tests {
Scope::parse(&scope_20).unwrap(),
Description::parse(&desc_44).unwrap(),
BreakingChange::No,
Body::default(),
);
assert!(result.is_ok());
let commit = result.unwrap();
@@ -666,7 +718,6 @@ mod tests {
Scope::parse(&scope_30).unwrap(),
Description::parse(&desc_31).unwrap(),
BreakingChange::No,
Body::default(),
);
assert!(result.is_err());
assert_eq!(
@@ -690,7 +741,6 @@ mod tests {
Scope::parse(&scope_30).unwrap(),
Description::parse(&desc_40).unwrap(),
BreakingChange::No,
Body::default(),
);
assert!(result.is_err());
assert_eq!(
@@ -710,7 +760,6 @@ mod tests {
Scope::empty(),
test_description("quick fix"),
BreakingChange::No,
Body::default(),
);
assert!(result.is_ok());
}
@@ -723,7 +772,6 @@ mod tests {
test_scope("cli"),
test_description("add feature"),
BreakingChange::No,
Body::default(),
);
assert!(result.is_ok());
}
@@ -749,7 +797,6 @@ mod tests {
Scope::empty(),
test_description("test"),
BreakingChange::No,
Body::default(),
);
// Just verify it's a Result by using is_ok()
assert!(result.is_ok());
@@ -775,13 +822,7 @@ mod tests {
None => Scope::empty(),
};
let desc = Description::parse(*desc_str).unwrap();
let commit = ConventionalCommit::new(
*commit_type,
scope,
desc,
BreakingChange::No,
Body::default(),
);
let commit = ConventionalCommit::new(*commit_type, scope, desc, BreakingChange::No);
// new() itself calls git_conventional::Commit::parse internally, so
// if this is Ok, SC-002 is satisfied for this case.
assert!(
@@ -917,7 +958,6 @@ mod tests {
Scope::parse(&scope_20).unwrap(),
Description::parse(&desc_44).unwrap(),
BreakingChange::Yes,
Body::default(),
);
assert!(result.is_err());
assert_eq!(
@@ -939,7 +979,6 @@ mod tests {
Scope::empty(),
test_description("quick fix"),
long_note.into(),
Body::default(),
);
assert!(result.is_ok());
}
@@ -954,11 +993,10 @@ mod tests {
BreakingChange::No,
);
let preview = ConventionalCommit::format_preview(
commit.commit_type,
&commit.scope,
&commit.description,
commit.commit_type(),
commit.scope(),
commit.description(),
&BreakingChange::No,
&Body::default(),
);
assert_eq!(preview, commit.format());
}
@@ -971,7 +1009,6 @@ mod tests {
&Scope::empty(),
&test_description("drop legacy API"),
&"removes legacy endpoint".into(),
&Body::default(),
);
assert_eq!(
preview,
@@ -987,7 +1024,6 @@ mod tests {
&test_scope("api"),
&test_description("drop Node 6"),
&"Node 6 is no longer supported".into(),
&Body::default(),
);
assert_eq!(
preview,
@@ -1079,109 +1115,4 @@ mod tests {
);
}
}
// --- Body tests (these will fail until format_preview() is updated to use body) ---
/// format() includes the body between the header and an empty footer
///
/// Case: body = Some, footer = "" → "type: desc\n\ncontent"
#[test]
fn format_with_body_no_breaking_change() {
let commit = ConventionalCommit::new(
CommitType::Feat,
Scope::empty(),
test_description("add feature"),
BreakingChange::No,
Body::from("This explains the change."),
)
.unwrap();
assert_eq!(
commit.format(),
"feat: add feature\n\nThis explains the change."
);
}
/// format() includes the body when a scope is also present
#[test]
fn format_with_body_and_scope() {
let commit = ConventionalCommit::new(
CommitType::Fix,
test_scope("api"),
test_description("handle null response"),
BreakingChange::No,
Body::from("Null responses were previously unhandled."),
)
.unwrap();
assert_eq!(
commit.format(),
"fix(api): handle null response\n\nNull responses were previously unhandled."
);
}
/// format() preserves internal newlines in a multi-paragraph body
#[test]
fn format_with_multiline_body() {
let commit = ConventionalCommit::new(
CommitType::Docs,
Scope::empty(),
test_description("update README"),
BreakingChange::No,
Body::from("First paragraph.\n\nSecond paragraph."),
)
.unwrap();
assert_eq!(
commit.format(),
"docs: update README\n\nFirst paragraph.\n\nSecond paragraph."
);
}
/// format() places the body between the header and the breaking-change footer
///
/// Case: body = Some, footer = Some → "type: desc\n\nbody\n\nBREAKING CHANGE: note"
#[test]
fn format_with_body_and_breaking_change_note() {
let commit = ConventionalCommit::new(
CommitType::Feat,
Scope::empty(),
test_description("drop legacy API"),
"removes legacy endpoint".into(),
Body::from("The endpoint was deprecated in v2."),
)
.unwrap();
assert_eq!(
commit.format(),
"feat!: drop legacy API\n\nThe endpoint was deprecated in v2.\n\nBREAKING CHANGE: removes legacy endpoint"
);
}
/// format_preview() includes the body in the output
#[test]
fn format_preview_with_body() {
let preview = ConventionalCommit::format_preview(
CommitType::Feat,
&Scope::empty(),
&test_description("add feature"),
&BreakingChange::No,
&Body::from("This explains the change."),
);
assert_eq!(preview, "feat: add feature\n\nThis explains the change.");
}
/// format_preview() with body and breaking-change note produces the full message
///
/// Case: body = Some, footer = Some → "type: desc\n\nbody\n\nBREAKING CHANGE: note"
#[test]
fn format_preview_with_body_and_breaking_change() {
let preview = ConventionalCommit::format_preview(
CommitType::Fix,
&Scope::empty(),
&test_description("drop old API"),
&"old API removed".into(),
&Body::from("Migration guide: see CHANGELOG."),
);
assert_eq!(
preview,
"fix!: drop old API\n\nMigration guide: see CHANGELOG.\n\nBREAKING CHANGE: old API removed"
);
}
}

View File

@@ -13,8 +13,5 @@ pub use scope::{Scope, ScopeError};
mod description;
pub use description::{Description, DescriptionError};
mod body;
pub use body::Body;
mod message;
pub use message::{CommitMessageError, ConventionalCommit};

View File

@@ -18,9 +18,9 @@ impl Scope {
if value.is_empty() {
return Ok(Self::empty());
}
if value.chars().count() > Self::MAX_LENGTH {
if value.len() > Self::MAX_LENGTH {
return Err(ScopeError::TooLong {
actual: value.chars().count(),
actual: value.len(),
max: Self::MAX_LENGTH,
});
}
@@ -458,6 +458,10 @@ mod tests {
assert!(msg.contains("30"));
}
// =========================================================================
// header_segment() / header_segment_len() tests
// =========================================================================
/// Test header_segment() returns empty string for empty scope
#[test]
fn header_segment_empty_scope_returns_empty_string() {
@@ -513,53 +517,4 @@ mod tests {
);
}
}
/// A scope whose byte count exceeds MAX_LENGTH but whose char
/// count does not must be rejected with InvalidCharacter, not
/// TooLong.
///
/// Before the fix the byte-based `.len()` check fired first,
/// producing a misleading "too long" error for a string that is
/// actually within the limit.
#[test]
fn length_limit_uses_char_count_not_byte_count() {
// "ñ" is 2 bytes in UTF-8; 16 × "ñ" = 16 chars, 32 bytes.
// char count 16 ≤ 30 → length check passes
// regex rejects "ñ" → should return InvalidCharacter, not TooLong
let input = "ñ".repeat(16);
assert_eq!(input.chars().count(), 16, "sanity: 16 chars");
assert_eq!(input.len(), 32, "sanity: 32 bytes");
let result = Scope::parse(&input);
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
ScopeError::InvalidCharacter('ñ'),
"expected InvalidCharacter('ñ') for a 16-char / 32-byte input, not TooLong",
);
}
/// The actual length reported in TooLong must be the char count,
/// not the byte count.
///
/// "a".repeat(30) + "é" is 31 chars and 32 bytes. The length
/// check should fire on char count (31 > 30) and report actual =
/// 31.
#[test]
fn too_long_error_actual_reports_char_count_not_byte_count() {
// 30 ASCII 'a' + 1 two-byte 'é' = 31 chars, 32 bytes
let input = "a".repeat(30) + "é";
assert_eq!(input.chars().count(), 31, "sanity: 31 chars");
assert_eq!(input.len(), 32, "sanity: 32 bytes");
let result = Scope::parse(&input);
assert_eq!(
result.unwrap_err(),
ScopeError::TooLong {
actual: 31,
max: 30
},
"actual should be the char count (31), not the byte count (32)",
);
}
}

View File

@@ -6,7 +6,7 @@ mod prompts;
pub use crate::{
commit::types::{
Body, BreakingChange, CommitMessageError, CommitType, ConventionalCommit, Description,
BreakingChange, CommitMessageError, CommitType, ConventionalCommit, Description,
DescriptionError, Scope, ScopeError,
},
error::Error,

View File

@@ -8,7 +8,7 @@
use std::sync::{Arc, Mutex};
use crate::{
commit::types::{Body, BreakingChange, CommitType, Description, Scope},
commit::types::{BreakingChange, CommitType, Description, Scope},
error::Error,
prompts::prompter::Prompter,
};
@@ -20,7 +20,6 @@ enum MockResponse {
Scope(Scope),
Description(Description),
BreakingChange(BreakingChange),
Body(Body),
Confirm(bool),
Error(Error),
}
@@ -81,15 +80,6 @@ impl MockPrompts {
self
}
/// Configure the mock to return a specific body response
pub fn with_body(self, body: Body) -> Self {
self.responses
.lock()
.unwrap()
.push(MockResponse::Body(body));
self
}
/// Configure the mock to return a specific confirmation response
pub fn with_confirm(self, confirm: bool) -> Self {
self.responses
@@ -140,14 +130,6 @@ impl MockPrompts {
.contains(&"input_breaking_change".to_string())
}
/// Check if input_body was called
pub fn was_body_called(&self) -> bool {
self.prompts_called
.lock()
.unwrap()
.contains(&"input_body".to_string())
}
/// Check if confirm_apply was called
pub fn was_confirm_called(&self) -> bool {
self.prompts_called
@@ -215,19 +197,6 @@ impl Prompter for MockPrompts {
}
}
fn input_body(&self) -> Result<Body, Error> {
self.prompts_called
.lock()
.unwrap()
.push("input_body".to_string());
match self.responses.lock().unwrap().remove(0) {
MockResponse::Body(body) => Ok(body),
MockResponse::Error(e) => Err(e),
_ => panic!("MockPrompts: Expected Body response, got different type"),
}
}
fn confirm_apply(&self, _message: &str) -> Result<bool, Error> {
self.prompts_called
.lock()

View File

@@ -6,10 +6,9 @@
//! in production while accepting mock implementations in tests.
use inquire::{Confirm, Text};
use unicode_width::UnicodeWidthStr;
use crate::{
commit::types::{Body, BreakingChange, CommitType, Description, Scope},
commit::types::{BreakingChange, CommitType, Description, Scope},
error::Error,
};
@@ -30,9 +29,6 @@ pub trait Prompter: Send + Sync {
/// Prompt the user for breaking change
fn input_breaking_change(&self) -> Result<BreakingChange, Error>;
/// Prompt the user to optionally add a free-form body via an external editor
fn input_body(&self) -> Result<Body, Error>;
/// Prompt the user to confirm applying the commit message
fn confirm_apply(&self, message: &str) -> Result<bool, Error>;
@@ -44,19 +40,14 @@ pub trait Prompter: Send + Sync {
}
fn format_message_box(message: &str) -> String {
let preview_width = message
.split('\n')
.map(|line| line.width())
.max()
.unwrap_or(0)
.max(72);
let preview_width = 72 + 2; // max width + space padding
let mut lines: Vec<String> = Vec::new();
lines.push(format!("{}", "".repeat(preview_width + 2)));
for line in message.split('\n') {
let padding = preview_width.saturating_sub(line.width());
lines.push(format!("{}", "".repeat(preview_width)));
for line in message.split("\n") {
let padding = 72_usize.saturating_sub(line.chars().count());
lines.push(format!("{line}{:padding$}", ""));
}
lines.push(format!("{}", "".repeat(preview_width + 2)));
lines.push(format!("{}", "".repeat(preview_width)));
lines.join("\n")
}
@@ -179,38 +170,6 @@ impl Prompter for RealPrompts {
Ok(trimmed.into())
}
fn input_body(&self) -> Result<Body, Error> {
use inquire::Editor;
let wants_body = Confirm::new("Add a body?")
.with_default(false)
.prompt()
.map_err(|_| Error::Cancelled)?;
if !wants_body {
return Ok(Body::default());
}
let template = "\
JJ: Body (optional). Markdown is supported.\n\
JJ: Wrap prose lines at 72 characters where possible.\n\
JJ: Lines starting with \"JJ:\" will be removed.\n";
let raw = Editor::new("Body:")
.with_predefined_text(template)
.with_file_extension(".md")
.prompt()
.map_err(|_| Error::Cancelled)?;
let stripped: String = raw
.lines()
.filter(|line| !line.starts_with("JJ:"))
.collect::<Vec<_>>()
.join("\n");
Ok(Body::from(stripped))
}
fn confirm_apply(&self, message: &str) -> Result<bool, Error> {
use inquire::Confirm;
@@ -311,91 +270,4 @@ mod tests {
let expected = format!("{line_72}");
assert_eq!(lines[1], expected);
}
/// 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
#[test]
fn format_message_box_single_cjk_char() {
let result = format_message_box("");
let lines: Vec<&str> = result.split('\n').collect();
let expected = format!("│ 字{:70}", "");
assert_eq!(lines[1], expected);
}
/// A single emoji (display width 2) is padded as if it occupies 2 columns
#[test]
fn format_message_box_single_emoji() {
let result = format_message_box("🦀");
let lines: Vec<&str> = result.split('\n').collect();
let expected = format!("│ 🦀{:70}", "");
assert_eq!(lines[1], expected);
}
/// Mixed ASCII and CJK: padding accounts for the display width of the whole line
///
/// "feat: " = 6 display cols, "漢字" = 4 display cols → total 10, padding = 62
#[test]
fn format_message_box_mixed_ascii_and_cjk() {
let result = format_message_box("feat: 漢字");
let lines: Vec<&str> = result.split('\n').collect();
let expected = format!("│ feat: 漢字{:62}", "");
assert_eq!(lines[1], expected);
}
/// When a line exceeds 72 display columns the border expands to fit (width + 2 dashes)
#[test]
fn format_message_box_border_expands_beyond_72() {
let line_73 = "a".repeat(73);
let result = format_message_box(&line_73);
let lines: Vec<&str> = result.split('\n').collect();
let dashes = "".repeat(75); // 73 + 2
assert_eq!(lines[0], format!("{dashes}"));
assert_eq!(lines[lines.len() - 1], format!("{dashes}"));
}
/// A line that sets the box width gets zero right-hand padding
#[test]
fn format_message_box_widest_line_has_no_padding() {
let line_73 = "a".repeat(73);
let result = format_message_box(&line_73);
let lines: Vec<&str> = result.split('\n').collect();
assert_eq!(lines[1], format!("{line_73}"));
}
/// In a multi-line message, shorter lines are padded out to match the widest line
#[test]
fn format_message_box_shorter_lines_padded_to_widest() {
let long_line = "a".repeat(80);
let result = format_message_box(&format!("{long_line}\nshort"));
let lines: Vec<&str> = result.split('\n').collect();
assert_eq!(lines[1], format!("{long_line}"));
assert_eq!(lines[2], format!("│ short{:75}", "")); // 80 - 5 = 75
}
/// All rows have equal char count when the box expands beyond 72
#[test]
fn format_message_box_all_rows_same_width_when_expanded() {
let long_line = "a".repeat(80);
let result = format_message_box(&format!("{long_line}\nshort"));
let widths: Vec<usize> = result.split('\n').map(|l| l.chars().count()).collect();
let expected = widths[0];
assert!(
widths.iter().all(|&w| w == expected),
"rows have differing widths: {:?}",
widths
);
}
/// Wide characters can also trigger box expansion beyond 72 columns
///
/// 37 CJK characters × 2 display columns = 74 display columns → border uses 76 dashes
#[test]
fn format_message_box_wide_chars_expand_box() {
let wide_line = "".repeat(37); // 74 display cols
let result = format_message_box(&wide_line);
let lines: Vec<&str> = result.split('\n').collect();
let dashes = "".repeat(76); // 74 + 2
assert_eq!(lines[0], format!("{dashes}"));
assert_eq!(lines[1], format!("{wide_line}")); // no padding
}
}

View File

@@ -5,8 +5,7 @@
use crate::{
commit::types::{
Body, BreakingChange, CommitMessageError, CommitType, ConventionalCommit, Description,
Scope,
BreakingChange, CommitMessageError, CommitType, ConventionalCommit, Description, Scope,
},
error::Error,
jj::JjExecutor,
@@ -63,9 +62,8 @@ impl<J: JjExecutor, P: Prompter> CommitWorkflow<J, P> {
let scope = self.scope_input().await?;
let description = self.description_input().await?;
let breaking_change = self.breaking_change_input().await?;
let body = self.body_input().await?;
match self
.preview_and_confirm(commit_type, scope, description, breaking_change, body)
.preview_and_confirm(commit_type, scope, description, breaking_change)
.await
{
Ok(conventional_commit) => {
@@ -114,11 +112,6 @@ impl<J: JjExecutor, P: Prompter> CommitWorkflow<J, P> {
self.prompts.input_breaking_change()
}
/// Prompt user to optionally add a free-form body via an external editor
async fn body_input(&self) -> Result<Body, Error> {
self.prompts.input_body()
}
/// Preview the formatted conventional commit message and get user confirmation
///
/// This method also validates that the complete first line
@@ -129,24 +122,17 @@ impl<J: JjExecutor, P: Prompter> CommitWorkflow<J, P> {
scope: Scope,
description: Description,
breaking_change: BreakingChange,
body: Body,
) -> Result<ConventionalCommit, Error> {
// Format the message for preview
let message = ConventionalCommit::format_preview(
commit_type,
&scope,
&description,
&breaking_change,
&body,
);
let message =
ConventionalCommit::format_preview(commit_type, &scope, &description, &breaking_change);
// Try to build the conventional commit (this validates the 72-char limit)
let conventional_commit: ConventionalCommit = match ConventionalCommit::new(
commit_type,
scope.clone(),
description.clone(),
breaking_change,
body,
crate::commit::types::BreakingChange::No,
) {
Ok(cc) => cc,
Err(CommitMessageError::FirstLineTooLong { actual, max }) => {
@@ -272,9 +258,8 @@ mod tests {
let scope = Scope::empty();
let description = Description::parse("test description").unwrap();
let breaking_change = BreakingChange::No;
let body = Body::default();
let result = workflow
.preview_and_confirm(commit_type, scope, description, breaking_change, body)
.preview_and_confirm(commit_type, scope, description, breaking_change)
.await;
assert!(result.is_ok());
}
@@ -320,7 +305,6 @@ mod tests {
.with_scope(Scope::empty())
.with_description(Description::parse("add new feature").unwrap())
.with_breaking_change(BreakingChange::Yes)
.with_body(Body::default())
.with_confirm(true);
// Create workflow with both mocks
@@ -353,7 +337,6 @@ mod tests {
.with_scope(Scope::parse("api").unwrap())
.with_description(Description::parse("fix bug").unwrap())
.with_breaking_change(BreakingChange::No)
.with_body(Body::default())
.with_confirm(false); // User cancels at confirmation
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
@@ -377,12 +360,10 @@ mod tests {
.with_scope(Scope::parse("very-long-scope-name").unwrap())
.with_description(Description::parse("a".repeat(45)).unwrap())
.with_breaking_change(BreakingChange::No)
.with_body(Body::default())
// Second iteration: short enough to succeed
.with_scope(Scope::empty())
.with_description(Description::parse("short description").unwrap())
.with_breaking_change(BreakingChange::No)
.with_body(Body::default())
.with_confirm(true);
// Clone before moving into workflow so we can inspect emitted messages after
@@ -476,7 +457,6 @@ mod tests {
.with_scope(Scope::empty())
.with_description(Description::parse("test").unwrap())
.with_breaking_change(BreakingChange::Yes)
.with_body(Body::default())
.with_confirm(true);
let workflow = CommitWorkflow::with_prompts(
@@ -499,7 +479,6 @@ mod tests {
.with_scope(Scope::empty())
.with_description(Description::parse("test").unwrap())
.with_breaking_change(BreakingChange::Yes)
.with_body(Body::default())
.with_confirm(true);
let workflow = CommitWorkflow::with_prompts(
@@ -517,7 +496,6 @@ mod tests {
.with_scope(Scope::parse("api").unwrap())
.with_description(Description::parse("test").unwrap())
.with_breaking_change(BreakingChange::No)
.with_body(Body::default())
.with_confirm(true);
let workflow = CommitWorkflow::with_prompts(
@@ -543,233 +521,4 @@ mod tests {
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
assert!(matches!(workflow, CommitWorkflow { .. }));
}
/// Preview_and_confirm must forward BreakingChange::Yes to
/// ConventionalCommit::new(), producing a commit whose string
/// contains '!'.
///
/// Before the fix the parameter was ignored and
/// BreakingChange::No was hard-coded, so a confirmed
/// breaking-change commit was silently applied without the '!'
/// marker.
#[tokio::test]
async fn preview_and_confirm_forwards_breaking_change_yes() {
let mock_executor = MockJjExecutor::new();
let mock_prompts = MockPrompts::new().with_confirm(true);
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result = workflow
.preview_and_confirm(
CommitType::Feat,
Scope::empty(),
Description::parse("remove old API").unwrap(),
BreakingChange::Yes,
Body::default(),
)
.await;
assert!(result.is_ok(), "expected Ok, got: {:?}", result);
let message = result.unwrap().to_string();
assert!(
message.contains("feat!:"),
"expected '!' marker in described message, got: {:?}",
message,
);
}
/// Preview_and_confirm must forward BreakingChange::WithNote,
/// producing a commit with both the '!' header marker and the
/// BREAKING CHANGE footer.
#[tokio::test]
async fn preview_and_confirm_forwards_breaking_change_with_note() {
let mock_executor = MockJjExecutor::new();
let mock_prompts = MockPrompts::new().with_confirm(true);
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let breaking_change: BreakingChange = "removes legacy endpoint".into();
let result = workflow
.preview_and_confirm(
CommitType::Feat,
Scope::empty(),
Description::parse("drop legacy API").unwrap(),
breaking_change,
Body::default(),
)
.await;
assert!(result.is_ok(), "expected Ok, got: {:?}", result);
let message = result.unwrap().to_string();
assert!(
message.contains("feat!:"),
"expected '!' header marker in message, got: {:?}",
message,
);
assert!(
message.contains("BREAKING CHANGE:"),
"expected BREAKING CHANGE footer in message, got: {:?}",
message,
);
}
/// The message passed to executor.describe() must include the '!'
/// marker when the user selects a breaking change.
///
/// This test exercises the full run() path and inspects what was
/// actually handed to the jj executor, which is the authoritative
/// check that the described commit is correct.
#[tokio::test]
async fn full_workflow_describes_commit_with_breaking_change_marker() {
let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
let mock_prompts = MockPrompts::new()
.with_commit_type(CommitType::Feat)
.with_scope(Scope::empty())
.with_description(Description::parse("remove old API").unwrap())
.with_breaking_change(BreakingChange::Yes)
.with_body(Body::default())
.with_confirm(true);
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result: Result<(), Error> = workflow.run().await;
assert!(
result.is_ok(),
"expected workflow to succeed, got: {:?}",
result
);
let messages = workflow.executor.describe_messages();
assert_eq!(messages.len(), 1, "expected exactly one describe() call");
assert!(
messages[0].contains("feat!:"),
"expected '!' marker in the described message, got: {:?}",
messages[0],
);
}
// --- Body tests ---
// preview_and_confirm() tests compile now but will fail until the Body::default()
// at line 138 of preview_and_confirm() is replaced with the `body` parameter.
// The full_workflow_* tests additionally require MockPrompts::with_body().
/// preview_and_confirm must forward the body to ConventionalCommit::new()
///
/// Currently the implementation passes Body::default() instead of the
/// received body, so this test will fail until that is fixed.
#[tokio::test]
async fn preview_and_confirm_forwards_body() {
let mock_executor = MockJjExecutor::new();
let mock_prompts = MockPrompts::new().with_confirm(true);
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result = workflow
.preview_and_confirm(
CommitType::Feat,
Scope::empty(),
Description::parse("add feature").unwrap(),
BreakingChange::No,
Body::from("This explains the change."),
)
.await;
assert!(result.is_ok(), "expected Ok, got: {:?}", result);
assert!(
result
.unwrap()
.to_string()
.contains("This explains the change."),
"body must appear in the commit message"
);
}
/// preview_and_confirm must forward the body even when a breaking change is present
///
/// Expected format: "type!: desc\n\nbody\n\nBREAKING CHANGE: note"
#[tokio::test]
async fn preview_and_confirm_forwards_body_with_breaking_change() {
let mock_executor = MockJjExecutor::new();
let mock_prompts = MockPrompts::new().with_confirm(true);
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result = workflow
.preview_and_confirm(
CommitType::Feat,
Scope::empty(),
Description::parse("drop legacy API").unwrap(),
"removes legacy endpoint".into(),
Body::from("The endpoint was deprecated in v2."),
)
.await;
assert!(result.is_ok(), "expected Ok, got: {:?}", result);
let message = result.unwrap().to_string();
assert!(
message.contains("The endpoint was deprecated in v2."),
"body must appear in the commit message, got: {message:?}"
);
assert!(
message.contains("BREAKING CHANGE: removes legacy endpoint"),
"breaking change footer must still be present, got: {message:?}"
);
}
/// The full run() workflow must collect a body and include it in the
/// described commit.
///
/// Requires MockPrompts::with_body() and run() to call body_input().
#[tokio::test]
async fn full_workflow_describes_commit_with_body() {
let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
let mock_prompts = MockPrompts::new()
.with_commit_type(CommitType::Feat)
.with_scope(Scope::empty())
.with_description(Description::parse("add feature").unwrap())
.with_breaking_change(BreakingChange::No)
.with_body(Body::from("This explains the change."))
.with_confirm(true);
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result: Result<(), Error> = workflow.run().await;
assert!(
result.is_ok(),
"expected workflow to succeed, got: {:?}",
result
);
let messages = workflow.executor.describe_messages();
assert_eq!(messages.len(), 1, "expected exactly one describe() call");
assert!(
messages[0].contains("This explains the change."),
"body must appear in the described commit, got: {:?}",
messages[0]
);
}
/// run() must still work correctly when the user declines to add a body
///
/// Requires MockPrompts::with_body() returning Body::default().
#[tokio::test]
async fn full_workflow_with_no_body_succeeds() {
let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
let mock_prompts = MockPrompts::new()
.with_commit_type(CommitType::Fix)
.with_scope(Scope::empty())
.with_description(Description::parse("fix crash").unwrap())
.with_breaking_change(BreakingChange::No)
.with_body(Body::default())
.with_confirm(true);
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result: Result<(), Error> = workflow.run().await;
assert!(
result.is_ok(),
"expected workflow to succeed, got: {:?}",
result
);
let messages = workflow.executor.describe_messages();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0], "fix: fix crash");
}
}

View File

@@ -1,6 +1,6 @@
use assert_fs::TempDir;
#[cfg(feature = "test-utils")]
use jj_cz::{Body, BreakingChange, CommitType, Description, MockPrompts, Scope};
use jj_cz::{BreakingChange, CommitType, Description, MockPrompts, Scope};
use jj_cz::{CommitWorkflow, Error, JjLib};
#[cfg(feature = "test-utils")]
use jj_lib::{config::StackedConfig, settings::UserSettings, workspace::Workspace};
@@ -28,7 +28,6 @@ async fn test_happy_path_integration() {
.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
@@ -88,7 +87,6 @@ async fn test_cancellation() {
.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);