From 29d17dfcb44e391669b7a8f3fe1db5ecc69f967f Mon Sep 17 00:00:00 2001 From: Mohsen Beiranvand Date: Sat, 15 Aug 2026 10:04:36 +0200 Subject: [PATCH] Add --force to project delete, surface project in top-level help 'project delete' refused to remove a project with repos still registered, requiring a separate manual unregister first. --force now unregisters them as part of the same call. Also fixes 'project' (create/rename/delete/set-default) being missing from the curated 'Repos & Projects' section of `git task --help`, even though it was fully implemented and documented at the subcommand level. --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/cli/help.rs | 2 +- src/cli/project.rs | 21 ++++++++++++++++++--- src/config/global.rs | 19 +++++++++++++------ tests/cross_repo.rs | 30 ++++++++++++++++++++++++++++++ 6 files changed, 64 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e38d038..7e94e29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -451,7 +451,7 @@ dependencies = [ [[package]] name = "git-task" -version = "1.0.6" +version = "1.0.7" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index 910eb47..1583c4f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "git-task" -version = "1.0.6" +version = "1.0.7" edition = "2021" [lib] diff --git a/src/cli/help.rs b/src/cli/help.rs index 51e51cc..d1ea36e 100644 --- a/src/cli/help.rs +++ b/src/cli/help.rs @@ -12,7 +12,7 @@ use crate::color; const CATEGORIES: &[(&str, &[&str])] = &[ ("Tasks", &["new", "show", "ls", "edit", "status", "comment", "label", "log", "export", "epic", "link"]), ("Sync", &["clone", "push", "pull"]), - ("Repos & Projects", &["register", "unregister", "repos", "projects"]), + ("Repos & Projects", &["register", "unregister", "repos", "projects", "project"]), ("Config", &["key", "fields", "automation"]), ("Other", &["completions", "man", "skills", "web", "help"]), ]; diff --git a/src/cli/project.rs b/src/cli/project.rs index 9e54999..4e469b6 100644 --- a/src/cli/project.rs +++ b/src/cli/project.rs @@ -19,8 +19,8 @@ enum ProjectAction { SetDefault(NameArgs), /// Rename a project, re-tagging every repo registered under it Rename(RenameArgs), - /// Delete an empty, non-default project - Delete(NameArgs), + /// Delete a non-default project (must be empty unless --force) + Delete(DeleteArgs), } #[derive(Args)] @@ -28,6 +28,14 @@ struct NameArgs { name: String, } +#[derive(Args)] +struct DeleteArgs { + name: String, + /// Unregister any repos still under this project instead of refusing to delete it + #[arg(long)] + force: bool, +} + #[derive(Args)] struct RenameArgs { old_name: String, @@ -66,11 +74,18 @@ pub fn run(args: ProjectArgs) -> Result<()> { } } ProjectAction::Delete(a) => { - config.delete_project(&a.name)?; + let unregistered = config.delete_project(&a.name, a.force)?; config.save()?; if output::is_json() { output::registry::print_mutation("project_deleted", a.name, None, None, &config); } else { + if !unregistered.is_empty() { + Logger::info( + &format!("Unregistered {} repo(s): {}", unregistered.len(), unregistered.join(", ")), + None, + &[], + ); + } Logger::info(&format!("Deleted project '{}'", a.name), None, &[]); } } diff --git a/src/config/global.rs b/src/config/global.rs index 867e2dc..8feeb56 100644 --- a/src/config/global.rs +++ b/src/config/global.rs @@ -207,8 +207,10 @@ impl GlobalConfig { } /// Refuses to delete the default project or one that still has repos, rather than silently - /// reassigning or unregistering them — that's a decision the user should make explicitly. - pub fn delete_project(&mut self, name: &str) -> Result<()> { + /// reassigning or unregistering them — that's a decision the user should make explicitly, + /// unless `force` opts in: then every repo still tagged with `name` is unregistered as part + /// of the same call. Returns the names of any repos that got unregistered this way. + pub fn delete_project(&mut self, name: &str, force: bool) -> Result> { if !self.known_projects().contains(name) { return Err(not_found(format!("no such project '{name}'"), name.to_string())); } @@ -217,14 +219,19 @@ impl GlobalConfig { "'{name}' is the default project; set a different default first ('git task project set-default ')" ))); } - let repo_count = self.repos.values().filter(|e| e.project == name).count(); - if repo_count > 0 { + let repo_names: Vec = + self.repos.iter().filter(|(_, e)| e.project == name).map(|(n, _)| n.clone()).collect(); + if !repo_names.is_empty() && !force { return Err(conflict(format!( - "project '{name}' still has {repo_count} repo(s) registered; unregister them (or re-register under another project) first" + "project '{name}' still has {} repo(s) registered; unregister them (or re-register under another project) first, or pass --force to unregister them automatically", + repo_names.len() ))); } + for repo_name in &repo_names { + self.repos.remove(repo_name); + } self.projects.remove(name); - Ok(()) + Ok(repo_names) } } diff --git a/tests/cross_repo.rs b/tests/cross_repo.rs index cb978bf..050ff5e 100644 --- a/tests/cross_repo.rs +++ b/tests/cross_repo.rs @@ -166,6 +166,36 @@ fn project_json_mutations_report_action_and_registry() { assert_eq!(value["data"]["registry"]["default_project"], "infra"); } +#[test] +fn project_delete_refuses_non_empty_project_without_force() { + let config_dir = tempfile::tempdir().expect("tempdir"); + let repo = TestRepo::new_with_shared_config(config_dir.path()); + repo.run(&["register", "--project", "backend"]); + + let err = repo.run_err(&["project", "delete", "backend"]); + assert!(err.contains("still has 1 repo"), "unexpected error: {err}"); + assert!(err.contains("--force"), "expected a --force hint, got: {err}"); + + let projects = repo.run(&["projects"]); + assert!(projects.contains("backend"), "project should still exist: {projects}"); +} + +#[test] +fn project_delete_with_force_unregisters_its_repos() { + let config_dir = tempfile::tempdir().expect("tempdir"); + let repo = TestRepo::new_with_shared_config(config_dir.path()); + repo.run(&["register", "--project", "backend"]); + + let out = repo.run(&["project", "delete", "backend", "--force"]); + assert!(out.contains("Unregistered 1 repo"), "unexpected output: {out}"); + assert!(out.contains("Deleted project 'backend'"), "unexpected output: {out}"); + + let projects = repo.run(&["projects"]); + assert!(!projects.contains("backend"), "project should be gone: {projects}"); + let repos = repo.run(&["repos"]); + assert!(!repos.contains("backend"), "repo should have been unregistered: {repos}"); +} + /// `epic add --repo` records a fully-resolved cross-repo parent on the child's own side, and /// `show` on the epic finds that child by scanning every other repo registered under the same /// project — the "see all the linked tickets" end-to-end path: add from the child's repo, list