use std::io::IsTerminal;
use std::path::Path;
use anyhow::{anyhow, bail, Context, Result};
use dialoguer::Confirm;
use serde::Deserialize;
use crate::commands::remote;
use crate::context::CommandContext;
use crate::style::Style;
const SETUP_FILE: &str = ".git-meta";
const DEFAULT_REMOTE_NAME: &str = "meta";
#[derive(Debug, Deserialize)]
struct SetupConfig {
url: String,
}
pub(crate) fn run() -> Result<()> {
let ctx = CommandContext::open(None)?;
let repo = ctx.session.repo();
let workdir = repo
.workdir()
.ok_or_else(|| anyhow!("git meta setup requires a non-bare repository"))?;
let setup_path = workdir.join(SETUP_FILE);
if !setup_path.exists() {
return interactive_setup(&ctx, &setup_path);
}
let url = read_setup_url(&setup_path)?;
let s = Style::detect_stderr();
eprintln!(
"{} metadata remote URL from {}",
s.step("Using"),
s.dim(&setup_path.display().to_string()),
);
remote::run_add(&url, DEFAULT_REMOTE_NAME, None, true)
}
fn interactive_setup(ctx: &CommandContext, setup_path: &Path) -> Result<()> {
if !std::io::stdin().is_terminal() {
return Err(missing_setup_file_error(setup_path));
}
let repo = ctx.session.repo();
let ns = ctx.session.namespace().to_string();
let s = Style::detect_stderr();
let config = repo.config_snapshot();
let origin_url = config.string("remote.origin.url").map(|s| s.to_string());
let Some(origin_url) = origin_url else {
eprintln!(
"{}: this repository has no 'origin' remote configured.",
s.warn("Warning"),
);
eprintln!(
"git meta needs a remote to store metadata under refs/{ns}/main.\n\
Add a remote to your project (e.g. `git remote add origin <url>`) and re-run\n\
`git meta setup`, or configure a metadata remote directly:\n \
git meta remote add <url> --init",
);
return Ok(());
};
eprintln!("git meta has not been set up for this repository yet.");
eprintln!("Your 'origin' remote is {}.", s.dim(&origin_url),);
let accept = Confirm::new()
.with_prompt(format!(
"Use this remote to store metadata under refs/{ns}/main?"
))
.default(true)
.interact()
.unwrap_or(false);
if !accept {
eprintln!(
"No metadata remote configured. You can set one up later with:\n \
git meta remote add <url> --init",
);
return Ok(());
}
let contents = format!("url: {origin_url}\n");
std::fs::write(setup_path, &contents)
.with_context(|| format!("write {SETUP_FILE} at {}", setup_path.display()))?;
eprintln!(
"{} {} with metadata remote URL.",
s.ok("Wrote"),
s.dim(&setup_path.display().to_string()),
);
remote::run_add(&origin_url, DEFAULT_REMOTE_NAME, None, true)
}
fn missing_setup_file_error(path: &Path) -> anyhow::Error {
anyhow!(
"no {SETUP_FILE} file found at {display}\n\n\
Create one with the metadata remote URL in YAML, e.g.:\n \
printf 'url: git@github.com:org/project-meta.git\\n' > {display}\n\n\
Or run `git meta remote add <url> --init` directly to skip the alias.",
display = path.display(),
)
}
fn read_setup_url(path: &Path) -> Result<String> {
if !path.exists() {
return Err(missing_setup_file_error(path));
}
let raw = std::fs::read_to_string(path)
.with_context(|| format!("read {SETUP_FILE} at {}", path.display()))?;
parse_setup_url(&raw).with_context(|| format!("parse {SETUP_FILE} at {}", path.display()))
}
fn parse_setup_url(contents: &str) -> Result<String> {
let config = serde_yml::from_str::<Option<SetupConfig>>(contents)?
.ok_or_else(|| anyhow!(".git-meta is empty or contains no metadata remote URL"))?;
let url = config.url.trim();
if url.is_empty() {
bail!(".git-meta contains an empty url value");
}
Ok(strip_optional_trailing_slash_owned(url.to_string()))
}
fn strip_optional_trailing_slash_owned(url: String) -> String {
if url.len() > 1 && url.ends_with('/') {
let mut s = url;
s.pop();
s
} else {
url
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn parses_url_key() {
assert_eq!(
parse_setup_url("url: git@github.com:org/repo.git\n").unwrap(),
"git@github.com:org/repo.git",
);
}
#[test]
fn ignores_comments_and_unknown_keys() {
let input = "\n\
# this is a comment\n\
\n\
# indented comment\n\
url: git@github.com:org/repo.git\n\
future-key: ignored\n\
# trailing notes ignored\n";
assert_eq!(
parse_setup_url(input).unwrap(),
"git@github.com:org/repo.git"
);
}
#[test]
fn errors_for_empty_input() {
let err = parse_setup_url("").unwrap_err();
assert!(
err.to_string()
.contains("empty or contains no metadata remote URL"),
"got: {err}"
);
}
#[test]
fn errors_for_only_comments() {
let err = parse_setup_url("# only a comment\n\n# another\n").unwrap_err();
assert!(
err.to_string()
.contains("empty or contains no metadata remote URL"),
"got: {err}"
);
}
#[test]
fn trims_surrounding_whitespace() {
assert_eq!(
parse_setup_url("url: ' git@github.com:org/repo.git '\n").unwrap(),
"git@github.com:org/repo.git",
);
}
#[test]
fn errors_when_url_key_is_missing() {
let input = "\
remote: https://example.com/first.git\n\
note: https://example.com/second.git\n";
let err = parse_setup_url(input).unwrap_err();
assert!(
err.to_string().contains("missing field `url`"),
"got: {err}"
);
}
#[test]
fn strips_single_trailing_slash() {
assert_eq!(
strip_optional_trailing_slash_owned("https://example.com/foo/".to_string()),
"https://example.com/foo"
);
}
#[test]
fn keeps_lone_slash() {
assert_eq!(strip_optional_trailing_slash_owned("/".to_string()), "/");
}
#[test]
fn read_setup_url_missing_file_errors_helpfully() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".git-meta");
let err = read_setup_url(&path).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("no .git-meta file found"), "got: {msg}");
assert!(msg.contains("--init"), "got: {msg}");
}
#[test]
fn read_setup_url_empty_file_errors_helpfully() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".git-meta");
std::fs::write(&path, "# only a comment\n\n").unwrap();
let err = read_setup_url(&path).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("empty or contains no metadata remote URL"),
"got: {msg}"
);
}
#[test]
fn read_setup_url_invalid_yaml_errors_helpfully() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".git-meta");
std::fs::write(&path, "url: [").unwrap();
let err = read_setup_url(&path).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("parse .git-meta"), "got: {msg}");
}
}