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
18 changes: 14 additions & 4 deletions src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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",
Expand All @@ -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(
Expand Down Expand Up @@ -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<String>,

#[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,
Expand Down
26 changes: 22 additions & 4 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -657,7 +659,7 @@ impl From<OtherSubmoduleSettings> 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<HashMap<SubmoduleName, SubmoduleEntry>>,
sparse_checkouts: Option<HashMap<SubmoduleName, Vec<String>>>,
Expand Down Expand Up @@ -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<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
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(
Expand Down Expand Up @@ -1008,7 +1026,7 @@ impl Config {
/// Load configuration from a file, merging with CLI options
pub fn load(&self, path: impl AsRef<Path>, cli_options: Config) -> anyhow::Result<Self> {
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
Expand All @@ -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())
Expand Down
57 changes: 6 additions & 51 deletions src/git_ops/git2_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 5 additions & 15 deletions src/git_ops/gix_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
))
Comment on lines +311 to +315
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message "gix add_submodule not implemented: use git2 or CLI fallback for '...'" is an internal implementation detail that will be printed to stderr via the eprintln! in try_with_fallback_mut every time a user runs add. A more user-friendly approach would be to use a sentinel error type (e.g., a specific variant or a recognized message like "not implemented") that try_with_fallback_mut can detect and suppress the stderr log for, since this fallback is always expected — it is not a real failure. Similarly for the git2 error message.

Copilot uses AI. Check for mistakes.
}

/// Initialize a submodule by reading its configuration and setting it up
Expand Down
52 changes: 25 additions & 27 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A blank line is missing between the use clap_complete::generate; import and the fn main() definition. In Rust convention and throughout the rest of this file, import blocks are separated from function definitions by a blank line. This is a minor formatting issue but inconsistent with the surrounding code style.

Suggested change
use clap_complete::generate;
use clap_complete::generate;

Copilot uses AI. Check for mistakes.
fn main() -> Result<()> {
let cli = Cli::parse();
// config-path is always set because it has a default value, "submod.toml"
Expand All @@ -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))?;

Expand All @@ -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))?;
Expand Down Expand Up @@ -213,7 +205,7 @@ fn main() -> Result<()> {
ignore,
fetch,
update,
shallow,
Some(shallow),
url,
active,
)
Expand Down Expand Up @@ -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 = <Cli as clap::CommandFactory>::command();
Expand Down
1 change: 1 addition & 0 deletions src/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,7 @@ impl GitmodulesConvert for SerializableBranch {
{
return Ok(SerializableBranch::CurrentInSuperproject);
}
let trimmed = options.trim();
if trimmed.is_empty() {
return Err(());
}
Expand Down
33 changes: 17 additions & 16 deletions src/utilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<gix::Repository, anyhow::Error> {
Expand Down Expand Up @@ -89,24 +89,25 @@ pub(crate) fn get_main_root(repo: Option<&gix::Repository>) -> Result<PathBuf, a

/// Get the current branch name from the repository.
pub(crate) fn get_current_branch(repo: Option<&gix::Repository>) -> Result<String, anyhow::Error> {
let repo = match repo {
Some(r) => r,
fn branch_from_repo(repo: &gix::Repository) -> Result<String, anyhow::Error> {
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<PathBuf, anyhow::Error> {
Expand Down