use anyhow::{anyhow, Result};
use std::io::Read;
use std::path::Path;
use crate::patch;
use crate::{crosshub, gitcmd, refcode, repomap, repos, schema, valid_slug};
pub(crate) fn parse_ref(s: &str) -> Result<schema::CodeRef> {
let bad = || anyhow!("invalid --ref '{s}': expected repo:path[@sha][#Lstart-Lend]");
let (repo, rest) = s.split_once(':').ok_or_else(bad)?;
let (rest, range) = match rest.split_once('#') {
Some((r, span)) => (r, Some(parse_range(span)?)), None => (rest, None),
};
let (path, sha) = match rest.split_once('@') {
Some((p, sha)) => (p, sha.to_string()),
None => (rest, "HEAD".to_string()),
};
if repo.is_empty() || path.is_empty() {
return Err(bad());
}
if !valid_slug(repo) {
return Err(anyhow!(
"invalid --ref repo '{repo}': must be a repos/<slug> key ([a-z0-9][a-z0-9-]*)"
));
}
if path.chars().any(|c| c.is_control()) {
return Err(anyhow!(
"invalid --ref path '{path}': contains control characters"
));
}
Ok(schema::CodeRef {
repo: repo.to_string(),
sha,
path: path.to_string(),
range,
content_hash: None,
ref_name: None,
ref_type: None,
commit_date: None,
dirty: false,
untracked: false,
rev: None,
base_ref: None,
fork_point: None,
patch: false,
result_hash: None,
})
}
pub(crate) fn parse_range(span: &str) -> Result<[u64; 2]> {
let bad = || anyhow!("invalid line range '{span}': expected Lstart-Lend or Lstart");
match span.split_once('-') {
Some((a, b)) => {
let a = a.trim_start_matches('L').parse().map_err(|_| bad())?;
let b = b.trim_start_matches('L').parse().map_err(|_| bad())?;
Ok([a, b])
}
None => {
let n = span.trim_start_matches('L').parse().map_err(|_| bad())?;
Ok([n, n])
}
}
}
const EMBED_MAX_LINES: usize = 200;
pub(crate) struct PinOutcome {
pub(crate) provenance: Option<String>,
pub(crate) fence: Option<String>,
pub(crate) warnings: Vec<String>,
}
#[derive(Clone)]
enum RefKind {
Branch(String),
Tag(String),
Detached,
}
impl RefKind {
fn apply(self, r: &mut schema::CodeRef) {
match self {
RefKind::Branch(n) => {
r.ref_type = Some("branch".to_string());
r.ref_name = Some(n);
}
RefKind::Tag(n) => {
r.ref_type = Some("tag".to_string());
r.ref_name = Some(n);
}
RefKind::Detached => {
r.ref_type = Some("detached".to_string());
r.ref_name = None;
}
}
}
}
fn is_full_hex(s: &str) -> bool {
(s.len() == 40 || s.len() == 64) && s.chars().all(|c| c.is_ascii_hexdigit())
}
fn looks_like_hex(s: &str) -> bool {
s.len() >= 4 && s.chars().all(|c| c.is_ascii_hexdigit())
}
fn classify_implicit_head(dir: &Path) -> RefKind {
if let Ok(o) = gitcmd::output(dir, &["symbolic-ref", "--short", "-q", "HEAD"]) {
if o.status.success() {
let name = String::from_utf8_lossy(&o.stdout).trim().to_string();
if !name.is_empty() {
return RefKind::Branch(name);
}
}
}
if let Ok(o) = gitcmd::output(dir, &["describe", "--tags", "--exact-match", "HEAD"]) {
if o.status.success() {
let name = String::from_utf8_lossy(&o.stdout).trim().to_string();
if !name.is_empty() {
return RefKind::Tag(name);
}
}
}
RefKind::Detached
}
fn classify_explicit(dir: &Path, token: &str) -> RefKind {
if looks_like_hex(token) {
return RefKind::Detached;
}
let verified = |refname: &str| {
gitcmd::output(dir, &["show-ref", "--verify", "--quiet", refname])
.map(|o| o.status.success())
.unwrap_or(false)
};
if verified(&format!("refs/heads/{token}")) {
return RefKind::Branch(token.to_string());
}
if verified(&format!("refs/tags/{token}")) {
return RefKind::Tag(token.to_string());
}
RefKind::Detached
}
struct Hunk {
old_len: u64,
new_start: u64,
new_len: u64,
}
fn parse_hunks(diff_text: &str) -> Vec<Hunk> {
let mut out = Vec::new();
let parse_span = |s: &str| -> Option<(u64, u64)> {
match s.split_once(',') {
Some((a, b)) => Some((a.parse().ok()?, b.parse().ok()?)),
None => Some((s.parse().ok()?, 1)),
}
};
for line in diff_text.lines() {
let Some(rest) = line.strip_prefix("@@ -") else { continue };
let Some(end) = rest.find(" @@") else { continue };
let Some((old, new)) = rest[..end].split_once(" +") else { continue };
let (Some((_, old_len)), Some((new_start, new_len))) = (parse_span(old), parse_span(new))
else {
continue;
};
out.push(Hunk { old_len, new_start, new_len });
}
out
}
enum GateVerdict {
Clean,
Remapped { range: [u64; 2], note: String },
Untracked { ignored: bool },
Dirty { reason: String },
}
fn integrity_gate(dir: &Path, pinned_sha: &str, path: &str, range: Option<[u64; 2]>) -> Result<GateVerdict> {
let tracked = gitcmd::output(dir, &["ls-files", "--error-unmatch", "--", path])
.map(|o| o.status.success())
.unwrap_or(false);
if !tracked {
let ignored = gitcmd::output(dir, &["check-ignore", "-q", "--", path])
.map(|o| o.status.success())
.unwrap_or(false);
return Ok(GateVerdict::Untracked { ignored });
}
let o = gitcmd::output(dir, &["diff", "-U0", pinned_sha, "--", path])?;
if !o.status.success() {
return Ok(GateVerdict::Clean); }
let hunks = parse_hunks(&String::from_utf8_lossy(&o.stdout));
if hunks.is_empty() {
if let Some([_, e]) = range {
if let Some(n) = refcode::blob_line_count(dir, pinned_sha, path) {
if e > n {
return Ok(GateVerdict::Dirty {
reason: format!("those lines aren't committed yet (the pinned commit only has {n} lines)"),
});
}
}
}
return Ok(GateVerdict::Clean);
}
let Some([s, e]) = range else {
return Ok(GateVerdict::Dirty { reason: "the file has uncommitted changes".to_string() });
};
let mut shift: i64 = 0;
for h in &hunks {
let new_end = if h.new_len == 0 { h.new_start } else { h.new_start + h.new_len - 1 };
if h.new_len > 0 && h.new_start <= e && new_end >= s {
return Ok(GateVerdict::Dirty {
reason: format!("uncommitted changes overlapping L{s}-{e}"),
});
}
if new_end < s {
shift += h.new_len as i64 - h.old_len as i64;
}
}
if shift != 0 {
let ns = (s as i64 - shift).max(1) as u64;
let ne = (e as i64 - shift).max(1) as u64;
return Ok(GateVerdict::Remapped {
range: [ns, ne],
note: format!("range remapped L{s}-{e} → L{ns}-{ne}: uncommitted insertion above"),
});
}
Ok(GateVerdict::Clean)
}
fn embed_fence(working_path: &Path, repo: &str, path: &str, sha: &str, range: Option<[u64; 2]>) -> Result<String, String> {
let text = std::fs::read_to_string(working_path)
.map_err(|e| format!("could not read {} to embed: {e}", working_path.display()))?;
let lines: Vec<&str> = text.lines().collect();
let (start, end, header_range) = match range {
Some([s, e]) => (s.max(1) as usize, e as usize, format!(" range=L{s}-{e}")),
None => (1, lines.len(), String::new()),
};
let snippet: Vec<&str> = lines
.iter()
.enumerate()
.filter(|(i, _)| {
let n = i + 1;
n >= start && n <= end
})
.map(|(_, l)| *l)
.collect();
if snippet.len() > EMBED_MAX_LINES {
return Err(format!(
"{} lines is too large to embed (> {EMBED_MAX_LINES}) — commit it instead of --allow-dirty",
snippet.len()
));
}
let mut fence = format!("```confer-ref repo={repo} path={path} sha={sha}{header_range}\n");
for l in &snippet {
fence.push_str(l);
fence.push('\n');
}
fence.push_str("```\n");
Ok(fence)
}
pub(crate) fn resolve_and_pin_ref(
repo_inv: &repos::Repos,
r: &mut schema::CodeRef,
ref_from: Option<&Path>,
allow_dirty: bool,
) -> Result<PinOutcome> {
let raw_rev = r.sha.clone(); let is_full_hex_sha = is_full_hex(&raw_rev);
let hex_token = looks_like_hex(&raw_rev);
let card_root_sha = repo_inv.get(&r.repo).and_then(|c| c.root_sha.clone());
let capture = repomap::capture_dir(&r.repo, card_root_sha.as_deref(), ref_from);
let mut warnings = Vec::new();
if let Some(cap) = capture.as_ref() {
if card_root_sha.is_some() && crosshub::is_shallow(&cap.dir) {
warnings.push(format!(
"--ref {}:{}: '{}' is a shallow clone — its root-sha identity can't be verified against \
the hub card; accepted anyway (unverifiable, not mismatched).",
r.repo, r.path, r.repo
));
}
}
if !is_full_hex_sha {
let Some(cap) = capture.as_ref() else {
if allow_dirty {
r.sha = "unresolved".to_string();
r.rev = Some(raw_rev.clone());
warnings.push(format!(
"--ref {}:{}@{}: no local clone resolvable on this machine (checked --ref-from, cwd, \
and the repo map) — stored as unresolved (--allow-dirty). Map a clone \
(`confer repos map {} <path>`) for a durable pin.",
r.repo, r.path, raw_rev, r.repo
));
return Ok(PinOutcome { provenance: None, fence: None, warnings });
}
return Err(anyhow!(
"cannot pin --ref {}:{}@{}: no local clone of '{}' is mapped on this machine (or reachable \
from your cwd/--ref-from), and a non-sha ref can't be made durable without one. Map a clone \
(`confer repos map {} <path>`), pass an explicit full commit sha (`@<40-hex>`), or use \
--allow-dirty to store it as unresolved.",
r.repo, r.path, raw_rev, r.repo, r.repo
));
};
r.sha = resolve_symbolic_sha(&cap.dir, &raw_rev, &r.repo, &r.path)?;
}
if hex_token {
r.ref_type = Some("detached".to_string());
r.ref_name = None;
}
if let Some(cap) = capture.as_ref() {
let id = capture_identity(&cap.dir, &r.sha, &raw_rev, hex_token);
if let Some(kind) = id.kind {
kind.apply(r);
}
r.commit_date = id.commit_date;
r.base_ref = id.base_ref;
r.fork_point = id.fork_point;
}
if r.content_hash.is_none() {
if let Some(cap) = capture.as_ref() {
r.content_hash = capture_content_hash(&cap.dir, &r.sha, &r.path);
}
}
let mut fence = None;
if let Some(cap) = capture.as_ref() {
let outcome = run_integrity_gate(&cap.dir, r, allow_dirty)?;
warnings.extend(outcome.warnings);
fence = outcome.fence;
}
let provenance = capture.map(|cap| build_provenance(&cap, r));
Ok(PinOutcome { provenance, fence, warnings })
}
fn resolve_symbolic_sha(dir: &Path, raw_rev: &str, repo: &str, path: &str) -> Result<String> {
let spec = format!("{raw_rev}^{{commit}}");
let o = gitcmd::output(dir, &["rev-parse", "--verify", "--quiet", &spec])?;
if !o.status.success() {
let shallow_hint = if crosshub::is_shallow(dir) {
" (shallow clone — fetch with `--unshallow` or deepen to reach it)"
} else {
""
};
return Err(anyhow!(
"cannot resolve --ref {repo}:{path}@{raw_rev} in {}{shallow_hint} (unknown revision)",
dir.display(),
));
}
Ok(String::from_utf8_lossy(&o.stdout).trim().to_string())
}
struct Identity {
kind: Option<RefKind>,
commit_date: Option<String>,
base_ref: Option<String>,
fork_point: Option<String>,
}
fn capture_identity(dir: &Path, sha: &str, raw_rev: &str, hex_token: bool) -> Identity {
let kind = if hex_token {
None
} else if raw_rev == "HEAD" {
Some(classify_implicit_head(dir))
} else {
Some(classify_explicit(dir, raw_rev))
};
let commit_date = gitcmd::output(dir, &["log", "-1", "--format=%cI", sha])
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|d| !d.is_empty());
let (base_ref, fork_point) = match &kind {
Some(RefKind::Branch(branch)) => resolve_fork_point(dir, sha, branch),
_ => (None, None),
};
Identity { kind, commit_date, base_ref, fork_point }
}
fn resolve_fork_point(dir: &Path, sha: &str, branch: &str) -> (Option<String>, Option<String>) {
let Some(base_ref) = resolve_base_ref(dir, branch) else {
return (None, None);
};
let fork_point = gitcmd::output(dir, &["merge-base", sha, &base_ref])
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|f| !f.is_empty() && f != sha);
(Some(base_ref), fork_point)
}
fn resolve_base_ref(dir: &Path, branch: &str) -> Option<String> {
let upstream = gitcmd::output(dir, &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"])
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty())
.map(|s| s.rsplit('/').next().unwrap_or(&s).to_string());
let base = upstream.or_else(|| default_branch(dir))?;
(base != branch).then_some(base)
}
fn default_branch(dir: &Path) -> Option<String> {
if let Ok(o) = gitcmd::output(dir, &["symbolic-ref", "--short", "-q", "refs/remotes/origin/HEAD"]) {
if o.status.success() {
let s = String::from_utf8_lossy(&o.stdout).trim().to_string();
let name = s.rsplit('/').next().unwrap_or(&s).to_string();
if !name.is_empty() {
return Some(name);
}
}
}
["main", "master"]
.into_iter()
.find(|cand| {
gitcmd::output(dir, &["show-ref", "--verify", "--quiet", &format!("refs/heads/{cand}")])
.map(|o| o.status.success())
.unwrap_or(false)
})
.map(str::to_string)
}
fn capture_content_hash(dir: &Path, sha: &str, path: &str) -> Option<String> {
let spec = format!("{sha}:{path}");
gitcmd::output(dir, &["rev-parse", "--verify", "--quiet", &spec])
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|oid| !oid.is_empty())
}
struct GateOutcome {
fence: Option<String>,
warnings: Vec<String>,
}
fn run_integrity_gate(dir: &Path, r: &mut schema::CodeRef, allow_dirty: bool) -> Result<GateOutcome> {
let head_sha = gitcmd::output(dir, &["rev-parse", "--verify", "--quiet", "HEAD^{commit}"])
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
if head_sha.as_deref() != Some(r.sha.as_str()) {
return Ok(GateOutcome { fence: None, warnings: Vec::new() });
}
let mut warnings = Vec::new();
let mut fence = None;
match integrity_gate(dir, &r.sha, &r.path, r.range)? {
GateVerdict::Clean => {}
GateVerdict::Remapped { range, note } => {
r.range = Some(range);
warnings.push(format!("--ref {}:{}: {note}", r.repo, r.path));
}
GateVerdict::Untracked { ignored } => {
if !allow_dirty {
let what = if ignored { "is ignored by .gitignore" } else { "is untracked" };
return Err(anyhow!(
"cannot --ref {}:{}: the file {what} — there is no committed content for peers \
to retrieve. Commit it, or pass --allow-dirty to embed the current lines instead.",
r.repo, r.path
));
}
let working = dir.join(&r.path);
match embed_fence(&working, &r.repo, &r.path, "unresolved", r.range) {
Ok(f) => {
r.sha = "unresolved".to_string();
r.content_hash = None;
r.untracked = true;
r.rev = None; warnings.push(format!(
"--ref {}:{}: {} — embedded (--allow-dirty)",
r.repo,
r.path,
if ignored { "ignored by .gitignore" } else { "untracked" }
));
fence = Some(f);
}
Err(e) => return Err(anyhow!("--ref {}:{}: {e}", r.repo, r.path)),
}
}
GateVerdict::Dirty { reason } => {
if !allow_dirty {
return Err(anyhow!(
"cannot --ref {}:{}{}: {reason} (working tree ≠ pinned commit {}). Commit the \
change so peers can retrieve what you mean, or pass --allow-dirty to embed the \
current lines into the message instead.",
r.repo,
r.path,
r.range.map(|x| format!("#L{}-{}", x[0], x[1])).unwrap_or_default(),
&r.sha[..r.sha.len().min(9)]
));
}
let working = dir.join(&r.path);
match embed_fence(&working, &r.repo, &r.path, &r.sha, r.range) {
Ok(f) => {
r.dirty = true;
warnings.push(format!("--ref {}:{}: {reason} — embedded (--allow-dirty)", r.repo, r.path));
fence = Some(f);
}
Err(e) => return Err(anyhow!("--ref {}:{}: {e}", r.repo, r.path)),
}
}
}
Ok(GateOutcome { fence, warnings })
}
fn build_provenance(cap: &repomap::Capture, r: &schema::CodeRef) -> String {
let label = match cap.source {
repomap::CaptureSource::RefFrom => "ref-from",
repomap::CaptureSource::Cwd => "cwd",
repomap::CaptureSource::Mapped => "mapped clone",
};
let short = &r.sha[..r.sha.len().min(9)];
let name = r.ref_name.as_deref().unwrap_or_else(|| r.ref_type.as_deref().unwrap_or("?"));
let date = r.commit_date.as_deref().map(|d| format!(", {}", &d[..d.len().min(10)])).unwrap_or_default();
let fork = match (r.base_ref.as_deref(), r.fork_point.as_deref()) {
(Some(base), Some(fp)) => format!(", forked from {base}@{}", &fp[..fp.len().min(7)]),
(Some(base), None) => format!(", forked from {base}"),
(None, _) => String::new(),
};
format!("pinned {short} ({name}{date}) from {} [{label}]{fork}", cap.dir.display())
}
pub(crate) fn read_patch_source(src: &str) -> Result<String> {
if src == "-" {
let mut s = String::new();
std::io::stdin().read_to_string(&mut s)?;
Ok(s)
} else {
std::fs::read_to_string(src).map_err(|e| anyhow!("--patch {src}: {e}"))
}
}
pub(crate) fn attach_patch(
repo_inv: &repos::Repos,
repo: &str,
diff: &str,
ref_from: Option<&Path>,
allow_large: bool,
) -> Result<(Vec<schema::CodeRef>, String)> {
let (validated, warning) = patch::validate_patch(diff, allow_large)?;
if let Some(warning) = warning {
crate::hint(warning);
}
let card_root_sha = repo_inv.get(repo).and_then(|c| c.root_sha.clone());
let capture = repomap::capture_dir(repo, card_root_sha.as_deref(), ref_from).ok_or_else(|| {
anyhow!(
"cannot pin --patch: no local clone of '{repo}' is mapped on this machine (or reachable \
from your cwd/--ref-from) — a patch needs a real base to apply against. Map one: \
`confer repos map {repo} <path>`."
)
})?;
let dir = &capture.dir;
let head = gitcmd::output(dir, &["rev-parse", "--verify", "--quiet", "HEAD^{commit}"])?;
if !head.status.success() {
return Err(anyhow!("cannot pin --patch: '{repo}' has no commits at HEAD in {}", dir.display()));
}
let base_sha = String::from_utf8_lossy(&head.stdout).trim().to_string();
let hashes = patch::validate_and_derive(dir, &base_sha, &validated)?;
let touched = patch::parse_diff_touched_files(diff);
if touched.is_empty() {
return Err(anyhow!(
"--patch: no files found in the diff (expected `diff --git a/… b/…` / `--- `/`+++ ` headers)"
));
}
let identity = capture_identity(dir, &base_sha, "HEAD", false);
let mut refs = Vec::with_capacity(touched.len());
for t in &touched {
let mut r = schema::CodeRef {
repo: repo.to_string(),
sha: base_sha.clone(),
path: t.path.clone(),
range: t.old_range,
content_hash: None,
ref_name: None,
ref_type: None,
commit_date: None,
dirty: false,
untracked: false,
rev: None,
base_ref: None,
fork_point: None,
patch: true,
result_hash: hashes.get(&t.path).cloned(),
};
if let Some(kind) = identity.kind.clone() {
kind.apply(&mut r);
}
r.commit_date = identity.commit_date.clone();
r.base_ref = identity.base_ref.clone();
r.fork_point = identity.fork_point.clone();
refs.push(r);
}
crate::hint(format!(
"--patch: {} file(s) against {repo}@{}",
refs.len(),
&base_sha[..base_sha.len().min(9)]
));
Ok((refs, patch::patch_fence(repo, &base_sha, diff)))
}