Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ license = "Apache-2.0 OR MIT"
name = "rona"
readme = "README.md"
repository = "https://github.com/rona-rs/rona"
version = "2.28.0"
version = "2.29.0"

[[bin]]
doc = true
Expand Down
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,9 @@ The path is resolved relative to the `.rona.toml` file that declares it. Absolut

#### Unreferenced extra fields are skipped

When a config extends another, extra fields are merged by `name`: same-name fields are overridden by the child, and fields the child does not redefine are inherited from the base. Rona only prompts for an extra field that the active template actually references (as `{name}` or `{?name}`). If an inherited field is not referenced by your template, it is skipped with a `[NOTE]` line instead of asking you for a value that would be discarded. This applies independently to `rona branch` (checked against `branch_template`) and `rona -g -i` (checked against `commit_template`).
When a config extends another, extra fields are merged by `name`: same-name fields are overridden by the child, and fields the child does not redefine are inherited from the base. Rona only prompts for an extra field that the active template actually references (as `{name}` or `{?name}`). If an inherited field is not referenced by your template, it is skipped with a `[NOTE]` line instead of asking you for a value that would be discarded. This applies independently to `rona branch` (checked against `branch_template`) and `rona -g -i` (checked against `commit_template`). The same rule covers the built-in commit type: the "Select commit type" selector is only shown when `commit_template` references `{commit_type}`, in both interactive and editor mode.

Editor mode (`rona -g` without `-i`) renders `commit_template` too: the first line of the generated `commit_message.md` is the template with an empty `{message}`, so you type your message straight onto a line that already has your format. Editor mode never prompts for extra fields. A field that the template uses renders as empty, and Rona prints one `[NOTE]` line for it. Complete these fields yourself in the editor.

For example, given a base config that defines a `ticket` field and uses it in both templates:

Expand Down Expand Up @@ -1036,10 +1038,10 @@ rona -g [-i | --interactive] [-n | --no-commit-number]
**Features:**

- Creates `commit_message.md` and `.commitignore`
- Interactive commit type selection
- Interactive commit type selection (only when `commit_template` uses `{commit_type}`)
- Automatic file change tracking
- **Interactive mode:** Input commit message directly in terminal (`-i` flag)
- **Editor mode:** Opens in configured editor (default behavior)
- **Editor mode:** Opens in configured editor (default behavior), on a header rendered from `commit_template`
- **No commit number:** Omit commit number from message (`-n` flag)

**Options:**
Expand All @@ -1050,7 +1052,7 @@ rona -g [-i | --interactive] [-n | --no-commit-number]
**Examples:**

