elvsc

19 commits
Updated 2026-04-30 16:50:05
src/commands
src/commands/connect.rs
use crate::{ElvscArgs, RemoteOrLocal, check_output};

use std::collections::HashSet;

use jj_lib::{
    config::ConfigValue, ref_name::RemoteNameBuf, repo::Repo, str_util::StringExpression,
};

use jj_cli::{
    cli_util::CommandHelper,
    command_error::{CommandError, cli_error_with_message, user_error, user_error_with_message},
    ui::Ui,
};
use toml_edit::Array;

const NUSHELL_GET_HUB_ENTRIES: &str = r#"
| where { |it| $it.type == dir }
| update name { |it| $it.name | path basename }
| get name
| to json
"#;

pub fn get_hub_entries(hub: &RemoteOrLocal<'_>) -> Result<HashSet<String>, CommandError> {
    let path = hub.path().display().to_string();
    let script = format!("ls {path} {NUSHELL_GET_HUB_ENTRIES}");
    let output = check_output(hub.shell_command(&script).output()?)?;

    serde_json::from_slice(&output.stdout)
        .map_err(|e| user_error_with_message("Couldn't parse hub entries json", e))
}

pub fn create_git_repository(
    ui: &mut Ui,
    hub: &RemoteOrLocal<'_>,
    repo: &str,
) -> Result<(), CommandError> {
    let target = hub.path().join(repo);
    let script = format!("git init {} --bare", target.display());
    let output = check_output(hub.shell_command(&script).output()?)?;

    write!(
        ui.stdout_formatter(),
        "[{}]: {}",
        hub.to_string(),
        String::from_utf8_lossy(&output.stdout)
    )?;

    Ok(())
}

