use std::collections::{BTreeMap, HashSet};
use std::process::ExitCode;
use anyhow::{Context, bail};
use clap::Args;
use log::info;
use crate::git::Git;
use crate::model::changeset::{ChangeType, Changeset, derive_changeset};
use crate::model::config::Config;
use crate::package_manager::Project;
use crate::path::AbsolutePath;
use crate::tui::change;
use super::GlobalArgs;
#[derive(Args, Default)]
pub struct ChangeArgs {
#[arg(short = 't', long, conflicts_with = "auto")]
pub change_type: Option<ChangeType>,
#[arg(short = 'p', long = "project")]
pub projects: Vec<String>,
#[arg(short = 'm', long, conflicts_with = "auto")]
pub message: Option<String>,
#[arg(long, conflicts_with_all = ["change_type", "message"])]
pub auto: bool,
#[arg(long, requires = "auto")]
pub no_git: bool,
}
fn match_files_to_projects(
projects: &[Project],
git_path: &AbsolutePath,
changed_files: &HashSet<String>,
) -> Vec<bool> {
let rel_paths: Vec<Option<String>> = projects
.iter()
.map(|p| {
p.path()
.strip_prefix(git_path.as_path())
.ok()
.map(|r| r.to_string_lossy().into_owned())
})
.collect();
let mut matched = vec![false; projects.len()];
for file in changed_files {
let candidates: Vec<(usize, usize)> = rel_paths
.iter()
.enumerate()
.filter_map(|(i, rel_opt)| {
let rel = rel_opt.as_deref()?;
if rel.is_empty() {
Some((i, 0usize))
} else if file.starts_with(rel)
&& (file.len() == rel.len() || file.as_bytes().get(rel.len()) == Some(&b'/'))
{
Some((i, rel.len()))
} else {
None
}
})
.collect();
if let Some(&(_, best_len)) = candidates.iter().max_by_key(|(_, len)| *len) {
candidates
.iter()
.filter(|(_, len)| *len == best_len)
.for_each(|(i, _)| matched[*i] = true);
}
}
matched
}
async fn classify_changed_projects(
git: &dyn Git,
projects: &[crate::package_manager::Project],
) -> Vec<bool> {
let sources = [
git.diff_names(&["origin/HEAD..HEAD"]).await,
git.diff_names(&["--cached"]).await,
git.diff_names(&[]).await,
];
let any_succeeded = sources.iter().any(|r| r.is_ok());
if !any_succeeded {
return vec![true; projects.len()];
}
let changed_files: HashSet<String> = sources
.into_iter()
.filter_map(|r| r.ok())
.flatten()
.collect();
match_files_to_projects(projects, git.path(), &changed_files)
}
fn resolve_project_indices(
projects: &[crate::package_manager::Project],
names: &[String],
) -> anyhow::Result<Option<Vec<usize>>> {
if names.is_empty() {
return Ok(None);
}
let indices = names
.iter()
.map(|name| {
projects
.iter()
.position(|p| p.name() == name)
.ok_or_else(|| anyhow::anyhow!("Unknown project: {name}"))
})
.collect::<anyhow::Result<Vec<_>>>()?;
Ok(Some(indices))
}
async fn validate_single_commit(git: &dyn Git) -> anyhow::Result<Option<String>> {
let count = git.rev_list_count("origin/HEAD..HEAD").await?;
if count == 0 {
bail!("No commits ahead of origin/HEAD — nothing to derive a changeset from");
}
if count > 1 {
info!(
"Branch has {count} commits ahead of origin/HEAD; \
skipping --auto (expected exactly 1)"
);
return Ok(None);
}
Ok(Some(git.log_message("HEAD").await?))
}
async fn cmd_change_auto(
args: &ChangeArgs,
global: &GlobalArgs,
env: &crate::Env,
config: Config,
) -> anyhow::Result<ExitCode> {
let git = env.git();
let Some(message) = validate_single_commit(git).await? else {
return Ok(ExitCode::SUCCESS);
};
let projects = config.load_projects(env).await?;
let changed_files: HashSet<String> = git.diff_tree_names("HEAD").await?.into_iter().collect();
let matched_flags = match_files_to_projects(&projects, git.path(), &changed_files);
let matched: Vec<_> = projects
.iter()
.zip(matched_flags.iter())
.filter_map(|(p, &m)| m.then_some(p))
.collect();
if matched.is_empty() {
info!("No projects matched the changed files — skipping changeset");
return Ok(ExitCode::SUCCESS);
}
let project_names: Vec<&str> = matched.iter().map(|p| p.name()).collect();
let Some(changeset) = derive_changeset(&message, &project_names)? else {
info!("Commit has no semver significance — skipping changeset");
return Ok(ExitCode::SUCCESS);
};
let description = changeset
.message
.as_deref()
.and_then(|m| m.lines().next())
.unwrap_or("auto-derived changeset");
if global.dry_run {
println!("{}", changeset.format()?);
if config.git.enabled() && !args.no_git {
git.add(&[git.path().join(".cursus/changeset-dry-run.md")])
.await?;
}
} else {
let path = changeset.write(git, env.fs()).await?;
info!("Created changeset: {}", path.display());
if config.git.enabled() && !args.no_git {
git.add(&[path]).await?;
}
}
if config.git.enabled() && !args.no_git {
git.commit(&format!("chore: add changeset for {description}"))
.await?;
git.push().await?;
}
Ok(ExitCode::SUCCESS)
}
fn resolve_non_interactive(
args: &ChangeArgs,
projects: &[crate::package_manager::Project],
project_indices: &Option<Vec<usize>>,
) -> anyhow::Result<change::ChangeResult> {
let Some(ct) = args.change_type else {
bail!("--change-type is required in non-interactive mode");
};
if args.message.is_none() {
bail!("--message is required in non-interactive mode");
}
let selected: Vec<crate::package_manager::Project> = match project_indices {
Some(indices) => indices.iter().map(|&i| projects[i].clone()).collect(),
None => projects.to_vec(),
};
Ok(change::ChangeResult {
projects: selected.into_iter().map(|p| (p, ct)).collect(),
message: args.message.clone(),
})
}
pub(crate) async fn cmd_change(
args: &ChangeArgs,
global: &GlobalArgs,
env: &crate::Env,
config: Config,
) -> anyhow::Result<ExitCode> {
if args.auto {
return cmd_change_auto(args, global, env, config).await;
}
let git = env.git();
let projects = config.load_projects(env).await?;
let project_indices = resolve_project_indices(&projects, &args.projects)?;
let result = if global.no_interactive {
resolve_non_interactive(args, &projects, &project_indices)?
} else {
let options = change::ChangeOptions {
change_type: args.change_type,
projects: project_indices.clone(),
};
let changed = classify_changed_projects(git, &projects).await;
let projects_clone = projects.clone();
let mut r = match tokio::task::spawn_blocking(move || {
change::run(&projects_clone, &options, &changed)
})
.await
.context("TUI task panicked")??
{
Some(r) => r,
None => return Ok(ExitCode::from(2)),
};
if let Some(msg) = &args.message {
r.message = Some(msg.clone());
}
r
};
let packages: BTreeMap<String, ChangeType> = result
.projects
.iter()
.map(|(p, ct)| (p.name().to_string(), *ct))
.collect();
let changeset = Changeset::new(packages, result.message.clone());
if global.dry_run {
println!("{}", changeset.format()?);
} else {
let path = changeset.write(git, env.fs()).await?;
info!("Created changeset: {}", path.display());
if result.message.is_none() {
env.run_editor_on(&path, git.path()).await?;
}
}
Ok(ExitCode::SUCCESS)
}
#[cfg(test)]
mod tests {
use std::path::Path;
use std::process::Output;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use crate::command::CommandRunner;
use crate::command::test_support::RecordingCommandRunner;
use crate::package_manager::Project;
use crate::path::AbsolutePath;
use super::*;
fn make_git_with_diff_output(stdout: &[u8]) -> crate::git::GitWorkdir {
let runner = Arc::new(RecordingCommandRunner::new(0).with_stdout(stdout.to_vec()))
as Arc<dyn CommandRunner>;
crate::git::GitWorkdir::new(runner, AbsolutePath::new("/nonexistent").unwrap())
}
fn make_git_failing() -> crate::git::GitWorkdir {
let runner = Arc::new(RecordingCommandRunner::new(1)) as Arc<dyn CommandRunner>;
crate::git::GitWorkdir::new(runner, AbsolutePath::new("/nonexistent").unwrap())
}
#[derive(Debug)]
struct SequencedRunner {
responses: Mutex<Vec<(i32, Vec<u8>)>>,
}
impl SequencedRunner {
fn new(responses: Vec<(i32, Vec<u8>)>) -> Self {
Self {
responses: Mutex::new(responses),
}
}
}
#[async_trait]
impl CommandRunner for SequencedRunner {
async fn run(&self, _program: &str, _args: &[&str], _cwd: &Path) -> anyhow::Result<Output> {
#[cfg(unix)]
fn make_status(code: i32) -> std::process::ExitStatus {
use std::os::unix::process::ExitStatusExt;
std::process::ExitStatus::from_raw(code << 8)
}
#[cfg(windows)]
fn make_status(code: i32) -> std::process::ExitStatus {
use std::os::windows::process::ExitStatusExt;
std::process::ExitStatus::from_raw(code as u32)
}
let (code, stdout) = self
.responses
.lock()
.expect("mutex poisoned")
.drain(..1)
.next()
.unwrap_or((0, vec![]));
Ok(Output {
status: make_status(code),
stdout,
stderr: vec![],
})
}
async fn run_shell(&self, _command: &str, cwd: &Path) -> anyhow::Result<Output> {
self.run(
crate::command::shell_program(),
&[crate::command::shell_flag(), ""],
cwd,
)
.await
}
async fn run_mut(
&self,
program: &str,
args: &[&str],
cwd: &Path,
) -> anyhow::Result<Output> {
self.run(program, args, cwd).await
}
async fn run_shell_mut(&self, command: &str, cwd: &Path) -> anyhow::Result<Output> {
self.run_shell(command, cwd).await
}
async fn run_interactive(
&self,
_program: &str,
_args: &[&str],
_cwd: &Path,
) -> anyhow::Result<std::process::ExitStatus> {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
return Ok(std::process::ExitStatus::from_raw(0));
}
#[cfg(windows)]
{
use std::os::windows::process::ExitStatusExt;
return Ok(std::process::ExitStatus::from_raw(0));
}
}
async fn run_shell_interactive(
&self,
_command: &str,
_cwd: &Path,
) -> anyhow::Result<std::process::ExitStatus> {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
return Ok(std::process::ExitStatus::from_raw(0));
}
#[cfg(windows)]
{
use std::os::windows::process::ExitStatusExt;
return Ok(std::process::ExitStatus::from_raw(0));
}
}
}
fn make_git_sequenced(responses: Vec<(i32, Vec<u8>)>) -> crate::git::GitWorkdir {
let runner = Arc::new(SequencedRunner::new(responses)) as Arc<dyn CommandRunner>;
crate::git::GitWorkdir::new(runner, AbsolutePath::new("/nonexistent").unwrap())
}
#[tokio::test]
async fn default_change_args() {
let args = ChangeArgs::default();
assert!(args.change_type.is_none());
assert!(args.projects.is_empty());
assert!(args.message.is_none());
assert!(!args.auto);
assert!(!args.no_git);
}
#[tokio::test]
async fn classify_changed_projects_matches_by_prefix() {
let git = make_git_with_diff_output(b"packages/a/src/lib.rs\n");
let projects = vec![
Project::new_test("a", "/nonexistent/packages/a"),
Project::new_test("b", "/nonexistent/packages/b"),
];
let result = classify_changed_projects(&git, &projects).await;
assert_eq!(result, vec![true, false]);
}
#[tokio::test]
async fn classify_changed_projects_does_not_match_prefix_without_separator() {
let git = make_git_with_diff_output(b"packages/a-extra/foo.rs\n");
let projects = vec![
Project::new_test("a", "/nonexistent/packages/a"),
Project::new_test("a-extra", "/nonexistent/packages/a-extra"),
];
let result = classify_changed_projects(&git, &projects).await;
assert_eq!(result, vec![false, true]);
}
#[tokio::test]
async fn classify_changed_projects_fallback_on_failure() {
let git = make_git_failing();
let projects = vec![
Project::new_test("a", "/nonexistent/packages/a"),
Project::new_test("b", "/nonexistent/packages/b"),
];
let result = classify_changed_projects(&git, &projects).await;
assert_eq!(result, vec![true, true]);
}
#[tokio::test]
async fn classify_changed_projects_empty_diff_returns_unchanged() {
let git = make_git_with_diff_output(b"");
let projects = vec![Project::new_test("a", "/nonexistent/packages/a")];
let result = classify_changed_projects(&git, &projects).await;
assert_eq!(result, vec![false]);
}
#[tokio::test]
async fn classify_changed_projects_root_project_changed_when_any_file_changed() {
let git = make_git_with_diff_output(b"src/main.rs\n");
let projects = vec![Project::new_test("root", "/nonexistent")];
let result = classify_changed_projects(&git, &projects).await;
assert_eq!(result, vec![true]);
}
#[tokio::test]
async fn classify_changed_projects_root_project_unchanged_when_empty_diff() {
let git = make_git_with_diff_output(b"");
let projects = vec![Project::new_test("root", "/nonexistent")];
let result = classify_changed_projects(&git, &projects).await;
assert_eq!(result, vec![false]);
}
#[tokio::test]
async fn classify_changed_projects_detects_staged_only_changes() {
let git = make_git_sequenced(vec![
(1, vec![]), (0, b"packages/a/lib.rs\n".to_vec()), (0, vec![]), ]);
let projects = vec![
Project::new_test("a", "/nonexistent/packages/a"),
Project::new_test("b", "/nonexistent/packages/b"),
];
let result = classify_changed_projects(&git, &projects).await;
assert_eq!(result, vec![true, false]);
}
#[tokio::test]
async fn classify_changed_projects_detects_unstaged_only_changes() {
let git = make_git_sequenced(vec![
(1, vec![]), (0, vec![]), (0, b"packages/b/index.js\n".to_vec()), ]);
let projects = vec![
Project::new_test("a", "/nonexistent/packages/a"),
Project::new_test("b", "/nonexistent/packages/b"),
];
let result = classify_changed_projects(&git, &projects).await;
assert_eq!(result, vec![false, true]);
}
#[tokio::test]
async fn classify_changed_projects_unions_all_sources() {
let git = make_git_sequenced(vec![
(0, b"packages/a/lib.rs\n".to_vec()), (0, b"packages/b/index.js\n".to_vec()), (0, b"packages/c/main.go\n".to_vec()), ]);
let projects = vec![
Project::new_test("a", "/nonexistent/packages/a"),
Project::new_test("b", "/nonexistent/packages/b"),
Project::new_test("c", "/nonexistent/packages/c"),
];
let result = classify_changed_projects(&git, &projects).await;
assert_eq!(result, vec![true, true, true]);
}
#[tokio::test]
async fn classify_changed_projects_fallback_only_when_all_fail() {
let git = make_git_sequenced(vec![
(1, vec![]), (1, vec![]), (1, vec![]), ]);
let projects = vec![
Project::new_test("a", "/nonexistent/packages/a"),
Project::new_test("b", "/nonexistent/packages/b"),
];
let result = classify_changed_projects(&git, &projects).await;
assert_eq!(result, vec![true, true]);
}
#[tokio::test]
async fn match_files_to_projects_basic_prefix_match() {
let path = AbsolutePath::new("/repo").unwrap();
let projects = vec![
Project::new_test("a", "/repo/packages/a"),
Project::new_test("b", "/repo/packages/b"),
];
let mut files = HashSet::new();
files.insert("packages/a/src/lib.rs".to_string());
assert_eq!(
match_files_to_projects(&projects, &path, &files),
vec![true, false]
);
}
#[tokio::test]
async fn match_files_to_projects_no_match_for_different_project() {
let path = AbsolutePath::new("/repo").unwrap();
let projects = vec![
Project::new_test("a", "/repo/packages/a"),
Project::new_test("b", "/repo/packages/b"),
];
let mut files = HashSet::new();
files.insert("packages/b/src/lib.rs".to_string());
assert_eq!(
match_files_to_projects(&projects, &path, &files),
vec![false, true]
);
}
#[tokio::test]
async fn match_files_to_projects_no_prefix_match_without_separator() {
let path = AbsolutePath::new("/repo").unwrap();
let projects = vec![
Project::new_test("a", "/repo/packages/a"),
Project::new_test("a-extra", "/repo/packages/a-extra"),
];
let mut files = HashSet::new();
files.insert("packages/a-extra/lib.rs".to_string());
assert_eq!(
match_files_to_projects(&projects, &path, &files),
vec![false, true]
);
}
#[tokio::test]
async fn match_files_to_projects_nested_file_goes_to_child() {
let path = AbsolutePath::new("/repo").unwrap();
let projects = vec![
Project::new_test("parent", "/repo/packages/a"),
Project::new_test("child", "/repo/packages/a/sub"),
];
let mut files = HashSet::new();
files.insert("packages/a/sub/src/lib.rs".to_string());
assert_eq!(
match_files_to_projects(&projects, &path, &files),
vec![false, true]
);
}
#[tokio::test]
async fn match_files_to_projects_nested_parent_file_goes_to_parent() {
let path = AbsolutePath::new("/repo").unwrap();
let projects = vec![
Project::new_test("parent", "/repo/packages/a"),
Project::new_test("child", "/repo/packages/a/sub"),
];
let mut files = HashSet::new();
files.insert("packages/a/README.md".to_string());
assert_eq!(
match_files_to_projects(&projects, &path, &files),
vec![true, false]
);
}
#[tokio::test]
async fn match_files_to_projects_root_project_matches_unowned_file() {
let path = AbsolutePath::new("/repo").unwrap();
let projects = vec![
Project::new_test("root", "/repo"),
Project::new_test("a", "/repo/packages/a"),
];
let mut files = HashSet::new();
files.insert("src/main.rs".to_string());
assert_eq!(
match_files_to_projects(&projects, &path, &files),
vec![true, false]
);
}
#[tokio::test]
async fn match_files_to_projects_root_does_not_steal_from_subproject() {
let path = AbsolutePath::new("/repo").unwrap();
let projects = vec![
Project::new_test("root", "/repo"),
Project::new_test("a", "/repo/packages/a"),
];
let mut files = HashSet::new();
files.insert("packages/a/src/lib.rs".to_string());
assert_eq!(
match_files_to_projects(&projects, &path, &files),
vec![false, true]
);
}
#[tokio::test]
async fn match_files_to_projects_empty_files() {
let path = AbsolutePath::new("/repo").unwrap();
let projects = vec![Project::new_test("root", "/repo")];
let files = HashSet::new();
assert_eq!(
match_files_to_projects(&projects, &path, &files),
vec![false]
);
}
#[tokio::test]
async fn match_files_to_projects_outside_git_root_always_unchanged() {
let path = AbsolutePath::new("/repo").unwrap();
let projects = vec![Project::new_test("outside", "/other/path")];
let files = HashSet::new();
assert_eq!(
match_files_to_projects(&projects, &path, &files),
vec![false]
);
}
#[tokio::test]
async fn match_files_to_projects_outside_git_root_unchanged_even_with_files() {
let path = AbsolutePath::new("/repo").unwrap();
let projects = vec![
Project::new_test("outside", "/other/path"),
Project::new_test("a", "/repo/packages/a"),
];
let mut files = HashSet::new();
files.insert("packages/a/src/lib.rs".to_string());
assert_eq!(
match_files_to_projects(&projects, &path, &files),
vec![false, true]
);
}
#[tokio::test]
async fn match_files_to_projects_unowned_file_with_no_root() {
let path = AbsolutePath::new("/repo").unwrap();
let projects = vec![
Project::new_test("a", "/repo/packages/a"),
Project::new_test("b", "/repo/packages/b"),
];
let mut files = HashSet::new();
files.insert("other/random.txt".to_string());
assert_eq!(
match_files_to_projects(&projects, &path, &files),
vec![false, false]
);
}
#[tokio::test]
async fn match_files_to_projects_multiple_at_same_path_all_marked() {
let path = AbsolutePath::new("/repo").unwrap();
let projects = vec![
Project::new_test("npm-root", "/repo"),
Project::new_test("cargo-root", "/repo"),
Project::new_test("sub", "/repo/packages/sub"),
];
let mut files = HashSet::new();
files.insert("README.md".to_string());
assert_eq!(
match_files_to_projects(&projects, &path, &files),
vec![true, true, false]
);
}
#[tokio::test]
async fn match_files_to_projects_exact_path_length_match() {
let path = AbsolutePath::new("/repo").unwrap();
let projects = vec![Project::new_test("my-pkg", "/repo/my-pkg")];
let mut files = HashSet::new();
files.insert("my-pkg".to_string()); assert_eq!(
match_files_to_projects(&projects, &path, &files),
vec![true]
);
}
#[tokio::test]
async fn match_files_to_projects_multiple_at_same_path_subproject_wins() {
let path = AbsolutePath::new("/repo").unwrap();
let projects = vec![
Project::new_test("npm-root", "/repo"),
Project::new_test("cargo-root", "/repo"),
Project::new_test("sub", "/repo/packages/sub"),
];
let mut files = HashSet::new();
files.insert("packages/sub/src/lib.rs".to_string());
assert_eq!(
match_files_to_projects(&projects, &path, &files),
vec![false, false, true]
);
}
}