hive-forge: infer active repo from cwd's git remote, demote HIVE_FORGE_REPO

This commit is contained in:
damocles 2026-07-29 18:37:37 +02:00
commit f034ffb9d8
9 changed files with 201 additions and 25 deletions

View file

@ -17,10 +17,6 @@ use serde_json::Value;
/// Default Forgejo URL when `HIVE_FORGE_URL` is unset.
const DEFAULT_URL: &str = "http://localhost:3000";
/// Default repo when `HIVE_FORGE_REPO` is unset and the verb doesn't
/// take a repo override.
const DEFAULT_REPO: &str = "hyperhive/hyperhive";
/// Forgejo client pair: the typed `/api/v1` client plus a raw
/// `reqwest` client for web-router-only routes.
pub struct Client {
@ -50,10 +46,20 @@ pub struct Client {
impl Client {
/// Build a client from the standard environment variables.
/// `repo_override` (from the global `-r/--repo` flag) takes
/// priority over `HIVE_FORGE_REPO`; the env var is the fallback
/// default. `json_mode` comes from the global `--json` flag —
/// per-verb output formatters key off it via `Client::json_mode`.
///
/// Repo resolution, highest priority first: the global `-r/--repo`
/// flag (`repo_override`); the `origin` remote of the git checkout
/// containing the current working directory (see
/// [`infer_repo_from_cwd`]); the `HIVE_FORGE_REPO` env var (kept as
/// a last-resort override for callers that aren't in any checkout —
/// nothing in this hive's nix config sets it, so it's opt-in only).
/// No repo resolving from any of those is an error rather than a
/// silent default — a single hardcoded fallback repo was exactly
/// what made agents assume it was "the only repo they're allowed to
/// operate on".
///
/// `json_mode` comes from the global `--json` flag — per-verb
/// output formatters key off it via `Client::json_mode`.
/// `forge_label` (from the global `-f/--forge` flag) targets a
/// dashboard-provisioned external forge account instead of the
/// internal forge — see [`resolve_credentials`].
@ -63,9 +69,15 @@ impl Client {
forge_label: Option<String>,
) -> Result<Self> {
let (base, token) = resolve_credentials(forge_label.as_deref())?;
let default_repo = repo_override
let Some(default_repo) = repo_override
.or_else(infer_repo_from_cwd)
.or_else(|| std::env::var("HIVE_FORGE_REPO").ok())
.unwrap_or_else(|| DEFAULT_REPO.to_owned());
else {
bail!(
"hive-forge: no repo specified — pass -r/--repo, run from inside a \
git checkout with an `origin` remote, or set HIVE_FORGE_REPO"
)
};
let url = url::Url::parse(&base).with_context(|| format!("parse HIVE_FORGE_URL {base}"))?;
let api = forgejo_api::sync::Forgejo::new(Auth::Token(&token), url)
@ -224,6 +236,83 @@ impl Client {
}
}
/// Infer `owner/name` from the `origin` remote of the git checkout
/// containing the current working directory. Walks upward from `$PWD`
/// looking for a `.git` directory (so it works from any subdirectory of
/// a checkout, not just the root), reads its `config` file for the
/// `[remote "origin"]` section's `url`, and extracts the repo path.
/// Returns `None` (never an error) on any failure — an unreadable cwd,
/// no `.git` directory found, no `origin` remote, or a URL that doesn't
/// parse into `owner/name` — so callers can fall through to the next
/// resolution step.
fn infer_repo_from_cwd() -> Option<String> {
let cwd = std::env::current_dir().ok()?;
let git_dir = find_git_dir(&cwd)?;
let config = std::fs::read_to_string(git_dir.join("config")).ok()?;
let url = origin_url(&config)?;
owner_repo_from_git_url(&url)
}
/// Walk upward from `start` looking for a `.git` directory. Plain
/// checkouts only — a `.git` *file* (submodules, linked worktrees)
/// isn't followed, since agents don't use either shape here.
fn find_git_dir(start: &std::path::Path) -> Option<PathBuf> {
let mut dir = start;
loop {
let candidate = dir.join(".git");
if candidate.is_dir() {
return Some(candidate);
}
dir = dir.parent()?;
}
}
/// Extract the `[remote "origin"]` section's `url` value from a git
/// config file's contents. Minimal line-based INI parsing — just
/// enough for what `git init`/`git remote add` actually write, not a
/// general config parser.
fn origin_url(config: &str) -> Option<String> {
let mut in_origin = false;
for line in config.lines() {
let trimmed = line.trim();
if let Some(section) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
in_origin = section == "remote \"origin\"";
continue;
}
if in_origin
&& let Some(rest) = trimmed.strip_prefix("url")
&& let Some(value) = rest.trim_start().strip_prefix('=')
{
return Some(value.trim().to_owned());
}
}
None
}
/// Extract `owner/name` from a git remote URL. Handles the shapes this
/// hive's remotes actually take: `http(s)://[user[:token]@]host/owner/name[.git]`.
/// Strips the scheme, then any embedded credentials (up to the last
/// `@`), then the host, then a trailing `.git`; the remaining path must
/// be exactly two non-empty segments. Anything else (SSH `git@host:` /
/// `ssh://` remotes, nested paths, malformed URLs) returns `None` —
/// this hive's forge only ever serves flat `owner/name` repos over
/// HTTP, so there's nothing else to support.
fn owner_repo_from_git_url(url: &str) -> Option<String> {
let without_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
let without_creds = without_scheme
.rsplit_once('@')
.map_or(without_scheme, |(_, rest)| rest);
let (_host, path) = without_creds.split_once('/')?;
let path = path.strip_suffix(".git").unwrap_or(path);
let mut segments = path.split('/').filter(|s| !s.is_empty());
let owner = segments.next()?;
let name = segments.next()?;
if segments.next().is_some() {
return None;
}
Some(format!("{owner}/{name}"))
}
/// Split an `owner/name` repo string into its two path segments for
/// the typed client. Errors on anything that isn't exactly
/// `owner/name` with both halves non-empty.
@ -389,7 +478,78 @@ fn check_status(resp: Response, op: &str) -> Result<Response> {
#[cfg(test)]
mod tests {
use super::split_repo;
use super::{find_git_dir, origin_url, owner_repo_from_git_url, split_repo};
#[test]
fn owner_repo_from_git_url_strips_scheme_and_creds() {
assert_eq!(
owner_repo_from_git_url(
"http://damocles:abc123token@forge.pr1ma.darkest.space/hyperhive/hyperhive.git"
),
Some("hyperhive/hyperhive".to_owned())
);
}
#[test]
fn owner_repo_from_git_url_handles_plain_https_no_creds() {
assert_eq!(
owner_repo_from_git_url("https://forge.pr1ma.darkest.space/hyperhive/hive-claude.git"),
Some("hyperhive/hive-claude".to_owned())
);
}
#[test]
fn owner_repo_from_git_url_tolerates_missing_dot_git_suffix() {
assert_eq!(
owner_repo_from_git_url("https://forge.pr1ma.darkest.space/internal/knowledge"),
Some("internal/knowledge".to_owned())
);
}
#[test]
fn owner_repo_from_git_url_rejects_nested_paths() {
assert_eq!(
owner_repo_from_git_url("https://forge.example/a/b/c.git"),
None
);
}
#[test]
fn owner_repo_from_git_url_rejects_host_only() {
assert_eq!(owner_repo_from_git_url("https://forge.example"), None);
}
#[test]
fn origin_url_finds_url_in_origin_section_only() {
let config = "[core]\n\trepositoryformatversion = 0\n[remote \"upstream\"]\n\turl = https://wrong/repo.git\n[remote \"origin\"]\n\turl = https://forge.pr1ma.darkest.space/hyperhive/hyperhive.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n";
assert_eq!(
origin_url(config),
Some("https://forge.pr1ma.darkest.space/hyperhive/hyperhive.git".to_owned())
);
}
#[test]
fn origin_url_none_without_origin_remote() {
let config = "[core]\n\trepositoryformatversion = 0\n[remote \"upstream\"]\n\turl = https://wrong/repo.git\n";
assert_eq!(origin_url(config), None);
}
#[test]
fn find_git_dir_walks_up_from_subdirectory() {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir(root.path().join(".git")).unwrap();
let nested = root.path().join("a").join("b");
std::fs::create_dir_all(&nested).unwrap();
assert_eq!(find_git_dir(&nested), Some(root.path().join(".git")));
}
#[test]
fn find_git_dir_none_when_no_ancestor_has_one() {
let root = tempfile::tempdir().unwrap();
let nested = root.path().join("a");
std::fs::create_dir_all(&nested).unwrap();
assert_eq!(find_git_dir(&nested), None);
}
#[test]
fn split_repo_accepts_owner_name() {