use std::path::Path;
use crate::gates::GitCall;
use crate::Rule;
const WRITES: &[&str] = &[
"commit",
"merge",
"rebase",
"cherry-pick",
"revert",
"am",
"pull",
"push",
];
const NO_WRITE: &[&str] = &["-h", "--help", "--abort", "--quit", "--dry-run"];
#[cfg(test)]
thread_local! {
static RUNNER: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
#[cfg(not(test))]
fn runner() -> bool {
crate::under_a_runner()
}
#[cfg(test)]
fn runner() -> bool {
RUNNER.with(std::cell::Cell::get)
}
#[must_use]
pub fn guard_line(line: &str, cwd: Option<&Path>) -> Option<Rule> {
if !runner() {
return None;
}
if crate::writes_hold_record(line, cwd) {
return Some(refusal(
"a tool command under a runner may not write a sitting record",
));
}
if crate::call_unattributed() {
if history_or_unread(line, cwd) {
crate::bookkeep::log_line("hook: unattributed call, no conversation id");
}
let calls = match crate::gates::git_calls(line, cwd) {
Ok(calls) => calls,
Err(why) => {
return Some(refusal(&format!(
"{why}, so the sitting gate cannot tell what this line writes. \
Run the git command directly"
)))
}
};
for push in calls
.iter()
.filter(|c| c.sub == "push" && writes_history(c))
{
let cfg = match crate::gates::consensus_push(Some(&push.cwd)) {
Ok(cfg) => cfg,
Err(why) => return Some(refusal(&unread_settings(&why))),
};
if let Some(dest) = crate::gates::protected_push(push, line, &cfg.branches) {
let dest = format!("may land on {dest}");
return Some(refusal(&unattributed_push(&dest, &cfg.branches)));
}
}
return None;
}
let calls = match crate::gates::git_calls(line, cwd) {
Ok(calls) => calls,
Err(why) => {
return Some(refusal(&format!(
"{why}, so the sitting gate cannot tell what this line writes. \
Run the git command directly"
)))
}
};
let first = calls.iter().find(|call| writes_history(call))?;
let issue = match live_sitting() {
Ok(issue) => issue,
Err(why) => return Some(refusal(&no_sitting(&first.sub, &why))),
};
let pushes = calls
.iter()
.filter(|c| c.sub == "push" && writes_history(c));
for push in pushes {
let cfg = match crate::gates::consensus_push(Some(&push.cwd)) {
Ok(cfg) => cfg,
Err(why) => return Some(refusal(&unread_settings(&why))),
};
let Some(dest) = crate::gates::protected_push(push, line, &cfg.branches) else {
continue;
};
if let Err(why) = crate::protected_push_stands(&issue, cfg.quorum) {
let shown = format!("may land on {dest}");
let detail = seats_wait(&shown, &cfg.branches, &issue, &why);
if only_missing_seat_vote(&why)
&& plain_branch(&dest)
.is_some_and(|branch| cloud_push_clear(line, cwd, push, branch))
{
return Some(asked(&detail));
}
return Some(refusal(&detail));
}
}
None
}
#[must_use]
pub fn no_sitting(verb: &str, why: &str) -> String {
format!(
"`git {verb}` writes history, and {why}. Run `ljos sitting ID` on the open ticket \
this work belongs to (for a subagent when the parent holds ID, `ljos sitting ID` \
opens a child ticket under ID, or run `ljos file \"TITLE\" --parent ID` then \
`ljos sitting <child-id>`), then run it again. The person at their own terminal is not gated"
)
}
#[must_use]
pub fn unattributed_push(dest: &str, branches: &[String]) -> String {
let named: Vec<String> = branches.iter().map(|b| format!("`{b}`")).collect();
format!(
"`git push` {dest}, and a runner's push onto {} waits for the seats on the ticket \
it sits on, but this call names no conversation, so it holds no live sitting and \
sits on no ticket. Pass the conversation id in the hook payload or the session \
environment, open a sitting with `ljos sitting ID`, and run it again. A push onto a \
topic branch is not held. The person at their own terminal is not gated",
named.join(", ")
)
}
#[must_use]
pub fn unread_settings(why: &str) -> String {
format!("the settings that name the protected branches do not read: {why}")
}
#[must_use]
pub fn seats_wait(dest: &str, branches: &[String], issue: &str, why: &str) -> String {
let named: Vec<String> = branches.iter().map(|b| format!("`{b}`")).collect();
format!(
"`git push` {dest}, and a runner's push onto {} waits for the seats on the ticket \
it sits on: {why}. Run `ljos consensus {issue} --no-file` to see where they stand, \
then run it again once they clear it. A push onto this seat's own topic branch \
needs only the sitting. The person at their own terminal is not gated",
named.join(", ")
)
}
pub const GATE_IDLE: &str = "not active here: no agent runner is above this shell";
#[must_use]
pub fn doctor_row(cwd: Option<&Path>) -> crate::Habitat {
let row = |state: String, ok: bool| crate::Habitat {
name: "sitting gate",
state,
ok,
};
if !runner() {
return row(
format!(
"{GATE_IDLE}, so the person's git is not gated; under one, a commit, merge, \
rebase, cherry-pick or push waits for a live sitting"
),
true,
);
}
let seat = crate::seat_name();
let cfg = match crate::gates::consensus_push(cwd) {
Ok(cfg) => cfg,
Err(why) => {
return row(
format!(
"active for {seat}, and {}, so every push onto a protected branch is refused",
unread_settings(&why)
),
false,
)
}
};
let sitting = match live_sitting() {
Ok(issue) => format!("this conversation sits on {issue}"),
Err(why) => format!(
"{why}, so a commit, merge, rebase, cherry-pick or push waits for `ljos sitting ID`"
),
};
let named: Vec<String> = cfg.branches.iter().map(|b| format!("`{b}`")).collect();
row(
format!(
"active for {seat}: {sitting}; a push onto {} also waits for a quorum of {} seats \
on that ticket",
named.join(", "),
cfg.quorum
),
true,
)
}
fn history_or_unread(line: &str, cwd: Option<&Path>) -> bool {
match crate::gates::git_calls(line, cwd) {
Ok(calls) => calls.iter().any(writes_history),
Err(_) => true,
}
}
fn writes_history(call: &GitCall) -> bool {
if !WRITES.contains(&call.sub.as_str()) {
return false;
}
if call.args.iter().any(|a| NO_WRITE.contains(&a.as_str())) {
return false;
}
!(call.sub == "push" && crate::gates::is_dry(&call.args))
}
pub fn live_sitting() -> Result<String, String> {
if crate::call_conversation_unusable() {
return Err(
"the hook payload's conversation id names no conversation, so this conversation holds no live sitting"
.into(),
);
}
let candidates = crate::live_held_issues();
if candidates.is_empty() {
return Err("this conversation holds no live sitting".into());
}
let mut first_err = None;
for (issue, hold) in candidates {
let card = match crate::issue_record(&issue) {
Ok(c) => c,
Err(e) => {
first_err.get_or_insert_with(|| {
format!("the tracker does not read {issue} ({e:#}), so its sitting cannot be checked")
});
continue;
}
};
let state = card["state"].as_str().unwrap_or("").trim();
if state.is_empty() || matches!(state, "DONE" | "CANCELLED") {
let state = if state.is_empty() {
"in no state"
} else {
state
};
first_err.get_or_insert_with(|| format!("{issue} is {state} on the tracker, not open"));
continue;
}
let by = card["claimed_by"].as_str().unwrap_or("").trim();
if !crate::same_seat_here(by, &hold.assignee)
|| !crate::same_conversation_tag(by, &hold.assignee)
{
first_err.get_or_insert_with(|| {
if by.is_empty() {
format!("the tracker names no holder on {issue}")
} else {
format!("the tracker has {issue} claimed by {by}, not by this seat")
}
});
continue;
}
return Ok(issue);
}
Err(first_err.unwrap_or_else(|| "this conversation holds no live sitting".into()))
}
fn refusal(detail: &str) -> Rule {
Rule {
pattern: "sitting-gate".into(),
verdict: "deny".into(),
reason: format!("sitting-gate: {detail}"),
}
}
fn asked(detail: &str) -> Rule {
Rule {
pattern: "sitting-gate".into(),
verdict: "ask".into(),
reason: format!("sitting-gate: {detail}"),
}
}
fn only_missing_seat_vote(why: &str) -> bool {
why.contains("no seat voted on ")
}
fn plain_branch(dest: &str) -> Option<&str> {
let name = dest.strip_prefix('`')?.strip_suffix('`')?;
(!name.is_empty() && !name.contains([' ', '\t', '\n', '`'])).then_some(name)
}
fn cloud_push_clear(line: &str, cwd: Option<&Path>, push: &GitCall, branch: &str) -> bool {
if !crate::cloud::cloud_seat() {
return false;
}
if crate::cloud_guard::cursor_push(line, cwd).is_some() {
return false;
}
let Some(url) = remote_url(push) else {
return false;
};
allowlist_has(&url, branch)
}
fn remote_url(call: &GitCall) -> Option<String> {
let name = remote_word(&call.args).unwrap_or_else(|| "origin".to_string());
if explicit_repo(&name) {
return Some(name);
}
let out = std::process::Command::new("git")
.arg("-C")
.arg(&call.cwd)
.args(["remote", "get-url", &name])
.stdin(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if !out.status.success() {
return None;
}
let url = String::from_utf8(out.stdout).ok()?;
let url = url.trim();
(!url.is_empty()).then(|| url.to_string())
}
fn explicit_repo(name: &str) -> bool {
name.contains("://")
|| name.starts_with('/')
|| name.starts_with('.')
|| name.starts_with('~')
|| name.contains(':')
}
fn remote_word(args: &[String]) -> Option<String> {
const VALUED: &[&str] = &[
"-o",
"--push-option",
"--receive-pack",
"--exec",
"--repo",
"--recurse-submodules",
];
let mut i = 0;
while i < args.len() {
let word = args[i].as_str();
if word == "--" {
return args.get(i + 1).cloned();
}
if let Some(flags) = word.strip_prefix('-') {
if !flags.is_empty()
&& !flags.starts_with('-')
&& flags.ends_with('o')
&& flags.chars().all(|c| c.is_ascii_alphabetic())
{
i += 2;
continue;
}
}
if VALUED.contains(&word) {
i += 2;
continue;
}
if word.starts_with('-') {
i += 1;
continue;
}
return Some(word.to_string());
}
None
}
fn allowlist_has(url: &str, branch: &str) -> bool {
let key = crate::cloud_hook::push_repo_key(url);
if key.is_empty() {
return false;
}
let Some(path) = crate::cloud_hook::allow_path() else {
return false;
};
let Ok(text) = std::fs::read_to_string(path) else {
return false;
};
let mut kind = "";
let mut listed = false;
let mut closed = false;
for line in text.lines() {
let Some((tag, val)) = line.split_once('\t') else {
continue;
};
match tag {
"code" if val == key => kind = "code",
"tracker" if val == key && kind != "code" => kind = "tracker",
"branch" if val == branch => listed = true,
"closed" => closed = true,
_ => {}
}
}
match kind {
"tracker" => true,
"code" => !closed && listed,
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
struct Posed;
impl Posed {
fn runner() -> Self {
RUNNER.with(|r| r.set(true));
Self
}
}
impl Drop for Posed {
fn drop(&mut self) {
RUNNER.with(|r| r.set(false));
}
}
#[test]
fn an_idle_gate_informs_and_fails_no_doctor() {
let row = doctor_row(None);
assert_eq!(crate::doctor_word(&row), "info", "{}", row.state);
assert!(crate::healthy(&[row]));
}
#[test]
fn only_a_history_write_under_a_runner_is_judged() {
assert!(guard_line("git commit -m x", None).is_none());
let _posed = Posed::runner();
for line in [
"git status",
"git log -1",
"git fetch origin",
"git push --dry-run origin main",
"git rebase --abort",
"git commit --help",
"echo commit",
] {
assert!(guard_line(line, None).is_none(), "{line}");
}
for line in [
"git commit -m x",
"git commit -n -m x",
"git merge topic",
"git rebase main",
"git cherry-pick abc",
"git revert abc",
"git am x.patch",
"git pull",
"git push origin topic",
] {
let calls = crate::gates::git_calls(line, None).unwrap();
assert!(calls.iter().any(writes_history), "{line}");
}
}
}