submod 0.3.0

A headache-free submodule management tool, built on top of gitoxide. Manage sparse checkouts, submodule updates, and adding/removing submodules with ease.
Documentation
// SPDX-FileCopyrightText: 2025 Adam Poulemanos <89049923+bashandbone@users.noreply.github.com>
//
// SPDX-License-Identifier: LicenseRef-PlainMIT OR MIT

#![doc = r#"
Command-line argument definitions for the `submod` tool.

Defines the CLI structure and commands using [`clap`] for managing git submodules with sparse checkout support.

# Overview

- Parses CLI arguments for the `submod` binary.
- Supports commands for adding, checking, initializing, updating, resetting, and syncing submodules.
- Allows specifying a custom configuration file (default: `submod.toml`).

# Commands

- [`Commands::Add`](src/commands.rs): Adds a new submodule configuration.
- [`Commands::Change`](src/commands.rs): Changes the configuration of an existing submodule.
- [`Commands::ChangeGlobal`](src/commands.rs): Changes global settings for all submodules in the current repository.
- [`Commands::Check`](src/commands.rs): Checks submodule status and configuration.
- [`Commands::Delete`](src/commands.rs): Deletes a submodule by name.
- [`Commands::Disable`](src/commands.rs): Disables a submodule by name.
- [`Commands::List`](src/commands.rs): Lists all submodules, optionally recursively.
- [`Commands::Init`](src/commands.rs): Initializes missing submodules.
- [`Commands::Update`](src/commands.rs): Updates all submodules.
- [`Commands::Reset`](src/commands.rs): Hard resets submodules (stash, reset --hard, clean).
- [`Commands::Sync`](src/commands.rs): Runs a full sync (check, init, update).
- [`Commands::GenerateConfig`](src/commands.rs): Generates a new configuration file.
- [`Commands::NukeItFromOrbit`](src/commands.rs): Deletes all submodules or specific ones, optionally leaving them dead. (reinits by default)
- [`Commands::Completions`](src/commands.rs): Generates shell completions for the specified shell.

# Usage Example

```sh
submod add https://github.com/example/my-lib.git --name my-lib --path libs/my-lib --sparse-paths "src/,include/"
submod change my-lib --branch "main" --sparse-paths "src/,include/" --fetch "always" --update "checkout"
submod check
submod init
submod update
submod reset --all
submod sync
submod completeme bash
```

# Configuration

Use the `--config` option to specify a custom config file location.

See the [README.md](../README.md) for full usage and configuration details.
"#]

use crate::shells::Shell;
use clap::{Parser, Subcommand};

use crate::long_abouts::COMPLETE_ME;
use crate::options::{
    SerializableFetchRecurse as FetchRecurse, SerializableIgnore as Ignore,
    SerializableUpdate as Update,
};
use std::{ffi::OsString, path::PathBuf};

/// Top-level CLI parser for the `submod` tool.
///
/// Accepts a command and an optional config file path.
#[derive(Parser, Debug)]
#[command(name = clap::crate_name!(), version = clap::crate_version!(), propagate_version = true, author = clap::crate_authors!(), about = clap::crate_description!(), infer_subcommands = true)]
pub struct Cli {
    /// command to execute.
    #[command(subcommand)]
    pub command: Commands,

    /// Path to the configuration file (default: submod.toml).
    #[arg(long = "config", global = true, default_value = "submod.toml", value_parser = clap::value_parser!(PathBuf), value_hint = clap::ValueHint::FilePath, help = "Optionally provide a different configuration file path. Defaults to submod.toml in the current directory.")]
    pub config: PathBuf,

    /// Enable verbose output with detailed status information.
    #[arg(long, short, global = true)]
    pub verbose: bool,
}

