use crate::ExecOutcome;
use content_addressable::{ContentId, RawContentId};
use std::collections::BTreeMap;
pub const GUIDANCE: &str = "For a refactor that asks for a worktree and PR, plan one cohesive extraction per publish cycle: extract and wire it in, cargo check, commit, push, open the PR. Make further extractions follow-up commits to the same PR.";
const HINT: &str = "Publish early: a task-worktree commit matches the staged content of a passing cargo check; no governed PR creation has been observed for this branch. Push and open the PR before the next extraction (update the same PR if one already exists).";
#[derive(Debug, Default)]
pub(crate) struct State {
branches: BTreeMap<String, Branch>,
checked: Option<Checked>,
}
#[derive(Debug)]
struct Checked {
tree: ContentId,
reflog: RawContentId,
log_len: usize,
}
#[derive(Debug, Default)]
struct Branch {
pr: bool,
shown: bool,
requested: bool,
}
impl State {
fn observe(&mut self, branch: &str, pr: bool, checked_commit: bool) -> Option<&'static str> {
let state = self.branches.entry(branch.to_owned()).or_default();
state.pr |= pr;
if !state.shown && !state.pr && checked_commit {
state.shown = true;
Some(HINT)
} else {
None
}
}
}
pub(crate) fn bounded_move_hint(lines: usize) -> &'static str {
if lines > 1500 {
"\nRefactor hint: this operation exceeds 1500 lines; prefer to split it into smaller cohesive extractions, wire each into its parent, check, then publish. This is advice, not a refusal."
} else {
""
}
}
fn refactor_publication(objective: &str) -> bool {
let text = objective.to_ascii_lowercase();
text.contains("refactor")
&& text.contains("worktree")
&& (text.contains("pull request")
|| text
.split(|c: char| !c.is_ascii_alphanumeric())
.any(|word| word == "pr"))
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn after_tool(
session: &crate::worktree_adoption::WorktreeSession,
objective: &str,
name: &str,
args: &serde_json::Value,
workspace: &str,
execution: Option<ExecOutcome>,
publication: Option<&crate::git_staging::Outcome>,
read: &crate::Scope<String>,
command_directory: Option<&std::path::Path>,
) -> Option<&'static str> {
use std::path::Path;
if !matches!(
name,
"run_command" | "build_exec" | "write_file" | "edit_file" | "delete_file"
) {
return None;
}
let root = session.task_root(Path::new(workspace))?;
let admin = crate::workspace_key::discover_git_dir(&root)?;
let head = read_fact(&admin.join("HEAD"), read)?;
let branch = head.trim().strip_prefix("ref: refs/heads/")?;
crate::git_staging::validate_branch_name(branch).ok()?;
let command = if name == "build_exec" {
args["argv"]
.as_array()?
.iter()
.map(|v| v.as_str())
.collect::<Option<Vec<_>>>()?
.join(" ")
} else {
args["command"].as_str().unwrap_or("").to_owned()
};
let check = if matches!(name, "run_command" | "build_exec")
&& super::self_verify::cargo_check_directory(&command, args, workspace)
.and_then(|p| p.canonicalize().ok())
.is_some_and(|p| p.starts_with(&root))
{
Some(execution.unwrap_or(ExecOutcome::Denied))
} else {
None
};
let pr = matches!(publication, Some(crate::git_staging::Outcome::PrCreated { url })
if crate::git_staging::validate_pr_url(url).is_some())
&& command_directory
.and_then(|path| path.canonicalize().ok())
.is_some_and(|path| path.starts_with(&root));
let mut state = session.publish_early.lock().expect("publication hint lock");
let observed = state.branches.entry(branch.to_owned()).or_default();
observed.requested |= refactor_publication(objective);
observed.pr |= pr;
if observed.pr || observed.shown {
state.checked = None;
return None;
}
let requested = observed.requested;
if execution != Some(ExecOutcome::Passed)
|| matches!(name, "write_file" | "edit_file" | "delete_file")
|| (command.contains("cargo") && check != Some(ExecOutcome::Passed))
{
state.checked = None;
return None;
}
if check.is_none() && state.checked.is_none() {
return None;
}
let Some(log) = read_fact(&admin.join("logs/HEAD"), read) else {
state.checked = None;
return None;
};
let Some(tree) = checked_tree::index_tree(&root, read) else {
state.checked = None;
return None;
};
if check == Some(ExecOutcome::Passed) {
state.checked = Some(Checked {
tree,
reflog: RawContentId::from_content(log.as_bytes()),
log_len: log.len(),
});
return None;
}
let checked = state.checked.as_ref()?;
let prefix_matches = log
.as_bytes()
.get(..checked.log_len)
.is_some_and(|prefix| RawContentId::from_content(prefix) == checked.reflog);
let added = log.get(checked.log_len..).unwrap_or_default();
if tree != checked.tree
|| !prefix_matches
|| added.is_empty()
|| !added.lines().all(commit_in_reflog)
{
state.checked = None;
return None;
}
let commit = requested && checked_tree::index_is_head(&root, read);
state.observe(branch, pr, commit)
}
#[path = "publish_early_tree.rs"]
pub(super) mod checked_tree;
fn read_fact(path: &std::path::Path, read: &crate::Scope<String>) -> Option<String> {
use std::io::Read;
if !crate::permits_path(read, path.to_str()?) {
return None;
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
let file =
crate::fs_cap::WorkspaceDir::open_granted_file(path, std::path::Path::new("."), true)
.ok()?;
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
let file = {
if !std::fs::symlink_metadata(path).ok()?.is_file() {
return None;
}
std::fs::File::open(path).ok()?
};
let meta = file.metadata().ok()?;
if !meta.is_file() || meta.len() > 1024 * 1024 {
return None;
}
let mut text = String::new();
file.take(1024 * 1024 + 1).read_to_string(&mut text).ok()?;
(text.len() <= 1024 * 1024).then_some(text)
}
fn commit_in_reflog(log: &str) -> bool {
let Some((fields, message)) = log.lines().last().and_then(|line| line.split_once('\t')) else {
return false;
};
let Some(oid) = fields.split_whitespace().nth(1) else {
return false;
};
crate::git_staging::is_hex_oid(oid)
&& oid.bytes().any(|c| c != b'0')
&& ["commit: ", "commit (initial): ", "commit (amend): "]
.iter()
.any(|prefix| message.starts_with(prefix))
}
#[cfg(test)]
#[path = "publish_early_tests.rs"]
mod tests;