repoverse 0.1.4

Multi-repo workspace tool: keep many git repos in sync and roll changes up across dependency boundaries
//! clap definitions and dispatch. Every command self-documents.

use crate::ops;
use anyhow::Result;
use clap::{Parser, Subcommand};

#[derive(Parser, Debug)]
#[command(
    name = "rv",
    version,
    about = "repoverse — multi-repo workspace & rollup tool",
    long_about = "Keep many independent git repos in sync and roll changes up \
across dependency boundaries. See `rv help <cmd>`."
)]
pub struct Cli {
    #[command(subcommand)]
    pub command: Cmd,
}

#[derive(Subcommand, Debug)]
pub enum Cmd {
    /// Symlink-safe git status for the workspace or one checkout
    Status {
        /// Emit machine-readable JSON
        #[arg(long)]
        json: bool,
        /// Show only dirty repos and their changed files
        #[arg(long)]
        dirty: bool,
        /// Project path/name to inspect with symlink-safe git status
        target: Option<String>,
    },
    /// Show the workspace's shared checkout layout
    Layout,
    /// Check Repoverse workspace health and suggest or apply safe repairs
    Doctor {
        /// Apply safe local repairs such as missing skip-worktree flags
        #[arg(long)]
        fix: bool,
    },
    /// Check or stamp committed gitlinks from current Repoverse checkouts
    Gitlinks {
        /// Update mismatched gitlinks and commit each changed consumer
        #[arg(long)]
        commit: bool,
    },
    /// Clone/initialize the workspace from .repoverse.yaml
    Init {
        /// Force HTTPS remotes
        #[arg(long, conflicts_with = "ssh")]
        https: bool,
        /// Force SSH remotes
        #[arg(long)]
        ssh: bool,
    },
    /// Fetch all remotes in parallel
    Fetch {
        /// Parallel jobs
        #[arg(short = 'j', long, default_value_t = 8)]
        jobs: usize,
        targets: Vec<String>,
    },
    /// Work with configured upstream source remotes
    Upstream {
        #[command(subcommand)]
        command: UpstreamCmd,
    },
    /// Pull repos on their branches, restoring overlays around git pull
    Pull {
        #[arg(long)]
        rebase: bool,
        targets: Vec<String>,
    },
    /// Run git merge in selected/current repos, restoring overlays around git merge
    Merge {
        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
        args: Vec<String>,
    },
    /// Run git rebase in selected/current repos, restoring overlays around git rebase
    Rebase {
        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
        args: Vec<String>,
    },
    /// Checkout the workspace to .repoverse.lock, restoring overlays around git checkout
    Sync {
        /// Discard local changes
        #[arg(long)]
        force: bool,
    },
    /// Write .repoverse.lock = current HEADs
    Pin,
    /// Update the installed rv binary
    Update,
    /// Move the lock forward to current tips of the yaml's branches
    LockUpdate,
    /// Create a branch in dirty repos (smart mode)
    Branch { name: String },
    /// Checkout a revision in selected/current repos, restoring overlays around git checkout
    Checkout {
        rev: String,
        targets: Vec<String>,
        #[arg(long)]
        reset: bool,
    },
    /// Checkout/create a branch across selected repos, restoring overlays around git checkout
    Switch {
        #[arg(short = 'b')]
        create: bool,
        branch: String,
        targets: Vec<String>,
    },
    /// Re-point existing clones to ssh|https
    Remote {
        #[arg(value_parser = ["ssh", "https"])]
        scheme: String,
    },
    /// Atomic cross-repo commit with the same message
    Commit {
        #[arg(short = 'm')]
        message: String,
        targets: Vec<String>,
    },
    /// Re-establish shared-dep symlinks from config (run after a fresh clone)
    Link,
    /// Restore shared overlays to real submodules (before git history ops)
    Unlink {
        /// Remove overlays without hydrating submodule checkouts
        #[arg(long)]
        no_submodules: bool,
        /// Only this repo (owner/repo or short name); default: all
        repo: Option<String>,
    },
    /// Push repos on feature branches
    Push,
    /// Open PRs: sub-repos first, then root with cross-links (needs gh)
    Pr {
        /// Print the resolved PR plan without creating PRs
        #[arg(long)]
        dry_run: bool,
        #[arg(long)]
        json: bool,
    },
    /// Pin + commit the lock + tag a release
    Release { tag: Option<String> },
    /// Install project dependencies per repo
    Setup { targets: Vec<String> },
    /// Run lint in dirty repos (parallel, quiet on success)
    Lint {
        #[arg(long)]
        force: bool,
        targets: Vec<String>,
    },
    /// Run tests in dirty repos
    Test {
        #[arg(long)]
        force: bool,
        targets: Vec<String>,
    },
    /// Lint + test dirty repos by default
    Check {
        /// Explicitly check dirty repos only (default)
        #[arg(long, conflicts_with = "all")]
        dirty: bool,
        /// Check all configured repos, even clean ones
        #[arg(long)]
        all: bool,
        #[arg(long)]
        json: bool,
        targets: Vec<String>,
    },
    /// Migrate a Google-repo manifest.xml into .repoverse.yaml
    Import { manifest: String },
    /// Wizard: adopt an existing nested-submodule project
    Adopt {
        /// Recursively scan submodules and print a leaf-up conversion plan
        #[arg(long)]
        plan: bool,
        /// Lift one repo to a single shared copy (dry-run unless --yes)
        #[arg(long, value_name = "REPO")]
        step: Option<String>,
        /// Directory (workspace-relative) to place the shared copy in
        /// [default: ./repos/<repo>]
        #[arg(long, value_name = "DIR")]
        into: Option<String>,
        /// Rewrite each consumer's .gitmodules `branch=` to this branch
        /// (stages it; kills phantom branch-conflicts). Not pushed.
        #[arg(long, value_name = "BRANCH")]
        normalize_branch: Option<String>,
        /// Also write a per-repo .repoverse.yaml into each consumer
        /// (only for repos built/cloned standalone; default off)
        #[arg(long)]
        standalone: bool,
        #[arg(long)]
        json: bool,
        /// Actually perform the step (otherwise dry-run)
        #[arg(long)]
        yes: bool,
        #[arg(long)]
        dry_run: bool,
    },
    /// Show the full ordered dependency DAG with per-node status
    Plan {
        #[arg(long)]
        json: bool,
    },
    /// The single next actionable node (agent loop primitive)
    Next {
        #[arg(long)]
        json: bool,
    },
    /// Dependency-ordered rollup cascade
    Rollup {
        #[arg(long)]
        dry_run: bool,
        #[arg(long)]
        direct: bool,
        #[arg(long)]
        step: bool,
        #[arg(long)]
        from: Option<String>,
        #[arg(long)]
        slug: Option<String>,
    },
}

