diff --git a/src/commands.rs b/src/commands.rs index 4189c11..b70f554 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -242,7 +242,11 @@ pub enum Commands { next_help_heading = "Delete a Submodule", about = "Deletes a submodule by name; removes it from the configuration and the filesystem." )] - Delete, + Delete { + /// Name of the submodule to delete. + #[arg(help = "Name of the submodule to delete.")] + name: String, + }, // TODO: Implement this command (use git2). Functionally this changes a module to `active = false` in our config and `.gitmodules`, but does not delete the submodule from the filesystem. #[command( @@ -251,7 +255,11 @@ pub enum Commands { next_help_heading = "Disable a Submodule", about = "Disables a submodule by name; sets its active status to false. Does not remove settings or files." )] - Disable, + Disable { + /// Name of the submodule to disable. + #[arg(help = "Name of the submodule to disable.")] + name: String, + }, #[command( name = "update", @@ -268,7 +276,7 @@ pub enum Commands { about = "Hard resets submodules, stashing changes, resetting to the configured state, and cleaning untracked files." )] Reset { - #[arg(short = 'a', long = "all", default_value = "false", action = clap::ArgAction::SetTrue, default_missing_value = "true", value_hint = clap::ValueHint::CommandName, help = "If given, resets all submodules. If not given, you must specify specific submodules to reset.")] + #[arg(short = 'a', long = "all", default_value = "false", action = clap::ArgAction::SetTrue, default_missing_value = "true", help = "If given, resets all submodules. If not given, you must specify specific submodules to reset.")] all: bool, #[arg( @@ -296,9 +304,11 @@ pub enum Commands { #[arg( short = 's', long = "from-setup", + num_args = 0, + default_missing_value = "true", help = "Generates the config from your current repository's submodule settings." )] - from_setup: String, + from_setup: Option, #[arg(short = 'f', long = "force", default_value = "false", action = clap::ArgAction::SetTrue, default_missing_value = "true", help = "If given, overwrites the existing configuration file without prompting.")] force: bool, diff --git a/src/config.rs b/src/config.rs index b290d8a..91cca35 100644 --- a/src/config.rs +++ b/src/config.rs @@ -27,7 +27,9 @@ use crate::options::{ SerializableUpdate, }; use anyhow::Result; -use serde::{Deserialize, Serialize}; +use serde::de::Deserializer; +use serde::ser::SerializeMap; +use serde::{Deserialize, Serialize, Serializer}; use std::path::PathBuf; use std::{collections::HashMap, path::Path}; // TODO: Implement figment::Profile for modular configs @@ -657,7 +659,7 @@ impl From for SubmoduleEntry { /// A collection of submodule entries, including sparse checkouts /// /// Revamped to better reflect git's structure so we can use the SubmoduleEntry types directly with gix/git2 -#[derive(Debug, Default, Clone, Serialize, PartialEq, Eq)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct SubmoduleEntries { submodules: Option>, sparse_checkouts: Option>>, @@ -685,6 +687,22 @@ impl<'de> Deserialize<'de> for SubmoduleEntries { } } +impl Serialize for SubmoduleEntries { + /// Serialize as a flat map of submodule name → entry, so the round-trip + /// through `Deserialize` (which also expects a flat map) is consistent. + fn serialize(&self, serializer: S) -> Result { + let submodules = self.submodules.as_ref(); + let len = submodules.map_or(0, HashMap::len); + let mut map = serializer.serialize_map(Some(len))?; + if let Some(subs) = submodules { + for (name, entry) in subs { + map.serialize_entry(name, entry)?; + } + } + map.end() + } +} + impl SubmoduleEntries { /// Create a new empty SubmoduleEntries pub fn new( @@ -1008,7 +1026,7 @@ impl Config { /// Load configuration from a file, merging with CLI options pub fn load(&self, path: impl AsRef, cli_options: Config) -> anyhow::Result { let fig = Figment::from(Self::default()) // 1) start from Rust-side defaults - .merge(Toml::file(path).nested()) // 2) file-based overrides + .merge(Toml::file(path)) // 2) file-based overrides .merge(cli_options); // 3) CLI overrides file // 4) extract into Config, then post-process submodules @@ -1022,7 +1040,7 @@ impl Config { Some(ref p) => p, None => &".", }; - let fig = Figment::from(Self::default()).merge(Toml::file(p).nested()); + let fig = Figment::from(Self::default()).merge(Toml::file(p)); // Extract the configuration from Figment let cfg: Config = fig.extract()?; Ok(cfg.apply_defaults()) diff --git a/src/git_ops/git2_ops.rs b/src/git_ops/git2_ops.rs index b50fad0..15e0d67 100644 --- a/src/git_ops/git2_ops.rs +++ b/src/git_ops/git2_ops.rs @@ -288,57 +288,12 @@ impl GitOperations for Git2Operations { Ok(()) } fn add_submodule(&mut self, opts: &SubmoduleAddOptions) -> Result<()> { - // Register the submodule, clone it, then finalize (writes .gitmodules and updates index). - // This is the correct git2 sequence for a fresh submodule add. - { - let _submodule = self.repo.submodule( - &opts.url, &opts.path, true, // use_gitlink - )?; - } // submodule is dropped here - // Configure the submodule (after dropping the submodule reference) - if let Some(ignore) = &(*opts).ignore { - let git2_ignore: git2::SubmoduleIgnore = ignore - .clone() - .try_into() - .map_err(|_| anyhow::anyhow!("Failed to convert ignore setting"))?; - self.repo - .submodule_set_ignore(&opts.path.to_string_lossy(), git2_ignore)?; - } - if let Some(update) = &opts.update { - let git2_update: git2::SubmoduleUpdate = update - .clone() - .try_into() - .map_err(|_| anyhow::anyhow!("Failed to convert update setting"))?; - self.repo - .submodule_set_update(&opts.path.to_string_lossy(), git2_update)?; - } - if let Some(branch) = &opts.branch { - let branch_str = match branch { - SerializableBranch::CurrentInSuperproject => ".".to_string(), - SerializableBranch::Name(name) => name.clone(), - }; - let mut config = self.repo.config()?; - config.set_str(&format!("submodule.{}.branch", opts.name), &branch_str)?; - } - if let Some(fetch_recurse) = &opts.fetch_recurse { - let fetch_str = match fetch_recurse { - SerializableFetchRecurse::OnDemand => "on-demand", - SerializableFetchRecurse::Always => "true", - SerializableFetchRecurse::Never => "false", - SerializableFetchRecurse::Unspecified => return Ok(()), - }; - let mut config = self.repo.config()?; - config.set_str(&format!("submodule.{}.fetchRecurseSubmodules", opts.name), fetch_str)?; - } - // Initialize the submodule if not skipped - if !opts.no_init { - let mut submodule = self.repo.find_submodule(opts.path.to_str().unwrap())?; - submodule.init(false)?; // false = don't overwrite existing config - submodule.update(true, None)?; // true = init, None = use default options - submodule.sync()?; - } - // Sync changes - Ok(()) + // git2 submodule cloning requires remote callbacks that are complex to configure. + // Fall through to the CLI fallback which handles this reliably. + Err(anyhow::anyhow!( + "Unable to add submodule '{}' using the library API; it will be added using the Git CLI instead", + opts.name + )) } fn init_submodule(&mut self, path: &str) -> Result<()> { let mut submodule = self diff --git a/src/git_ops/gix_ops.rs b/src/git_ops/gix_ops.rs index 7080a33..581568b 100644 --- a/src/git_ops/gix_ops.rs +++ b/src/git_ops/gix_ops.rs @@ -308,21 +308,11 @@ impl GitOperations for GixOperations { /// Add a new submodule to the repository fn add_submodule(&mut self, opts: &SubmoduleAddOptions) -> Result<()> { - // 2. Check if submodule already exists (do this before borrowing self mutably) - let entries = self.read_gitmodules()?; - let existing_names = &entries.submodule_names(); - if existing_names - .as_ref() - .map_or(false, |names| names.contains(&opts.name)) - { - return Err(anyhow::anyhow!( - "Submodule '{}' already exists. Use 'submod update' if you want to change its options", - opts.name - )); - } - let (name, entry) = opts.clone().into_entries_tuple(); - let merged_entries = entries.add_submodule(name, entry); - self.write_gitmodules(&merged_entries) + // gix does not support cloning in add_submodule; fall through to git2/CLI. + Err(anyhow::anyhow!( + "gix add_submodule not implemented: use git2 or CLI fallback for '{}'", + opts.name + )) } /// Initialize a submodule by reading its configuration and setting it up diff --git a/src/main.rs b/src/main.rs index 1afdec1..ca0de1d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,16 +31,11 @@ mod utilities; use crate::commands::{Cli, Commands}; use crate::git_manager::GitManager; -use crate::options::{ - SerializableBranch as Branch, SerializableFetchRecurse, SerializableIgnore, SerializableUpdate, -}; -use crate::utilities::{get_name, get_sparse_paths, name_from_osstring, name_from_url, set_path}; +use crate::options::SerializableBranch as Branch; +use crate::utilities::{get_name, get_sparse_paths, set_path}; use anyhow::Result; use clap::Parser; -use std::ffi::OsString; -use std::str::FromStr; -use submod::options::SerializableBranch; - +use clap_complete::generate; fn main() -> Result<()> { let cli = Cli::parse(); // config-path is always set because it has a default value, "submod.toml" @@ -63,22 +58,19 @@ fn main() -> Result<()> { let sparse_paths_vec = get_sparse_paths(sparse_paths) .map_err(|e| anyhow::anyhow!("Invalid sparse paths: {}", e))?; - let set_name = get_name(name, Some(url.clone()), path.clone()) let set_name = get_name(name, Some(url.clone()), path.clone()) .map_err(|e| anyhow::anyhow!("Failed to get submodule name: {}", e))?; let set_path = path .map(|p| set_path(p).map_err(|e| anyhow::anyhow!("Invalid path: {}", e))) - .transpose()?; + .transpose()? + .unwrap_or_else(|| set_name.clone()); let set_url = url.trim().to_string(); - let set_branch = Branch::set_branch(branch) let set_branch = Branch::set_branch(branch) .map_err(|e| anyhow::anyhow!("Failed to set branch: {}", e))?; - let mut manager = GitManager::new(config_path) - .map_err(|e| anyhow::anyhow!("Failed to create manager: {}", e))?; let mut manager = GitManager::new(config_path) .map_err(|e| anyhow::anyhow!("Failed to create manager: {}", e))?; @@ -88,11 +80,11 @@ fn main() -> Result<()> { set_path, set_url, sparse_paths_vec, - set_branch, - ignore, - fetch, - update, - shallow, + Some(set_branch), + Some(ignore), + Some(fetch), + Some(update), + Some(shallow), no_init, ) .map_err(|e| anyhow::anyhow!("Failed to add submodule: {}", e))?; @@ -213,7 +205,7 @@ fn main() -> Result<()> { ignore, fetch, update, - shallow, + Some(shallow), url, active, ) @@ -251,15 +243,21 @@ fn main() -> Result<()> { .disable_submodule(&name) .map_err(|e| anyhow::anyhow!("Failed to disable submodule: {}", e))?; } - Commands::GenerateConfig { .. } => { - return Err(anyhow::anyhow!( - "GenerateConfig command not yet implemented" - )); + Commands::GenerateConfig { + output, + from_setup, + force, + template, + } => { + GitManager::generate_config(&output, from_setup.is_some(), template, force) + .map_err(|e| anyhow::anyhow!("Failed to generate config: {}", e))?; } - Commands::NukeItFromOrbit { .. } => { - return Err(anyhow::anyhow!( - "NukeItFromOrbit command not yet implemented" - )); + Commands::NukeItFromOrbit { all, names, kill } => { + let mut manager = GitManager::new(config_path) + .map_err(|e| anyhow::anyhow!("Failed to create manager: {}", e))?; + manager + .nuke_submodules(all, names, kill) + .map_err(|e| anyhow::anyhow!("Failed to nuke submodules: {}", e))?; } Commands::CompleteMe { shell } => { let mut cmd = ::command(); diff --git a/src/options.rs b/src/options.rs index d0783f1..79f9923 100644 --- a/src/options.rs +++ b/src/options.rs @@ -437,6 +437,7 @@ impl GitmodulesConvert for SerializableBranch { { return Ok(SerializableBranch::CurrentInSuperproject); } + let trimmed = options.trim(); if trimmed.is_empty() { return Err(()); } diff --git a/src/utilities.rs b/src/utilities.rs index 815d54e..0f6fa81 100644 --- a/src/utilities.rs +++ b/src/utilities.rs @@ -26,9 +26,9 @@ pub(crate) fn get_current_git2_repository( } } -/**======================================================================== - ** Gix Utilities - *========================================================================**/ +/*========================================================================= + * Gix Utilities + *========================================================================*/ /// Get a repository from a given path. The returned repository is isolated (has very limited access to the working tree and environment). pub(crate) fn repo_from_path(path: &PathBuf) -> Result { @@ -89,24 +89,25 @@ pub(crate) fn get_main_root(repo: Option<&gix::Repository>) -> Result) -> Result { - let repo = match repo { - Some(r) => r, + fn branch_from_repo(repo: &gix::Repository) -> Result { + let head = repo.head()?; + if let Some(reference) = head.referent_name() { + return Ok(reference.as_bstr().to_string()); + } + Err(anyhow::anyhow!("Failed to get current branch name")) + } + match repo { + Some(r) => branch_from_repo(r), None => { - owned = get_current_repository()?; - &owned + let owned = get_current_repository()?; + branch_from_repo(&owned) } - }; - let head = repo.head()?; - if let Some(reference) = head.referent_name() { - let ref_bstr = reference.as_bstr(); - return Ok(ref_bstr.to_string()); } - Err(anyhow::anyhow!("Failed to get current branch name")) } -/**======================================================================== - ** General Utilities - *========================================================================**/ +/*========================================================================= + * General Utilities + *========================================================================*/ /// Get the current working directory. pub(crate) fn get_current_working_directory() -> Result {