Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-cli/src/repo_settings.rs

//! **`gunnar settings`** — a repository's settings on a forge (owner, 2026-10-14).
//!
//! On an installed Gunnar the control plane listens only on the estate's
//! WireGuard overlay, so a person's machine cannot reach `Repos.*`. The forge's
//! SSH door can: this asks its `gunnar-repo` verb, signed in with the person's
//! SSH key by the same `ssh` git runs, and the server answers with its own
//! `Repos` handlers (its rules, its audit). Nothing is decided here.

use std::process::{Command, Stdio};

use anyhow::{anyhow, bail, Context, Result};

use crate::cli::SettingsArgs;
use crate::crypt::{shell_quote, ssh_door};

/// The verb on the forge's SSH door (`gunnar_ssh::REPO_VERB`).
const VERB: &str = "gunnar-repo";

pub fn run(args: SettingsArgs) -> Result<()> {
    let repo = args.repo.trim().trim_end_matches(".git").to_string();
    if repo.split('/').filter(|p| !p.is_empty()).count() != 2 {
        bail!(
            "name the repository as <namespace>/<repository>, e.g. nordisk/knut (got `{}`)",
            args.repo
        );
    }
    let host = match &args.host {
        Some(h) => h.clone(),
        None => origin().ok_or_else(|| {
            anyhow!("name the forge with --host ssh://git@git.gunnar.rs:2222 (or stand in a clone whose origin is that forge)")
        })?,
    };
    let (target, port) =
        ssh_door(&host).ok_or_else(|| anyhow!("--host is not an SSH address: {host}"))?;

    let mut asks = Vec::new();
    if let Some(v) = &args.visibility {
        let v = v.trim().to_ascii_lowercase();
        if v != "public" && v != "private" {
            bail!("--visibility is `public` or `private`, not `{v}`");
        }
        asks.push(format!("{VERB} visibility {} {v}", shell_quote(&repo)));
    }
    if let Some(b) = &args.default_branch {
        asks.push(format!(
            "{VERB} default-branch {} {}",
            shell_quote(&repo),
            shell_quote(&full_branch(b))
        ));
    }
    // A change prints the repository as it now stands; with none, show it.
    if asks.is_empty() {
        asks.push(format!("{VERB} show {}", shell_quote(&repo)));
    }
    let mut last = String::new();
    for ask in &asks {
        last = exec(&target, port, args.identity.as_deref(), ask)?;
    }
    print!("{last}");
    Ok(())
}

/// The `<namespace>/<repository>` an SSH remote URL names
/// (`ssh://git@host:2222/ns/repo.git`, `git@host:ns/repo`).
pub(crate) fn repo_of_url(url: &str) -> Option<String> {
    let rest = if let Some(after) = url.split_once("://").map(|(_, r)| r) {
        after.split_once('/').map(|(_, p)| p)?
    } else {
        url.split_once(':').map(|(_, p)| p)?
    };
    let path = rest.trim_matches('/').trim_end_matches(".git");
    (path.split('/').filter(|p| !p.is_empty()).count() == 2).then(|| path.to_string())
}

/// **Before the first push of a sha256 repository**: a repository made by a
/// push can only be sha1 (the server must announce its format before the
/// client says a word), so a sha256 one is created first, in that format, over
/// the forge's SSH door. A repository that already exists is left alone.
pub(crate) fn ensure_remote_sha256(url: &str, identity: Option<&std::path::Path>) -> Result<()> {
    let (Some((target, port)), Some(repo)) = (ssh_door(url), repo_of_url(url)) else {
        return Ok(()); // not an SSH forge: git's own rules apply
    };
    if exec(
        &target,
        port,
        identity,
        &format!("{VERB} show {}", shell_quote(&repo)),
    )
    .is_ok()
    {
        return Ok(()); // it exists: the push goes into it
    }
    // The door does not tell a missing repository from one the caller may not read: both
    // answer "no read grant" (OX-GUNBR 2026-10-28: a sha256 first push never reached the
    // create, and went into a sha1 push-create). So ask for the create, and let its answer
    // decide: made, already there, or a refusal the push will meet in its own words.
    match exec(
        &target,
        port,
        identity,
        &format!("{VERB} create {} sha256", shell_quote(&repo)),
    ) {
        Ok(_) => {
            eprintln!("created {repo} at {target} as a sha256 repository");
            Ok(())
        }
        Err(e) if format!("{e:#}").contains("already exists") => Ok(()),
        // An older forge without the verb, a namespace that does not exist, no right to
        // create: said, and the push says the rest.
        Err(e) => {
            eprintln!(
                "could not create {repo} as a sha256 repository first ({e:#}); pushing anyway"
            );
            Ok(())
        }
    }
}