#[derive(Subcommand, Debug)]
pub enum UpstreamCmd {
    /// Fetch configured upstream remotes without merging
    Fetch {
        /// Parallel jobs
        #[arg(short = 'j', long, default_value_t = 8)]
        jobs: usize,
        targets: Vec<String>,
    },
}

pub fn run(cli: Cli) -> Result<()> {
    match cli.command {
        Cmd::Status {
            json,
            dirty,
            target,
        } => ops::status::run(json, dirty, target.as_deref()),
        Cmd::Layout => ops::status::layout(),
        Cmd::Doctor { fix } => ops::doctor::run(fix),
        Cmd::Gitlinks { commit } => ops::gitlinks::run(commit),
        Cmd::Init { https, ssh } => ops::init::run(https, ssh),
        Cmd::Fetch { jobs, targets } => ops::fetch::run(jobs, &targets),
        Cmd::Upstream { command } => match command {
            UpstreamCmd::Fetch { jobs, targets } => ops::upstream::fetch(jobs, &targets),
        },
        Cmd::Pull { rebase, targets } => ops::pull::run(rebase, &targets),
        Cmd::Merge { args } => ops::history::run(ops::history::Action::Merge, &args),
        Cmd::Rebase { args } => ops::history::run(ops::history::Action::Rebase, &args),
        Cmd::Sync { force } => ops::sync::run(force),
        Cmd::Pin => ops::pin::run(),
        Cmd::Update => ops::self_update::run(),
        Cmd::LockUpdate => ops::lock_update::run(),
        Cmd::Branch { name } => ops::branch::run(&name),
        Cmd::Checkout {
            rev,
            targets,
            reset,
        } => ops::checkout::run(&rev, &targets, reset),
        Cmd::Switch {
            create,
            branch,
            targets,
        } => ops::switch::run(create, &branch, &targets),
        Cmd::Remote { scheme } => ops::remote_cmd::run(&scheme),
        Cmd::Commit { message, targets } => ops::commit::run(&message, &targets),
        Cmd::Link => ops::adopt::relink(),
        Cmd::Unlink {
            no_submodules,
            repo,
        } => ops::adopt::unlink(repo.as_deref(), !no_submodules),
        Cmd::Push => ops::push::run(),
        Cmd::Pr { dry_run, json } => ops::pr::run(dry_run, json),
        Cmd::Release { tag } => ops::release::run(tag.as_deref()),
        Cmd::Setup { targets } => ops::tasks_cmd::run(ops::tasks_cmd::Task::Setup, false, &targets),
        Cmd::Lint { force, targets } => {
            ops::tasks_cmd::run(ops::tasks_cmd::Task::Lint, force, &targets)
        }
        Cmd::Test { force, targets } => {
            ops::tasks_cmd::run(ops::tasks_cmd::Task::Test, force, &targets)
        }
        Cmd::Check {
            dirty: _,
            all,
            json,
            targets,
        } => ops::check::run(json, all, &targets),
        Cmd::Import { manifest } => ops::import::run(&manifest),
        Cmd::Adopt {
            plan,
            step,
            into,
            normalize_branch,
            standalone,
            json,
            yes,
            dry_run,
        } => {
            if let Some(repo) = step {
                ops::adopt::step(
                    &repo,
                    into.as_deref(),
                    normalize_branch.as_deref(),
                    standalone,
                    yes,
                )
            } else if plan {
                ops::adopt::plan(json)
            } else {
                ops::adopt::run(yes, dry_run)
            }
        }
        Cmd::Plan { json } => ops::plan::run(json),
        Cmd::Next { json } => ops::plan::next(json),
        Cmd::Rollup {
            dry_run,
            direct,
            step,
            from,
            slug,
        } => ops::rollup::run(dry_run, direct, step, from.as_deref(), slug.as_deref()),
    }
}