/// Supported commands for the `submod` tool.
#[derive(Subcommand, Debug)]
pub enum Commands {
    #[command(
        name = "add",
        visible_alias = "a",
        next_help_heading = "Add a Submodule",
        about = "Add and initialize a new submodule."
    )]
    Add {
        #[arg(required = true, action = clap::ArgAction::Set, value_parser = clap::value_parser!(String), help = "The URL or local path of the submodule's git repository.")]
        url: String,

        #[arg(short = 'n', long = "name", value_parser = clap::value_parser!(String), help = "Optional *nickname* for the submodule to use in your config and `submod` commands. Otherwise we'll use the relative path, which is what git uses.")]
        name: Option<String>,

        #[arg(short = 'p', long = "path", value_parser = clap::value_parser!(OsString), value_hint = clap::ValueHint::DirPath, help = "Local path where you want to put the submodule.")]
        path: Option<OsString>,

        #[arg(
            short = 'b',
            long = "branch",
            help = "Branch to use for the submodule. If not provided, defaults to the submodule's default branch."
        )]
        branch: Option<String>,

        #[arg(
            short = 'i',
            long = "ignore",
            help = "What changes in the submodule git should ignore."
        )]
        ignore: Option<Ignore>,

        #[arg(
            short = 'x',
            long = "sparse-paths",
            value_delimiter = ',',
            help = "Sparse checkout paths (comma-separated). Can be globs or paths"
        )]
        sparse_paths: Option<Vec<String>>,

        #[arg(
            long = "use-git-default-sparse-checkout",
            num_args = 0..=1,
            value_parser = clap::value_parser!(bool),
            default_missing_value = "true",
            help = "Opt out of submod's deny-all-by-default sparse-checkout model and use git's built-in behaviour instead. When set, the `!/*` prefix is NOT prepended automatically."
        )]
        use_git_default_sparse_checkout: Option<bool>,

        #[arg(
            short = 'f',
            long = "fetch",
            help = "Sets the recursive fetch behavior for the submodule (like, if we should fetch its submodules)."
        )]
        fetch: Option<FetchRecurse>,

        #[arg(
            short = 'u',
            long = "update",
            help = "How git should update the submodule when you run `git submodule update`."
        )]
        update: Option<Update>,

        #[arg(short = 's', long = "shallow", default_value = "false", action = clap::ArgAction::SetTrue, default_missing_value = "true", help = "If given, sets the submodule as a shallow clone. It will only fetch the last commit of the branch, not the full history.")]
        shallow: bool,

        #[arg(long = "no-init", default_value = "false", action = clap::ArgAction::SetTrue, default_missing_value = "true", help = "If given, we'll add the submodule to your submod.toml but not initialize it.")]
        no_init: bool,
    },
    // TODO: Implement this command
    #[command(
        name = "change",
        next_help_heading = "Change a Submodule's Settings",
        about = "Change the configuration of an existing submodule. Any field you provide will overwrite an existing value (unless both are defaults). If you change the path, it will nuke-it-from-orbit (delete it and re-clone it)."
    )]
    Change {
        #[arg(required = true, value_parser = clap::value_parser!(String), value_hint = clap::ValueHint::CommandName, help = "The name of the submodule to change. Must match an existing submodule.", long_help = "The name of the submodule to change. Must match an existing submodule in your submod.toml. Because we use this value to lookup your config, you cannot change the name from the CLI. You must manually change it in your submod.toml. All other options can be changed here.")]
        name: String,

        #[arg(short = 'p', long = "path", value_parser = clap::value_parser!(OsString), value_hint = clap::ValueHint::DirPath, help = "New local path for the submodule. Implies `nuke-it-from-orbit` (no-kill) if the path changes.")]
        path: Option<OsString>,

        #[arg(
            short = 'b',
            long = "branch",
            help = "Branch to use for the submodule. If not provided, defaults to the submodule's default branch."
        )]
        branch: Option<String>,

        #[arg(short = 'x', long = "sparse-paths", value_delimiter = ',', value_parser = clap::value_parser!(OsString), help = "Replace the sparse checkout paths (comma-separated), or add if not set. Use `--append` to append to existing sparse paths.", default_missing_value = "none")]
        sparse_paths: Option<Vec<OsString>>,

        #[arg(requires("sparse_paths"), short = 'a', long = "append", value_parser = clap::value_parser!(bool), default_value = "false", default_missing_value = "true", help = "If given, appends the new sparse paths to the existing ones.")]
        append: bool,

        #[arg(
            long = "use-git-default-sparse-checkout",
            num_args = 0..=1,
            value_parser = clap::value_parser!(bool),
            default_missing_value = "true",
            help = "Opt out of submod's deny-all-by-default sparse-checkout model and use git's built-in behaviour instead."
        )]
        use_git_default_sparse_checkout: Option<bool>,

        #[arg(
            short = 'i',
            long = "ignore",
            help = "Change the ignore settings for the submodule."
        )]
        ignore: Option<Ignore>,

        #[arg(
            short = 'f',
            long = "fetch",
            help = "Change the fetch settings for the submodule."
        )]
        fetch: Option<FetchRecurse>,

        #[arg(
            short = 'u',
            long = "update",
            help = "Change the update settings for the submodule."
        )]
        update: Option<Update>,

        #[arg(
            short = 's',
            long = "shallow",
            default_value = "false",
            default_missing_value = "true",
            help = "If true, sets the submodule as a shallow clone. Set false to disable shallow cloning."
        )]
        shallow: bool,

        #[arg(short = 'U', long = "url", value_parser = clap::value_parser!(String), help = "Change the URL of the submodule. The submodule name from the url must match an existing submodule.")]
        url: Option<String>,

        #[arg(long = "active", num_args = 0..=1, value_parser = clap::value_parser!(bool), default_missing_value = "true", help = "Set to true/false to enable or disable the submodule. Omit to leave unchanged. For a quick disable, use `submod disable <name>` instead.")]
        active: Option<bool>,
    },
    #[command(name = "change-global", visible_aliases = ["cg", "chgl", "global"], next_help_heading = "Change Global Settings", about = "Add or change the global settings for submodules, affecting all submodules in the current repository. Any individual submodule settings will override these global settings.")]
    ChangeGlobal {
        #[arg(
            short = 'i',
            long = "ignore",
            help = "Sets the default ignore behavior for all submodules in this repository. This will override any individual submodule settings."
        )]
        ignore: Option<Ignore>,

        #[arg(
            short = 'f',
            long = "fetch",
            help = "Sets the default fetch behavior for all submodules in this repository. This will override any individual submodule settings."
        )]
        fetch: Option<FetchRecurse>,

        #[arg(
            short = 'u',
            long = "update",
            help = "Sets the default update behavior for all submodules in this repository. This will override any individual submodule settings."
        )]
        update: Option<Update>,

        #[arg(
            long = "use-git-default-sparse-checkout",
            num_args = 0..=1,
            value_parser = clap::value_parser!(bool),
            default_missing_value = "true",
            help = "Set the global default for sparse-checkout mode. When true, all submodules use git's built-in behaviour instead of submod's deny-all-by-default model (unless overridden per-submodule)."
        )]
        use_git_default_sparse_checkout: Option<bool>,
    },

    #[command(
        name = "check",
        visible_alias = "c",
        next_help_heading = "Check Submodules",
        about = "Checks the status of submodules, ensuring they are initialized and up-to-date."
    )]
    Check,

    #[command(name = "list", visible_aliases = ["ls", "l"], next_help_heading = "List Submodules", about = "Lists all submodules, optionally recursively.")]
    List {
        /// Recursively list all submodules for the current repository.
        #[arg(short = 'r', long = "recursive", default_value = "false", action = clap::ArgAction::SetTrue, default_missing_value = "true", help = "If given, lists all submodules recursively (like, the submodules of the submodules).")]
        recursive: bool,
    },

    #[command(
        name = "init",
        visible_alias = "i",
        next_help_heading = "Initialize Submodules",
        about = "Initializes missing submodules based on the configuration file."
    )]
    Init,

    // TODO: Implement this command (use git2 + fs to delete files)
    #[command(
        name = "delete",
        visible_alias = "del",
        next_help_heading = "Delete a Submodule",
        about = "Deletes a submodule by name; removes it from the configuration and the filesystem."
    )]
    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(
        name = "disable",
        visible_alias = "d",
        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 {
        /// Name of the submodule to disable.
        #[arg(help = "Name of the submodule to disable.")]
        name: String,
    },

    #[command(
        name = "update",
        visible_alias = "u",
        next_help_heading = "Update Submodules",
        about = "Updates all submodules to their configured state."
    )]
    Update,

    #[command(
        name = "reset",
        visible_alias = "r",
        next_help_heading = "Reset Submodules",
        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", help = "If given, resets all submodules. If not given, you must specify specific submodules to reset.")]
        all: bool,

        #[arg(
            required_unless_present = "all",
            value_delimiter = ',',
            help = "Names of specific submodules to reset. If `--all` is not given, you must specify at least one submodule name."
        )]
        names: Vec<String>,
    },

    #[command(
        name = "sync",
        visible_alias = "s",
        next_help_heading = "Sync Submodules",
        about = "Runs a full sync: check, init, update. Ensures all submodules are in sync with the configuration."
    )]
    Sync,

    #[command(name = "generate-config", visible_aliases = ["gc", "genconf"], next_help_heading = "Generate a Config File", about = "Generates a new configuration file.")]
    GenerateConfig {
        /// Path to the new configuration file to generate.
        #[arg(short = 'o', long = "output", value_parser = clap::value_parser!(PathBuf), value_hint = clap::ValueHint::FilePath, default_value = "submod.toml", help = "Path to the output configuration file. Defaults to submod.toml in the current directory.")]
        output: PathBuf,

        #[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: 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,

        #[arg(short = 't', long = "template", help = "Generates a template configuration file with default values.", default_value = "false", action = clap::ArgAction::SetTrue, default_missing_value = "true")]
        template: bool,
    },

    #[command(name = "nuke-it-from-orbit", visible_aliases = ["nuke-em", "nuke-it", "nuke-them"], next_help_heading = "Nuke It From Orbit", about = "Deletes all submodules or specific ones, removing them from the configuration and the filesystem. Optionally leaves them dead. πŸš€πŸ’₯πŸ‘ΎπŸ’₯πŸ’€.")]
    NukeItFromOrbit {
        #[arg(long = "all", default_value = "false", action = clap::ArgAction::SetTrue, default_missing_value = "true", help = "Nuke 'em all? πŸ€“")]
        all: bool,
        #[arg(
            required_unless_present = "all",
            value_delimiter = ',',
            help = "... or only specific ones? πŸ˜” (comma-separated list of names"
        )]
        names: Option<Vec<String>>,

        #[arg(short = 'k', long = "kill", default_value = "false", action = clap::ArgAction::SetTrue, default_missing_value = "true", help = "If given, DOES NOT reinitialize the submodules and DOES NOT add them back to the config. They will be truly dead. πŸ’€")]
        kill: bool,
    },

    // Shell completions are implemented using clap_complete/clap_complete_nushell
    #[command(name = "completeme", visible_aliases = ["comp", "complete", "comp-me", "complete-me"], next_help_heading = "Generate Shell Completions", about = "Generates shell completions for the specified shell. Completions generated to stdout.", long_about = COMPLETE_ME)]
    CompleteMe {
        #[arg(value_enum, action = clap::ArgAction::Set, help = "The shell to generate completions for. Supported shells: `bash`, `zsh`, `fish`, `powershell`, `elvish`, `nushell`.")]
        shell: Shell,
    },
}