use anyhow::{anyhow, Result};
use std::io::{IsTerminal, Read};
use std::path::Path;
use crate::patch;
use crate::projection::claimants;
use crate::schema::{self, Frontmatter, Message, TYPES};
use crate::{
config, crosshub, gitcmd, groups, hint, id_matches, is_full_ulid, is_reserved_name, now,
refcode, repomap, repos, resolve_unique, roster, secrets, short_id, store, truncate,
valid_slug, warn_if_watch_should_be_live, CreateArgs, LifecycleArgs,
};
pub(crate) struct AppendArgs {
pub(crate) msg_type: String,
pub(crate) text: Option<String>,
pub(crate) body_file: Option<String>,
pub(crate) summary: Option<String>,
pub(crate) summary_file: Option<String>,
pub(crate) to: Vec<String>,
pub(crate) cc: Vec<String>,
pub(crate) priority: Option<String>,
pub(crate) topic: Option<String>,
pub(crate) reply_to: Option<String>,
pub(crate) of: Option<String>,
pub(crate) supersedes: Option<String>,
pub(crate) from: Option<String>,
pub(crate) src: Option<String>,
pub(crate) refs: Vec<String>,
pub(crate) allow_empty_body: bool,
pub(crate) resolution: Option<String>,
pub(crate) defer: bool,
pub(crate) allow_secret: bool,
pub(crate) ref_from: Option<String>,
pub(crate) allow_dirty: bool,
pub(crate) patch: Option<String>,
pub(crate) patch_repo: Option<String>,
pub(crate) allow_large_patch: bool,
}
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)
}
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())
}
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}"))
}
}
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 {
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);
}
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)))
}
fn recipient_advisory(
root: &std::path::Path,
roster: &roster::Roster,
grps: &groups::Groups,
from: &str,
to: &[String],
cc: &[String],
summary: &str,
) {
if to.is_empty() && cc.is_empty() {
return;
}
let hub = root
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("this hub");
let mut known: Vec<&str> = roster.keys().map(String::as_str).collect();
known.sort_unstable();
let has_other_peer = known.iter().any(|r| *r != from);
let mut unknown: Vec<&str> = Vec::new(); let mut broadcast_empty = false; for t in to.iter().chain(cc.iter()) {
if t == from {
continue; }
if is_reserved_name(t) {
broadcast_empty |= !has_other_peer;
} else if let Some(members) = grps.get(t) {
broadcast_empty |= !members.iter().any(|m| m != from && roster.contains_key(m));
} else if !roster.contains_key(t) {
unknown.push(t);
}
}
unknown.sort_unstable();
unknown.dedup();
if unknown.is_empty() && !broadcast_empty {
return;
}
if !unknown.is_empty() {
let joined = if known.is_empty() {
"(none yet)".to_string()
} else {
known.join(", ")
};
let names = unknown
.iter()
.map(|r| format!("'{r}'"))
.collect::<Vec<_>>()
.join(", ");
eprintln!(
"confer: warning — {} {names} {} not joined hub '{hub}'; they won't see this until they join. Joined roles: {joined}. If you expected them here, you may be in the wrong hub.",
if unknown.len() == 1 { "role" } else { "roles" },
if unknown.len() == 1 { "has" } else { "have" },
);
}
if broadcast_empty {
let s = truncate(summary, 60);
eprintln!(
"confer: warning — you are the only role in hub '{hub}'; no other agent will receive \"{s}\" until they join."
);
}
}
pub(crate) fn cmd_lifecycle(
msg_type: &str,
a: LifecycleArgs,
resolution: Option<String>,
) -> Result<()> {
if matches!(msg_type, "done" | "error" | "blocked") && !a.of.trim().is_empty() {
let root = config::repo_root()?;
let role = config::resolve_role(a.from.clone(), &root)?;
let all = store::all_messages(&root)?;
let query = a.of.trim();
let canonical = match resolve_unique(&all, query) {
Ok(id) => Some(id.to_string()),
Err(_) if is_full_ulid(query) => Some(query.to_string()),
Err(_) => None,
};
if let Some(req_id) = canonical {
let prior = claimants(&all, &req_id);
if !prior.iter().any(|c| c == &role) {
cmd_append(AppendArgs {
msg_type: "claim".to_string(),
text: None,
body_file: None,
summary: Some("claiming".to_string()),
summary_file: None,
to: Vec::new(),
cc: Vec::new(),
priority: None,
topic: None,
reply_to: None,
of: Some(req_id.clone()),
supersedes: None,
from: a.from.clone(),
src: None,
refs: Vec::new(),
allow_empty_body: true,
resolution: None,
defer: false,
allow_secret: false,
ref_from: None,
allow_dirty: false,
patch: None,
patch_repo: None,
allow_large_patch: false,
})?;
let was = match prior.first() {
None => "was unclaimed".to_string(),
Some(owner) => format!("was claimed by {owner}"),
};
eprintln!(
"confer: auto-claimed {} ({}) as part of resolving",
short_id(&req_id),
was
);
}
}
}
let default_summary = match (msg_type, resolution.as_deref()) {
("done", Some(r)) => r.to_string(),
("done", None) => "done".to_string(),
("claim", _) => "claiming".to_string(),
("error", _) => "failed".to_string(),
("blocked", _) => "blocked/waiting".to_string(),
("defer", _) => "deferred to backlog".to_string(),
_ => msg_type.to_string(),
};
cmd_append(AppendArgs {
msg_type: msg_type.to_string(),
text: a.text, body_file: None,
summary: Some(a.summary.unwrap_or(default_summary)),
summary_file: None,
to: a.to,
cc: a.cc,
priority: None,
topic: None,
reply_to: a.reply_to,
of: Some(a.of),
supersedes: None,
from: a.from,
src: None,
refs: a.refs, allow_empty_body: true, resolution,
defer: false,
allow_secret: false,
ref_from: a.ref_from,
allow_dirty: a.allow_dirty,
patch: None,
patch_repo: None,
allow_large_patch: false,
})
}
pub(crate) fn cmd_create(msg_type: &str, a: CreateArgs, reply_to: Option<String>) -> Result<()> {
cmd_append(AppendArgs {
msg_type: msg_type.to_string(),
text: a.text,
body_file: None,
summary: Some(a.summary),
summary_file: None,
to: a.to,
cc: a.cc,
priority: a.priority,
topic: a.topic,
reply_to,
of: None,
supersedes: None,
from: a.from,
src: a.src,
refs: a.refs,
allow_empty_body: a.allow_empty_body,
resolution: None,
defer: a.defer,
allow_secret: a.allow_secret,
ref_from: a.ref_from,
allow_dirty: a.allow_dirty,
patch: a.patch,
patch_repo: a.patch_repo,
allow_large_patch: a.allow_large_patch,
})
}
pub(crate) fn cmd_suggest(a: CreateArgs) -> Result<()> {
if a.patch.is_none() {
return Err(anyhow!(
"confer suggest requires --patch <file|-> (the --worktree capture flow isn't implemented yet)"
));
}
cmd_create("request", a, None)
}
fn split_comma_targets(v: Vec<String>) -> Vec<String> {
v.into_iter()
.flat_map(|s| s.split(',').map(str::trim).map(str::to_string).collect::<Vec<_>>())
.filter(|s| !s.is_empty())
.collect()
}
pub(crate) fn cmd_append(mut a: AppendArgs) -> Result<()> {
a.to = split_comma_targets(a.to);
a.cc = split_comma_targets(a.cc);
let root = config::repo_root()?;
let role = config::resolve_role(a.from, &root)?;
warn_if_watch_should_be_live(&root, &role);
if !TYPES.contains(&a.msg_type.as_str()) {
return Err(anyhow!(
"unknown --type '{}': expected one of {:?}",
a.msg_type,
TYPES
));
}
if let Some(p) = &a.priority {
if !matches!(p.as_str(), "low" | "normal" | "high") {
return Err(anyhow!(
"invalid --priority '{p}': expected low | normal | high"
));
}
}
if a.summary.is_some() && a.summary_file.is_some() {
return Err(anyhow!(
"--summary and --summary-file are mutually exclusive — pass exactly one"
));
}
let summary = match (a.summary, &a.summary_file) {
(Some(s), None) => s,
(None, Some(path)) => {
let mut s = std::fs::read_to_string(path)
.map_err(|e| anyhow!("--summary-file {path}: {e}"))?;
if s.ends_with('\n') {
s.pop();
if s.ends_with('\r') {
s.pop();
}
}
s
}
(None, None) => {
return Err(anyhow!(
"--summary <text> or --summary-file <path> is required"
))
}
(Some(_), Some(_)) => unreachable!("checked above"),
};
let mut refs = a
.refs
.iter()
.map(|s| parse_ref(s))
.collect::<Result<Vec<_>>>()?;
let ref_from = a.ref_from.as_deref().map(Path::new);
let mut ref_fences: Vec<String> = Vec::new();
let mut ref_provenance: Vec<String> = Vec::new();
if !refs.is_empty() {
let repo_inv = repos::load(&root);
for r in refs.iter_mut() {
let outcome = resolve_and_pin_ref(&repo_inv, r, ref_from, a.allow_dirty)?;
for w in outcome.warnings {
hint(w);
}
ref_provenance.extend(outcome.provenance);
ref_fences.extend(outcome.fence);
}
}
if let Some(patch_src) = &a.patch {
let repo = a.patch_repo.clone().ok_or_else(|| {
anyhow!("--patch requires --repo <slug> (which repo the diff is against)")
})?;
if !valid_slug(&repo) {
return Err(anyhow!(
"invalid --repo '{repo}': must be a repos/<slug> key ([a-z0-9][a-z0-9-]*)"
));
}
let diff = read_patch_source(patch_src)?;
if diff.trim().is_empty() {
return Err(anyhow!("--patch {patch_src}: empty diff"));
}
let repo_inv = repos::load(&root);
let (mut derived, fence) = attach_patch(&repo_inv, &repo, &diff, ref_from, a.allow_large_patch)?;
refs.append(&mut derived);
ref_fences.push(fence);
}
let blank = |o: &Option<String>| o.as_deref().is_none_or(|s| s.trim().is_empty());
if a.msg_type == "request" && a.to.is_empty() {
return Err(anyhow!("--to <target> is required for type 'request'"));
}
if matches!(
a.msg_type.as_str(),
"claim" | "done" | "error" | "blocked" | "defer"
) && blank(&a.of)
{
return Err(anyhow!(
"--of <request-id> is required for type '{}'",
a.msg_type
));
}
if a.msg_type == "supersede" && blank(&a.supersedes) {
return Err(anyhow!(
"--supersedes <id> is required for type 'supersede'"
));
}
if summary.trim().is_empty() {
return Err(anyhow!(
"--summary must not be empty (it's the triage line peers read)"
));
}
let resolution = match a
.resolution
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
None => None,
Some(_) if a.msg_type != "done" => {
return Err(anyhow!("--as <resolution> is only valid on --type done"));
}
Some("done") => None,
Some(r @ ("wont-do" | "dropped" | "duplicate" | "obsolete")) => Some(r.to_string()),
Some(other) => {
return Err(anyhow!(
"invalid --as '{other}': expected wont-do | duplicate | obsolete"
));
}
};
if a.defer && a.msg_type != "request" {
return Err(anyhow!(
"--defer is only valid on --type request (it's a backlog marker)"
));
}
let topic = a.topic.unwrap_or_else(|| "general".to_string());
for (label, s) in [("role", role.as_str()), ("topic", topic.as_str())] {
if !valid_slug(s) {
return Err(anyhow!(
"invalid {label} '{s}': must match [a-z0-9][a-z0-9-]* (≤64 chars)"
));
}
if is_reserved_name(s) {
return Err(anyhow!(
"'{s}' is reserved (the broadcast target) and can't be a {label}"
));
}
}
for r in a.to.iter().chain(a.cc.iter()) {
if !valid_slug(r) {
return Err(anyhow!("invalid role '{r}': must match [a-z0-9][a-z0-9-]*"));
}
}
let all = store::all_messages(&root)?;
let resolve = |label: &str, v: &Option<String>| -> Result<Option<String>> {
let Some(raw) = v.as_ref() else {
return Ok(None);
};
let s = raw.trim();
if s.is_empty() {
return Ok(None);
}
match resolve_unique(&all, s) {
Ok(id) => Ok(Some(id.to_string())),
Err(_) if is_full_ulid(s) => Ok(Some(s.to_string())), Err(_) if all.iter().any(|m| id_matches(&m.front.id, s)) => {
Err(anyhow!("--{label} '{s}' is ambiguous; use the full id"))
}
Err(_) => Err(anyhow!(
"--{label} '{s}' matches no known message; fetch it first or pass the full id"
)),
}
};
let of = resolve("of", &a.of)?;
let supersedes = resolve("supersedes", &a.supersedes)?;
let reply_to = resolve("reply-to", &a.reply_to)?;
let mut to = a.to;
if to.is_empty() && !matches!(a.msg_type.as_str(), "request") {
if let Some(of_id) = &of {
if let Some(req) = all.iter().find(|m| &m.front.id == of_id) {
to = vec![req.front.from.clone()];
if matches!(a.msg_type.as_str(), "done" | "error" | "blocked" | "defer")
&& req.front.to.iter().any(|t| t == "all")
{
hint(format!(
"this closes a `--to all` request — it reaches only the author ({}). Add `--to all` (or `--cc` the responders) if the peers who replied should hear it.",
req.front.from
));
}
}
}
}
if to.is_empty() && a.cc.is_empty() {
if let Some(rt) = &reply_to {
if let Some(orig) = all.iter().find(|m| &m.front.id == rt) {
if orig.front.from != role {
to = vec![orig.front.from.clone()];
} else {
to = orig
.front
.to
.iter()
.filter(|t| t.as_str() != role && !is_reserved_name(t))
.cloned()
.collect();
}
}
}
}
if to.is_empty()
&& a.cc.is_empty()
&& (reply_to.is_some() || of.is_some() || a.msg_type == "request")
{
eprintln!(
"confer: ⚠ this {} is addressed to NO ONE — it lands on the board but reaches no inbox \
and wakes no peer. Add `--to <role>` (or `--to all`) so it's actually delivered.",
if a.msg_type == "request" { "request" } else { "reply" }
);
}
let grps = groups::load(&root);
recipient_advisory(
&root,
&roster::load(&root),
&grps,
&role,
&to,
&a.cc,
&summary,
);
if !refs.is_empty() {
let inv = repos::load(&root);
let audience: Vec<&str> = to.iter().chain(a.cc.iter()).map(String::as_str).collect();
for r in &refs {
match inv.get(&r.repo) {
None => hint(format!(
"repo '{}' isn't registered; add repos/{}.md so peers know its role/access (confer repos).",
r.repo, r.repo
)),
Some(card) if !card.access.is_empty() => {
let to_all = audience.contains(&"all");
let blocked: Vec<&str> = audience
.iter()
.copied()
.filter(|t| *t != "all" && !grps.contains_key(*t) && !repos::accessible_to(card, t))
.collect();
if to_all || !blocked.is_empty() {
let who = if to_all {
"some recipients (you targeted `all`)".to_string()
} else {
blocked.join(", ")
};
hint(format!(
"repo '{}' isn't accessible to {who}; they can't follow this pointer. Consider inlining the key content (condensed) so the message is self-contained.",
r.repo
));
}
}
_ => {}
}
}
}
if a.body_file.is_some() && a.text.is_some() {
return Err(anyhow!(
"--body-file is mutually exclusive with --text (and with stdin) — pass the body in exactly one way"
));
}
let mut body = if let Some(path) = &a.body_file {
std::fs::read_to_string(path).map_err(|e| anyhow!("--body-file {path}: {e}"))?
} else {
match a.text {
Some(t) if t == "-" => String::new(),
Some(t) => t,
None => String::new(),
}
};
if a.body_file.is_none() && body.is_empty() && !std::io::stdin().is_terminal() {
let mut s = String::new();
std::io::stdin().read_to_string(&mut s)?;
body = s.trim_end().to_string();
}
for fence in &ref_fences {
if !body.trim().is_empty() {
body.push_str("\n\n");
}
body.push_str(fence);
}
let lifecycle = matches!(
a.msg_type.as_str(),
"claim" | "done" | "error" | "supersede" | "blocked" | "defer"
);
if !a.allow_empty_body && !lifecycle && matches!(body.trim(), "" | "-" | ".") {
return Err(anyhow!(
"refusing to send an empty message body (got {:?}) — pass --text \"…\" or pipe stdin; \
use --allow-empty-body for an intentional summary-only note",
body.trim()
));
}
if !a.allow_secret {
let findings = secrets::scan(&format!("{}\n{body}", summary));
if !findings.is_empty() {
return Err(anyhow!(
"refusing to send — the message looks like it contains a secret: {}. \
The hub history is permanent and cloned by every agent. Remove it, or pass \
--allow-secret if this is a false positive.",
secrets::summarize(&findings)
));
}
}
let ctrl_body = body
.chars()
.find(|&c| c != '\n' && c != '\t' && c.is_control());
if let Some(c) = ctrl_body {
return Err(anyhow!(
"refusing to send — the body contains a control character (U+{:04X}). \
Strip terminal escape/control sequences; only newlines and tabs are allowed.",
c as u32
));
}
if let Some(c) = summary.chars().find(|c| c.is_control()) {
return Err(anyhow!(
"refusing to send — the --summary contains a control character (U+{:04X}); \
it must be a single clean line.",
c as u32
));
}
let id = ulid::Ulid::new().to_string();
let ts = now();
let msg = Message {
front: Frontmatter {
id: id.clone(),
from: role.clone(),
msg_type: a.msg_type,
ts: ts.clone(),
host: config::hostname(),
to,
cc: a.cc,
priority: a.priority,
topic: Some(topic.clone()),
reply_to,
of,
supersedes,
resolution,
defer: a.defer,
via: None,
src: a.src,
summary: Some(summary),
refs,
},
body,
};
let path = store::message_path(&root, &topic, &id, &role, &ts);
if let Some(p) = path.parent() {
std::fs::create_dir_all(p)?;
}
std::fs::write(&path, msg.to_markdown()?)?;
let synced = match gitcmd::commit_and_sync(
&root,
&role,
&path,
&format!("{role}: {} {}", msg.front.msg_type, id),
config::signing_key(&root).is_some(),
) {
Ok(gitcmd::Committed::Synced) => {
config::touch_signal(&config::hub_key(&root));
true
}
Ok(gitcmd::Committed::DeferredLocal) => {
eprintln!(
"confer: committed locally, hub push deferred; flushes on the next confer command"
);
false
}
Err(e) => {
let _ = std::fs::remove_file(&path);
return Err(anyhow!(
"did NOT send {} — not committed ({e}); the clone may be busy. Retry, e.g. `timeout 60 confer append …`.",
short_id(&id)
));
}
};
eprintln!(
"confer: sent {} ({} type, summary {} chars, body {} chars){}",
short_id(&id),
msg.front.msg_type,
msg.front.summary.as_deref().unwrap_or("").chars().count(),
msg.body.chars().count(),
if synced {
""
} else {
" [NOT synced — committed locally]"
}
);
for p in &ref_provenance {
eprintln!("confer: {p}");
}
if msg.front.msg_type == "claim" {
if let Some(req) = &msg.front.of {
if let Ok(after) = store::all_messages(&root) {
let cs = claimants(&after, req);
if cs.len() > 1 && cs.first().map(String::as_str) != Some(role.as_str()) {
eprintln!(
"confer: ⚠ contested claim — '{}' already claimed {} (owns by fold order). \
Yield (append a note and stand down) or coordinate to avoid duplicate work.",
cs[0],
short_id(req)
);
}
}
}
}
println!("{id}"); if !synced {
return Err(anyhow!(
"message {} committed locally but not synced to the hub",
short_id(&id)
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn comma_targets_split_trim_and_drop_empties() {
assert_eq!(split_comma_targets(vec!["a,b,c".into()]), vec!["a", "b", "c"]);
assert_eq!(split_comma_targets(vec!["a".into(), "b, c".into()]), vec!["a", "b", "c"]);
assert_eq!(split_comma_targets(vec!["a,,".into(), "".into()]), vec!["a"]);
assert!(split_comma_targets(vec![]).is_empty());
assert_eq!(split_comma_targets(vec!["all".into()]), vec!["all"]);
}
#[test]
fn parse_ref_handles_repo_path_sha_and_range() {
let r = parse_ref("proj:docs/spec.md@6c513dca").unwrap();
assert_eq!(r.repo, "proj");
assert_eq!(r.path, "docs/spec.md");
assert_eq!(r.sha, "6c513dca");
assert_eq!(r.range, None);
let d = parse_ref("proj:docs/spec.md").unwrap();
assert_eq!(d.sha, "HEAD");
let ranged = parse_ref("app:src/main.rs@abc#L10-L42").unwrap();
assert_eq!(ranged.path, "src/main.rs");
assert_eq!(ranged.sha, "abc");
assert_eq!(ranged.range, Some([10, 42]));
let one = parse_ref("app:src/main.rs@abc#L46").unwrap();
assert_eq!(one.range, Some([46, 46]));
assert!(parse_ref("no-colon").is_err());
assert!(parse_ref("repo:").is_err());
assert!(parse_ref(":path").is_err());
}
#[test]
fn parse_range_errors_on_malformed() {
assert_eq!(parse_range("10-42").unwrap(), [10, 42]);
assert_eq!(parse_range("L10-L42").unwrap(), [10, 42]);
assert_eq!(parse_range("46").unwrap(), [46, 46]);
assert_eq!(parse_range("L46").unwrap(), [46, 46]);
assert!(parse_range("L10-Lx").is_err()); assert!(parse_range("Lx").is_err()); assert!(parse_range("").is_err()); assert!(parse_range("99999999999999999999-2").is_err()); }
}