pub async fn run(
    ui: &mut Ui,
    ch: &CommandHelper,
    hub: RemoteOrLocal<'_>,
    public: &str,
    output: RemoteOrLocal<'_>,
    repo: Option<&str>,
    reconnect: bool,
    dry_run: bool,
) -> Result<(), CommandError> {
    if ElvscArgs::from_config(ch).is_ok() && !reconnect {
        return Err(user_error(
            "Repository is already connected to a hub. Use '--reconnect' to replace the existing connection",
        ));
    }

    let mut wch = ch.workspace_helper(ui)?;

    let repo = match repo {
        Some(s) => s.to_owned(),
        None => wch
            .workspace_root()
            .file_name()
            .and_then(|os| os.to_str())
            .map(str::to_owned)
            .ok_or_else(|| {
                user_error("No repository name was passed and none could be deduced from workspace")
            })?,
    };

    let hub_str = hub.to_string();
    let output_str = output.to_string();
    let git_url = hub.join(&repo).to_string();

    let git_repo = jj_lib::git::get_git_repo(wch.repo().store())?;

    let (origin_taken, should_add_git_remote) =
        if let Some(Ok(remote)) = git_repo.try_find_remote("origin") {
            let push = remote
                .url(gix::remote::Direction::Push)
                .map(|url| url.to_bstring())
                .unwrap_or_else(|| "<no URL>".into());
            let fetch = remote
                .url(gix::remote::Direction::Fetch)
                .map(|url| url.to_bstring())
                .unwrap_or_else(|| "<no URL>".into());

            if push != fetch {
                (true, true)
            } else {
                if push == git_url {
                    (true, false)
                } else {
                    (true, true)
                }
            }
        } else {
            (false, true)
        };

    let remote_name: String = if origin_taken && should_add_git_remote {
        let answer =
            ui.prompt(&format!("Repository {} origin already exists as a git remote. Enter a name for the elvsc remote", repo))?;
        let trimmed = answer.trim();
        if trimmed.is_empty() {
            return Err(user_error("Remote name cannot be empty"));
        }
        if git_repo.try_find_remote(trimmed).is_some() {
            return Err(user_error(format!(
                "Remote '{trimmed}' already exists too — aborting"
            )));
        }
        trimmed.to_owned()
    } else {
        "origin".to_owned()
    };

    {
        let mut stdout = ui.stdout_formatter();
        if dry_run {
            writeln!(
                stdout,
                "[dry-run] no changes will be made to the filesystem or repository"
            )?;
        }

        writeln!(stdout, "Connecting to hub:")?;
        writeln!(stdout, "  repo:    {repo}")?;
        writeln!(stdout, "  hub:     {hub_str}")?;
        writeln!(stdout, "  public:  {public}")?;
        writeln!(stdout, "  output:  {output_str}")?;
        writeln!(stdout, "  remote:  {remote_name}")?;
        writeln!(stdout, "  git url: {git_url}")?;

        if reconnect {
            writeln!(stdout, "  (replacing existing connection)")?;
        }
    }

    let entries = get_hub_entries(&hub)?;
    let repo_exists = entries.contains(&repo);

    if repo_exists {
        writeln!(
            ui.stdout_formatter(),
            "✓ hub already has a bare repository named '{repo}'"
        )?;
    } else if dry_run {
        writeln!(
            ui.stdout_formatter(),
            "[dry-run] would create bare repository: '{}/{repo}'",
            hub.to_string()
        )?;
    } else {
        writeln!(
            ui.stdout_formatter(),
            "→ creating bare repository on hub..."
        )?;
        create_git_repository(ui, &hub, &repo)?;
    }

    let config_entries: Vec<(&str, ConfigValue)> = vec![
        ("elvsc.repo", repo.as_str().into()),
        ("elvsc.hub", hub_str.as_str().into()),
        ("elvsc.public", public.into()),
        ("elvsc.output", output_str.as_str().into()),
        ("elvsc.remote", remote_name.as_str().into()),
        (
            "aliases.export",
            Array::from_iter([
                "--elvsc-update",
                "git",
                "push",
                "--remote",
                remote_name.as_str(),
                "--all",
            ])
            .into(),
        ),
        (
            "aliases.import",
            Array::from_iter(["git", "fetch", "--remote", remote_name.as_str()]).into(),
        ),
    ];

    if dry_run {
        writeln!(
            ui.stdout_formatter(),
            "[dry-run] would write to repo config:"
        )?;
        for (key, value) in &config_entries {
            writeln!(ui.stdout_formatter(), "    {key} = {value}")?;
        }
    } else {
        let mut files = ch.config_env().repo_config_files(ui, ch.raw_config())?;
        let config = files
            .get_mut(0)
            .ok_or_else(|| user_error("No repo config path found to edit"))?;

        for (key, value) in &config_entries {
            config
                .set_value(*key, value)
                .map_err(|e| cli_error_with_message("Couldn't set value in config file", e))?;
        }

        config
            .save()
            .map_err(|e| cli_error_with_message("Couldn't save config file", e))?;

        writeln!(
            ui.stdout_formatter(),
            "✓ wrote {} config entries",
            config_entries.len()
        )?;
    }

    if should_add_git_remote && dry_run {
        writeln!(
            ui.stdout_formatter(),
            "[dry-run] would add git remote '{remote_name}' → {git_url}"
        )?;
    } else if should_add_git_remote {
        let mut tx = wch.start_transaction();
        jj_lib::git::add_remote(
            tx.repo_mut(),
            &RemoteNameBuf::from(remote_name.as_str()),
            &git_url,
            None,
            gix::remote::fetch::Tags::All,
            &StringExpression::all(),
        )?;
        tx.finish(ui, format!("add git remote {remote_name}"))
            .await?;
        writeln!(
            ui.stdout_formatter(),
            "✓ added git remote '{remote_name}' → {git_url}"
        )?;
    }

    if dry_run {
        writeln!(
            ui.stdout_formatter(),
            "[dry-run] complete — no changes made"
        )?;
    } else {
        writeln!(ui.stdout_formatter(), "✓ connection complete")?;
    }

    Ok(())
}