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);
}
}