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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project is in 0.x, minor versions may add, change, or remove
functionality freely; 1.0.0 marks a stable command surface.

## [0.6.1] — 2026-09-01

### Fixed

- Disambiguate group paths in `commitor commit` using path-suffix matching and unambiguous basename fallback, preventing model shorthand paths from aliasing duplicate basenames across directories.
- Provide targeted feedback context when retrying inconsistent AI split plans, clarifying exact path and single-group assignment constraints.

## [0.6.0] — 2026-08-31

### Added
Expand Down Expand Up @@ -228,6 +235,7 @@ not of the full tool.
- `commitor scan` and `commitor commit` — in active development,
coming in future 0.x releases.

[0.6.1]: https://github.com/Commitor-AI/commitor/releases/tag/v0.6.1
[0.6.0]: https://github.com/Commitor-AI/commitor/releases/tag/v0.6.0
[0.5.1]: https://github.com/Commitor-AI/commitor/releases/tag/v0.5.1
[0.5.0]: https://github.com/Commitor-AI/commitor/releases/tag/v0.5.0
Expand Down
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 crates/cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "commitor-cli"
version = "0.6.0"
version = "0.6.1"
edition = "2021"
description = "Catches unrelated changes bundled into commits and splits them cleanly — whole-file or hunk-level, guided by AI analysis"
license = "MIT OR Apache-2.0"
Expand Down
103 changes: 93 additions & 10 deletions crates/cli/src/commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,9 +193,9 @@ pub fn run(flags: CommitFlags) -> Result<ExitCode> {
if plan_attempt >= MAX_PLAN_ATTEMPTS {
println!();
println!(
"note: the suggested split was still inconsistent ({err}), so all changes\n\
will be committed in a single commit instead of being refused.\n\
Re-run later (or use `commitor commit --offline`) for an AI split."
"note: the suggested AI split was still inconsistent ({err}).\n\
Falling back to an offline Conventional-Commits split.\n\
(Use `commitor commit --offline` to bypass the AI directly)."
);
let plan = offline_groups(&collected);
return run_offline_plan(plan, &file_diffs, collected.staged_used, &baseline, base_branch.as_deref());
Expand All @@ -205,7 +205,14 @@ pub fn run(flags: CommitFlags) -> Result<ExitCode> {
))? {
RetryDecision::Retry => {
println!("Re-requesting an analysis…");
match analysis::analyze_with_mode(&api_key, &collected.patch, "commit", None) {
let retry_context = format!(
"IMPORTANT: Your previous split was rejected because: {err}\n\
Rules to fix this:\n\
- Every changed file and diff hunk must belong to EXACTLY ONE group (no gaps, no duplicates).\n\
- Do NOT assign the same file or hunk to more than one group.\n\
- Use the EXACT full file path as shown in the diff."
);
match analysis::analyze_with_mode(&api_key, &collected.patch, "commit", Some(&retry_context)) {
Ok(ok) => {
response = ok.0;
rate = ok.1;
Expand Down Expand Up @@ -746,10 +753,12 @@ fn push_unique(actions: &mut Vec<String>, action: String) {
}

/// Repair backend group paths that don't match the analyzed diff
/// verbatim — e.g. the model returned a basename (`analysis.rs`) or a
/// `./`/`b/` prefix instead of the full path (`crates/cli/src/analysis.rs`).
/// Falls back to a basename match against the real changed files so a
/// usable AI split isn't rejected and forced into the offline fallback.
/// verbatim — e.g. the model returned a basename (`analysis.rs`), a
/// relative suffix (`services/audio.rs`), or a `./`/`b/` prefix instead
/// of the full path (`crates/cli/src/analysis.rs`).
/// Falls back to unambiguous suffix or basename matching against the real
/// changed files so a usable AI split isn't rejected, while refusing to
/// arbitrarily pick between multiple files sharing the same basename.
fn normalize_group_paths(groups: &mut [ChangeGroup], actual: &HashSet<String>) {
let resolve = |path: &str| -> String {
if actual.contains(path) {
Expand All @@ -762,10 +771,31 @@ fn normalize_group_paths(groups: &mut [ChangeGroup], actual: &HashSet<String>) {
if actual.contains(cleaned) {
return cleaned.to_string();
}

// 1. Match as a path suffix with a directory boundary (e.g. "services/audio.rs"
// matches "src/services/audio.rs" but not "src/widgets/audio.rs").
let suffix_with_slash = format!("/{cleaned}");
let suffix_matches: Vec<&String> = actual
.iter()
.filter(|f| f.ends_with(&suffix_with_slash) || *f == cleaned)
.collect();
if suffix_matches.len() == 1 {
return suffix_matches[0].clone();
}

// 2. Basename fallback: only match if UNAMBIGUOUS across the changed set.
// If multiple files share the basename (e.g. src/services/mod.rs and
// src/widgets/mod.rs), do not pick an arbitrary one, which would cause
// duplicate-assignment or missing-file validation failures.
let base = cleaned.rsplit('/').next().unwrap_or(cleaned);
if let Some(found) = actual.iter().find(|f| f.rsplit('/').next() == Some(base)) {
return found.clone();
let base_matches: Vec<&String> = actual
.iter()
.filter(|f| f.rsplit('/').next() == Some(base))
.collect();
if base_matches.len() == 1 {
return base_matches[0].clone();
}

path.to_string()
};
for group in groups.iter_mut() {
Expand Down Expand Up @@ -1603,6 +1633,59 @@ mod tests {
assert_eq!(groups[0].partial_files[0].path, "crates/cli/src/analysis.rs");
}

#[test]
fn normalize_disambiguates_by_suffix_with_duplicate_basenames() {
use crate::analysis::ChangeGroup;
let actual: std::collections::HashSet<String> =
["src/services/audio.rs", "src/widgets/audio.rs"]
.iter()
.map(|s| s.to_string())
.collect();

let mut groups = vec![
ChangeGroup {
files: vec!["services/audio.rs".to_string()],
commit_message: "services".into(),
rationale: String::new(),
partial_files: vec![],
},
ChangeGroup {
files: vec!["widgets/audio.rs".to_string()],
commit_message: "widgets".into(),
rationale: String::new(),
partial_files: vec![],
},
];

normalize_group_paths(&mut groups, &actual);

assert_eq!(groups[0].files[0], "src/services/audio.rs");
assert_eq!(groups[1].files[0], "src/widgets/audio.rs");
}

#[test]
fn normalize_leaves_ambiguous_basenames_unaltered() {
use crate::analysis::ChangeGroup;
let actual: std::collections::HashSet<String> =
["src/services/mod.rs", "src/widgets/mod.rs"]
.iter()
.map(|s| s.to_string())
.collect();

let mut groups = vec![ChangeGroup {
files: vec!["mod.rs".to_string()],
commit_message: "ambiguous".into(),
rationale: String::new(),
partial_files: vec![],
}];

normalize_group_paths(&mut groups, &actual);

// Neither should be arbitrarily chosen; path remains "mod.rs" so validation
// fails cleanly rather than aliasing one file onto the other.
assert_eq!(groups[0].files[0], "mod.rs");
}

#[test]
fn splits_features_fixes_and_remainder_into_separate_commits() {
let patch = "\
Expand Down
2 changes: 1 addition & 1 deletion npm/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "commitor-cli",
"version": "0.6.0",
"version": "0.6.1",
"description": "Catches unrelated changes bundled into commits and splits them cleanly — whole-file or hunk-level, guided by AI analysis",
"bin": {
"commitor": "bin/commitor.js"
Expand Down
Loading