/// `gunnar create <ns/repo> [--sha1]`: an empty repository, sha256 unless asked.
pub fn create(args: crate::cli::CreateArgs) -> Result<()> {
    let repo = args.repo.trim().trim_end_matches(".git").to_string();
    if repo.split('/').filter(|p| !p.is_empty()).count() != 2 {
        bail!(
            "name the repository as <namespace>/<repository> (got `{}`)",
            args.repo
        );
    }
    let host = match &args.host {
        Some(h) => h.clone(),
        None => origin()
            .ok_or_else(|| anyhow!("name the forge with --host ssh://git@git.gunnar.rs:2222"))?,
    };
    let (target, port) =
        ssh_door(&host).ok_or_else(|| anyhow!("--host is not an SSH address: {host}"))?;
    let format = if args.sha1 { "sha1" } else { "sha256" };
    print!(
        "{}",
        exec(
            &target,
            port,
            args.identity.as_deref(),
            &format!("{VERB} create {} {format}", shell_quote(&repo))
        )?
    );
    Ok(())
}

/// `main` → `refs/heads/main`; a full ref stays as it is.
pub(crate) fn full_branch(branch: &str) -> String {
    let b = branch.trim();
    if b.starts_with("refs/") {
        b.to_string()
    } else {
        format!("refs/heads/{b}")
    }
}

/// The `origin` of the clone this runs in, if any.
fn origin() -> Option<String> {
    let out = Command::new("git")
        .args(["remote", "get-url", "origin"])
        .stderr(Stdio::null())
        .output()
        .ok()?;
    out.status
        .success()
        .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
        .filter(|s| !s.is_empty())
}

/// Run `verb` at the forge's SSH door, signed in by the `ssh` git would run
/// (`GIT_SSH_COMMAND`, `GIT_SSH`, else `ssh`). The door's answer on success;
/// its own sentence on a refusal.
fn exec(
    target: &str,
    port: Option<u16>,
    identity: Option<&std::path::Path>,
    verb: &str,
) -> Result<String> {
    let ssh = std::env::var("GIT_SSH_COMMAND")
        .ok()
        .filter(|c| !c.trim().is_empty())
        .or_else(|| {
            std::env::var("GIT_SSH")
                .ok()
                .filter(|c| !c.trim().is_empty())
                .map(|p| shell_quote(&p))
        })
        .unwrap_or_else(|| "ssh".to_string());
    let mut argv: Vec<String> = Vec::new();
    if let Some(key) = identity {
        argv.extend([
            "-i".into(),
            key.display().to_string(),
            "-o".into(),
            "IdentitiesOnly=yes".into(),
        ]);
    }
    if let Some(port) = port {
        argv.extend(["-p".into(), port.to_string()]);
    }
    argv.push(target.to_string());
    argv.push(verb.to_string());
    let out = Command::new("sh")
        .arg("-c")
        .arg(format!("{ssh} \"$@\""))
        .arg(&ssh)
        .args(&argv)
        .stdin(Stdio::null())
        .output()
        .context("run ssh")?;
    let said = String::from_utf8_lossy(&out.stdout).to_string();
    if out.status.success() {
        return Ok(said);
    }
    let stderr = String::from_utf8_lossy(&out.stderr).to_string();
    let why = said
        .lines()
        .map(|l| l.trim().trim_start_matches("error: ").to_string())
        .find(|l| !l.is_empty())
        .or_else(|| {
            stderr
                .lines()
                .map(str::trim)
                .rfind(|l| !l.is_empty())
                .map(str::to_string)
        })
        .unwrap_or_else(|| format!("ssh exited {}", out.status));
    bail!("{target}: {why}")
}

#[cfg(test)]
mod tests {
    use super::full_branch;

    #[test]
    fn a_short_branch_name_becomes_a_full_ref_and_a_full_ref_stays() {
        assert_eq!(full_branch("main"), "refs/heads/main");
        assert_eq!(full_branch(" master "), "refs/heads/master");
        assert_eq!(full_branch("refs/heads/release/1"), "refs/heads/release/1");
    }

    #[test]
    fn the_repository_is_read_off_an_ssh_remote() {
        use super::repo_of_url;
        assert_eq!(
            repo_of_url("ssh://git@git.gunnar.rs:2222/mikronova/tunnr.git").as_deref(),
            Some("mikronova/tunnr")
        );
        assert_eq!(
            repo_of_url("ssh://git@git.gunnar.rs:2222/mikronova/tunnr").as_deref(),
            Some("mikronova/tunnr")
        );
        assert_eq!(
            repo_of_url("git@host:ns/repo.git").as_deref(),
            Some("ns/repo")
        );
        assert_eq!(repo_of_url("ssh://git@host/onlyone").as_deref(), None);
    }
}