```bash
# Standard mode: Opens commit type selector, then editor
# Standard mode: Opens commit type selector (if the template needs it), then editor
rona -g

# Interactive mode: Input message directly in terminal
Expand All @@ -1066,7 +1068,7 @@ rona -g -i -n
**Interactive Mode Usage:**
When using the `-i` flag, Rona will:

1. Show the commit type selector (uses configured types or defaults: feat, fix, docs, test, chore)
1. Show the commit type selector, if `commit_template` uses `{commit_type}` (uses configured types or defaults: feat, fix, docs, test, chore)
2. Show prompts for any configured extra fields and the message, in the order defined by `field_order` (defaults to extra fields first, then message)
3. Generate a clean format using your template (or default)
4. Save directly to `commit_message.md` without file details
Expand Down
203 changes: 146 additions & 57 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -968,6 +968,33 @@ fn prompt_interactive_fields(
Ok((message, extra_values))
}

/// Returns whether `template` uses `name`, either as `{name}` or as a conditional block
/// `{?name}...{/name}`.
fn template_references(template: &str, name: &str) -> bool {
template.contains(&format!("{{{name}}}")) || template.contains(&format!("{{?{name}}}"))
}

/// Show the commit type selector and return the chosen type.
///
/// # Errors
/// * If the user cancels the prompt
fn select_commit_type(config: &Config) -> Result<&str> {
let commit_types_vec = config.project_config.commit_types.as_ref().map_or_else(
|| COMMIT_TYPES.to_vec(),
|v| v.iter().map(String::as_str).collect::<Vec<&str>>(),
);

let index = FuzzySelect::with_theme(&prompt_theme())
.with_prompt("Select commit type")
.items(&commit_types_vec)
.default(0)
.interact_opt()
.map_err(|_| RonaError::UserCancelled)?
.ok_or(RonaError::UserCancelled)?;

Ok(commit_types_vec[index])
}

/// The default commit-message template used when none is configured.
///
/// The conditional block `{?commit_number}...{/commit_number}` is only included when
Expand Down Expand Up @@ -996,38 +1023,30 @@ fn handle_generate(interactive: bool, no_commit_number: bool, config: &Config) -

create_needed_files()?;

let commit_type = {
let commit_types_vec = config.project_config.commit_types.as_ref().map_or_else(
|| COMMIT_TYPES.to_vec(),
|v| v.iter().map(String::as_str).collect::<Vec<&str>>(),
);
let commit_template = config
.project_config
.commit_template
.as_deref()
.unwrap_or(DEFAULT_COMMIT_TEMPLATE);

let index = FuzzySelect::with_theme(&prompt_theme())
.with_prompt("Select commit type")
.items(&commit_types_vec)
.default(0)
.interact_opt()
.map_err(|_| RonaError::UserCancelled)?
.ok_or(RonaError::UserCancelled)?;
commit_types_vec[index]
// The selector is only worth showing when the chosen type ends up in the message. Both modes
// render the same template, so the template alone decides.
let commit_type = if template_references(commit_template, "commit_type") {
Some(select_commit_type(config)?)
} else {
None
};

if interactive {
// Only prompt for extra fields referenced in the commit template. Fields inherited from
// an extended config (or otherwise configured) but unused by this template are skipped
// rather than prompted for a value that would be discarded.
let commit_template = config
.project_config
.commit_template
.as_deref()
.unwrap_or(DEFAULT_COMMIT_TEMPLATE);
let referenced_fields: Vec<ExtraField> = config
.project_config
.commit_extra_fields
.iter()
.filter(|f| {
let referenced = commit_template.contains(&format!("{{{}}}", f.name))
|| commit_template.contains(&format!("{{?{}}}", f.name));
let referenced = template_references(commit_template, &f.name);
if !referenced {
println!(
"[NOTE] Extra field '{}' is not referenced in the template; skipping.",
Expand All @@ -1054,34 +1073,57 @@ fn handle_generate(interactive: bool, no_commit_number: bool, config: &Config) -
config,
)?;
} else {
// In editor mode, generate the template file first, then open editor
generate_commit_message(commit_type, no_commit_number)?;
// Editor mode renders the same template with an empty message, so the file opens on a
// header that already matches the configured format and only the message is missing.
// Extra fields are never prompted for here, so they resolve to empty as well.
let blank_extra_values: HashMap<String, String> = config
.project_config
.commit_extra_fields
.iter()
.filter(|f| template_references(commit_template, &f.name))
.map(|f| {
println!(
"[NOTE] Editor mode leaves the extra field '{}' empty. Complete it in your editor.",
f.name
);
(f.name.clone(), String::new())
})
.collect();

let header = build_commit_message(
commit_type,
no_commit_number,
"",
&blank_extra_values,
config,
)?;

// In editor mode, generate the scaffold file first, then open the editor
generate_commit_message(&header)?;
handle_editor_mode(config)?;
}
Ok(())
}

/// Handle interactive mode for generate command
fn handle_interactive_mode(
commit_type: &str,
/// Render the configured commit template (or [`DEFAULT_COMMIT_TEMPLATE`]) for `message`.
///
/// `commit_type` is `None` when the template does not use `{commit_type}`, in which case no type
/// was ever selected and the variable resolves to an empty string. Editor mode passes an empty
/// `message`, which renders the header the user then completes in their editor.
///
/// When the template fails validation a warning is printed and the built-in
/// `[number] (type on branch) message` layout is used instead, keeping whichever parts are known.
///
/// # Errors
/// * If the current branch, commit count, or git author cannot be read
/// * If the template cannot be processed
fn build_commit_message(
commit_type: Option<&str>,
no_commit_number: bool,
message: &str,
extra_values: &HashMap<String, String>,
config: &Config,
) -> Result<()> {
use std::fs;

let project_root = get_top_level_path()?;
let commit_file_path = project_root.join(COMMIT_MESSAGE_FILE_PATH);

if message.trim().is_empty() {
println!(
"{} Empty message provided. Exiting.",
"WARNING:".yellow().bold()
);
return Ok(());
}

) -> Result<String> {
let branch_name = format_branch_name(&COMMIT_TYPES, &get_current_branch()?);
let commit_number = if no_commit_number {
None
Expand All @@ -1104,36 +1146,49 @@ fn handle_interactive_mode(
"WARNING:".yellow().bold()
);
println!("Using fallback format...");
let formatted_message = if no_commit_number {
format!("({} on {}) {}", commit_type, branch_name, message.trim())
} else {
format!(
"[{}] ({} on {}) {}",
commit_number.unwrap_or(0),
commit_type,
branch_name,
message.trim()
)
};
fs::write(&commit_file_path, &formatted_message)?;
println!("\n{} Commit message created!", "✓".green());
println!("Message: {formatted_message}");
return Ok(());
let number_prefix = commit_number.map_or_else(String::new, |number| format!("[{number}] "));
let type_prefix = commit_type.map_or_else(String::new, |commit_type| {
format!("({commit_type} on {branch_name}) ")
});
return Ok(format!("{number_prefix}{type_prefix}{}", message.trim()));
}

// Create template variables
let variables = TemplateVariables::new(
commit_number,
commit_type.to_string(),
commit_type.unwrap_or_default().to_string(),
branch_name,
message.trim().to_string(),
)?;

// Process template (extra_values are substituted alongside built-in variables)
let formatted_message = process_template(template, &variables, extra_values)?;
process_template(template, &variables, extra_values)
}

/// Handle interactive mode for generate command
fn handle_interactive_mode(
commit_type: Option<&str>,
no_commit_number: bool,
message: &str,
extra_values: &HashMap<String, String>,
config: &Config,
) -> Result<()> {
let project_root = get_top_level_path()?;
let commit_file_path = project_root.join(COMMIT_MESSAGE_FILE_PATH);

if message.trim().is_empty() {
println!(
"{} Empty message provided. Exiting.",
"WARNING:".yellow().bold()
);
return Ok(());
}

let formatted_message =
build_commit_message(commit_type, no_commit_number, message, extra_values, config)?;

// Write the formatted message to commit_message.md
fs::write(&commit_file_path, &formatted_message)?;
std::fs::write(&commit_file_path, &formatted_message)?;

println!("\n{} Commit message created!", "✓".green());
println!("Message: {formatted_message}");
Expand Down Expand Up @@ -2962,6 +3017,40 @@ mod cli_tests {
);
}

// === COMMIT TYPE SELECTOR TESTS ===

#[test]
fn test_template_references_plain_variable() {
assert!(template_references(
"({commit_type}) {message}",
"commit_type"
));
assert!(template_references("({commit_type}) {message}", "message"));
}

#[test]
fn test_template_references_conditional_block() {
let template = "{?commit_number}[{commit_number}] {/commit_number}{message}";
assert!(template_references(template, "commit_number"));
}

#[test]
fn test_template_references_ignores_unused_variables() {
assert!(!template_references("{message}", "commit_type"));
assert!(!template_references("{message}", "ticket"));
}

#[test]
fn test_template_references_requires_exact_name() {
// A longer name that merely contains the shorter one must not count as a reference.
assert!(!template_references("{commit_type_extra}", "commit_type"));
}

#[test]
fn test_default_template_references_commit_type() {
assert!(template_references(DEFAULT_COMMIT_TEMPLATE, "commit_type"));
}

// === SYNC COMMAND TESTS ===

#[test]
Expand Down
Loading
Loading