use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use serde_json::Value;
use crate::config::{Config, Followups, StateStore};
use crate::error::Result;
use crate::model::{Issue, IssueRef, ItemKind, PersistedState, PrRef, PrView};
use crate::proc::{self, ExecOpts};
use crate::style::{self, Style};
use crate::{bail, logdim, spar_err};
pub const FETCH_CEILING: usize = 500;
pub const STATE_MARKER: &str = "<!-- spar:state";
const WORKTREE_DIR: &str = ".spar-worktrees";
const STATE_DIR: &str = ".spar";
#[derive(Debug)]
pub struct Repo {
root: PathBuf,
pub style: Style,
pub branch_prefix: String,
pub state_store: StateStore,
pub followups: Followups,
}
impl Repo {
pub fn open(root: impl AsRef<Path>, cfg: &Config) -> Result<Self> {
let root =
std::fs::canonicalize(root.as_ref()).unwrap_or_else(|_| root.as_ref().to_path_buf());
let inside = proc::run_str(
&["git", "rev-parse", "--is-inside-work-tree"],
&ExecOpts::new().cwd(&root).check(false).timeout_secs(30),
)
.unwrap_or_default();
if inside.trim() != "true" {
bail!("not a git repository: {}", root.display());
}
let repo = Self {
root,
style: cfg.style.clone(),
branch_prefix: cfg.loop_cfg.branch_prefix.clone(),
state_store: cfg.loop_cfg.state_store,
followups: cfg.loop_cfg.followups,
};
repo.self_exclude();
Ok(repo)
}
fn self_exclude(&self) {
let git_dir = self.git_try(&["rev-parse", "--path-format=absolute", "--git-common-dir"]);
let git_dir = git_dir.trim();
if git_dir.is_empty() {
return;
}
let path = Path::new(git_dir).join("info").join("exclude");
let existing = std::fs::read_to_string(&path).unwrap_or_default();
let wanted = [format!("/{WORKTREE_DIR}/"), format!("/{STATE_DIR}/")];
let missing: Vec<&String> = wanted
.iter()
.filter(|line| !existing.lines().any(|l| l.trim() == line.as_str()))
.collect();
if missing.is_empty() {
return;
}
use std::io::Write;
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let mut block = String::new();
if !existing.is_empty() && !existing.ends_with('\n') {
block.push('\n');
}
block.push_str("\n# added by spar: its worktrees and run state\n");
for line in missing {
block.push_str(line);
block.push('\n');
}
if let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
{
let _ = file.write_all(block.as_bytes());
}
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn clean(&self, text: &str) -> Result<String> {
let out = style::scrub(text, &self.style);
let bad = style::violations(&out, &self.style);
if !bad.is_empty() {
bail!(
"style gate could not clean text ({}): {}",
bad.join(", "),
style::clip(&out, 300)
);
}
Ok(out)
}
pub fn clean_body(&self, text: &str) -> Result<String> {
self.clean(&style::body(text, &self.style))
}
pub fn clean_title(&self, text: &str) -> Result<String> {
Ok(style::title(&self.clean(text)?, &self.style))
}
fn git_opts(&self, cwd: Option<&Path>, check: bool) -> ExecOpts {
ExecOpts::new()
.cwd(cwd.unwrap_or(&self.root))
.check(check)
.timeout_secs(600)
}
pub fn git(&self, args: &[&str]) -> Result<String> {
self.git_at(None, args)
}
pub fn git_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
let mut argv = vec!["git".to_string()];
argv.extend(args.iter().map(|s| s.to_string()));
proc::run(&argv, &self.git_opts(cwd, true))
}
pub fn git_try(&self, args: &[&str]) -> String {
self.git_try_at(None, args)
}
pub fn git_try_at(&self, cwd: Option<&Path>, args: &[&str]) -> String {
let mut argv = vec!["git".to_string()];
argv.extend(args.iter().map(|s| s.to_string()));
proc::run(&argv, &self.git_opts(cwd, false)).unwrap_or_default()
}
pub fn default_branch(&self, configured: &str) -> String {
let refname = self.git_try(&["symbolic-ref", "refs/remotes/origin/HEAD"]);
match refname.trim().rsplit('/').next() {
Some(name) if !name.is_empty() => name.to_string(),
_ => configured.to_string(),
}
}
pub fn branch_for_issue(&self, issue: i64) -> String {
format!("{}issue-{issue}", self.branch_prefix)
}
pub fn branch_for_pr(&self, number: i64) -> String {
format!("{}pr-{number}", self.branch_prefix)
}
fn ledger_path(&self) -> PathBuf {
self.root.join(STATE_DIR).join("branches.json")
}
pub fn known_branches(&self) -> BTreeMap<String, BranchRecord> {
std::fs::read_to_string(self.ledger_path())
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or_default()
}
pub fn record_branch(&self, branch: &str, kind: &str, number: i64) {
let mut data = self.known_branches();
data.insert(
branch.to_string(),
BranchRecord {
kind: kind.to_string(),
number,
},
);
if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
logdim!("could not record branch {branch}: {e}");
}
}
pub fn forget_branch(&self, branch: &str) {
let mut data = self.known_branches();
if data.remove(branch).is_none() {
return;
}
if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
logdim!("could not update the branch record: {e}");
}
}
fn worktree_path(&self, name: &str) -> PathBuf {
self.root.join(WORKTREE_DIR).join(name)
}
pub fn worktree_add(&self, issue: i64, base: &str) -> Result<(PathBuf, String)> {
let branch = self.branch_for_issue(issue);
let path = self.worktree_path(&format!("issue-{issue}"));
self.git_try(&["fetch", "origin", base]);
self.git_try(&["fetch", "origin", &branch]);
let remote_branch = format!("origin/{branch}");
if self.rev_exists(&self.root, &remote_branch) {
let range = format!("origin/{base}..{remote_branch}");
let ahead: u32 = self
.git_try(&["rev-list", "--count", &range])
.trim()
.parse()
.unwrap_or(0);
if ahead > 0 {
bail!(
"origin/{branch} already has {ahead} commit(s) that are not on {base}, and no \
open pull request accounts for them. Rebuilding it would force push over \
that work.\nOpen a pull request for the branch and run `spar resume <pr>` to \
continue it, or delete it with `git push origin --delete {branch}` if it is \
stale."
);
}
}
self.worktree_remove(issue);
self.git_try(&["branch", "-D", &branch]);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
}
let path_str = path.display().to_string();
let remote_start = format!("origin/{base}");
let created = self
.git(&["worktree", "add", "-b", &branch, &path_str, &remote_start])
.or_else(|_| self.git(&["worktree", "add", "-b", &branch, &path_str, base]));
created.map_err(|e| {
spar_err!(
"could not create a worktree for issue #{issue}. {}\nIs `{base}` a real branch, \
and does `origin` exist?",
e.last_line()
)
})?;
self.record_branch(&branch, "issue", issue);
Ok((path, branch))
}
pub fn worktree_remove(&self, issue: i64) {
self.remove_worktree_at(&self.worktree_path(&format!("issue-{issue}")));
}
fn remove_worktree_at(&self, path: &Path) {
let path_str = path.display().to_string();
self.git_try(&["worktree", "remove", "--force", &path_str]);
if path.is_dir() {
let _ = std::fs::remove_dir_all(path);
}
self.git_try(&["worktree", "prune"]);
}
pub fn worktree_for_pr(&self, pr: &PrView) -> Result<(PathBuf, String)> {
let head = pr.head_ref_name.clone();
if head.trim().is_empty() {
bail!("PR #{} has no head branch to check out", pr.number);
}
let path = self.worktree_path(&format!("pr-{}", pr.number));
let local = self.branch_for_pr(pr.number);
self.git(&["fetch", "origin", &head]).map_err(|e| {
spar_err!(
"could not fetch the branch behind PR #{}: {}",
pr.number,
e.last_line()
)
})?;
self.remove_worktree_at(&path);
self.git_try(&["branch", "-D", &local]);
let path_str = path.display().to_string();
let start = format!("origin/{head}");
self.git(&["worktree", "add", "-B", &local, &path_str, &start])?;
self.record_branch(&local, "pr", pr.number);
Ok((path, head))
}
pub fn worktree_for_pr_head(&self, number: i64) -> Result<PathBuf> {
let path = self.worktree_path(&format!("review-{number}"));
let local_ref = review_ref(number);
let refspec = format!("+refs/pull/{number}/head:{local_ref}");
self.git(&["fetch", "origin", &refspec]).map_err(|e| {
spar_err!(
"could not fetch the head of PR #{number}. {}\nGitHub serves refs/pull/N/head for \
every pull request, so this usually means the number is wrong or `origin` does \
not point at the repository the PR is on.",
e.last_line()
)
})?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
}
self.remove_worktree_at(&path);
let path_str = path.display().to_string();
self.git(&["worktree", "add", "--detach", &path_str, &local_ref])?;
Ok(path)
}
pub fn release_review_worktree(&self, number: i64) {
self.remove_worktree_at(&self.worktree_path(&format!("review-{number}")));
self.git_try(&["update-ref", "-d", &review_ref(number)]);
}
pub fn release_pr_worktree(&self, number: i64) {
let path = self.worktree_path(&format!("pr-{number}"));
self.remove_worktree_at(&path);
let local = self.branch_for_pr(number);
self.git_try(&["branch", "-D", &local]);
self.forget_branch(&local);
}
pub fn base_ref(&self, cwd: &Path, base: &str) -> String {
let remote = format!("origin/{base}");
if self.rev_exists(cwd, &remote) {
return remote;
}
if self.rev_exists(cwd, base) {
logdim!("origin/{base} does not resolve, comparing against local {base}");
return base.to_string();
}
logdim!("neither origin/{base} nor {base} resolves; results will be unreliable");
remote
}
fn rev_exists(&self, cwd: &Path, refname: &str) -> bool {
let spec = format!("{refname}^{{commit}}");
!self
.git_try_at(Some(cwd), &["rev-parse", "--verify", "--quiet", &spec])
.trim()
.is_empty()
}
pub fn has_changes(&self, cwd: &Path, base: &str) -> bool {
let range = format!("{}..HEAD", self.base_ref(cwd, base));
!self
.git_try_at(Some(cwd), &["log", &range, "--oneline"])
.trim()
.is_empty()
}
pub fn diff_stat(&self, cwd: &Path, base: &str) -> String {
let range = format!("{}...HEAD", self.base_ref(cwd, base));
let full = self.git_try_at(Some(cwd), &["diff", &range, "--shortstat"]);
full.trim().to_string()
}
pub fn rewrite_commits_if_needed(&self, cwd: &Path, base: &str) -> Result<()> {
let range = format!("{}..HEAD", self.base_ref(cwd, base));
let raw = self.git_try_at(Some(cwd), &["log", &range, "--format=%H%x00%B%x1e"]);
let offenders = raw
.split('\x1e')
.filter_map(|entry| entry.split_once('\0'))
.filter(|(_, body)| !style::violations(body, &self.style).is_empty())
.count();
if offenders == 0 {
return Ok(());
}
logdim!("{offenders} commit message(s) violated style rules, rewriting");
let exe = self_binary()?;
let filter = format!("{} scrub-filter", sh_quote(&exe.display().to_string()));
let argv: Vec<String> = [
"git",
"filter-branch",
"-f",
"--msg-filter",
&filter,
&range,
]
.iter()
.map(|s| s.to_string())
.collect();
let opts = ExecOpts::new()
.cwd(cwd)
.check(false)
.timeout_secs(600)
.env("FILTER_BRANCH_SQUELCH_WARNING", "1")
.env("SPAR_BAN_EM_DASH", bool_env(self.style.ban_em_dash))
.env(
"SPAR_BAN_AI_ATTRIBUTION",
bool_env(self.style.ban_ai_attribution),
);
let _ = proc::run(&argv, &opts);
let after = self.git_try_at(Some(cwd), &["log", &range, "--format=%B"]);
if !style::violations(&after, &self.style).is_empty() {
bail!(
"commit messages still violate style rules after a rewrite. Fix them by hand in \
{} and rerun.",
cwd.display()
);
}
Ok(())
}
pub fn push(&self, cwd: &Path, branch: &str) -> Result<()> {
let refspec = format!("HEAD:{branch}");
self.git_at(
Some(cwd),
&["push", "--force-with-lease", "origin", &refspec],
)
.map(|_| ())
.map_err(|e| {
spar_err!(
"could not push to origin/{branch}. {}\nCheck push access and whether the \
branch moved under you.",
e.last_line()
)
})
}
pub fn gh(&self, args: &[&str]) -> Result<String> {
self.gh_at(None, args)
}
pub fn gh_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
let mut argv = vec!["gh".to_string()];
argv.extend(args.iter().map(|s| s.to_string()));
proc::run(
&argv,
&ExecOpts::new()
.cwd(cwd.unwrap_or(&self.root))
.timeout_secs(300),
)
}
pub fn gh_try(&self, args: &[&str]) -> String {
let mut argv = vec!["gh".to_string()];
argv.extend(args.iter().map(|s| s.to_string()));
proc::run(
&argv,
&ExecOpts::new()
.cwd(&self.root)
.check(false)
.timeout_secs(300),
)
.unwrap_or_default()
}
pub fn fetch_issues(&self, numbers: &[i64]) -> Result<Vec<Issue>> {
let mut issues = Vec::new();
for number in numbers {
let text = self
.gh(&[
"issue",
"view",
&number.to_string(),
"--json",
"number,title,body,labels,state,url",
])
.map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
let issue: Issue = serde_json::from_str(&text)
.map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
if issue.is_closed() {
crate::log!("issue #{number} is closed, skipping");
continue;
}
issues.push(issue);
}
if issues.is_empty() {
bail!("no open issues to work on");
}
Ok(issues)
}
fn open_numbers(&self, kind: &str, limit: usize) -> Result<Vec<i64>> {
#[derive(Deserialize)]
struct Row {
number: i64,
}
let text = self.gh(&[
kind,
"list",
"--state",
"open",
"--limit",
&FETCH_CEILING.to_string(),
"--json",
"number",
])?;
let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
numbers.sort_unstable();
let noun = if kind == "issue" { "issues" } else { "PRs" };
if numbers.len() >= FETCH_CEILING {
crate::log!(
"more than {FETCH_CEILING} open {noun}; only the first {FETCH_CEILING} were \
considered."
);
}
if numbers.len() > limit {
crate::log!(
"{} open {noun}, taking the {limit} lowest numbered. Raise --limit or name them \
explicitly.",
numbers.len()
);
numbers.truncate(limit);
}
Ok(numbers)
}
pub fn list_open_issues(&self, limit: usize) -> Result<Vec<i64>> {
self.open_numbers("issue", limit)
}
pub fn list_open_prs(&self, limit: usize) -> Result<Vec<i64>> {
self.open_numbers("pr", limit)
}
pub fn pr_for_branch(&self, branch: &str) -> Option<PrRef> {
let text = self.gh_try(&[
"pr",
"list",
"--head",
branch,
"--state",
"open",
"--json",
"number,url,title",
]);
serde_json::from_str::<Vec<PrRef>>(text.trim())
.ok()
.and_then(|mut v| {
if v.is_empty() {
None
} else {
Some(v.remove(0))
}
})
}
pub fn item_kind(&self, number: i64) -> Result<ItemKind> {
let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
let text = self
.gh(&[
"api",
&path,
"--jq",
"if .pull_request then \"pr\" else \"issue\" end",
])
.map_err(|e| {
spar_err!(
"no issue or pull request #{number} in this repository. {}",
e.last_line()
)
})?;
match text.trim() {
"pr" => Ok(ItemKind::Pr),
"issue" => Ok(ItemKind::Issue),
other => Err(spar_err!(
"could not tell whether #{number} is an issue or a pull request (got {other:?})"
)),
}
}
pub fn open_pr_for_issue(&self, issue: i64) -> Option<PrRef> {
if let Some(pr) = self.pr_for_branch(&self.branch_for_issue(issue)) {
return Some(pr);
}
let text = self.gh_try(&[
"pr",
"list",
"--state",
"open",
"--limit",
&FETCH_CEILING.to_string(),
"--json",
"number,url,title,closingIssuesReferences",
]);
find_linked_pr(&text, issue)
}
pub fn pr_view(&self, number: i64) -> Result<PrView> {
let text = self.gh(&[
"pr",
"view",
&number.to_string(),
"--json",
"number,url,title,headRefName,baseRefName,state,closingIssuesReferences,isCrossRepository",
])?;
serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
}
pub fn pr_state(&self, number: i64) -> String {
let text = self.gh_try(&["pr", "view", &number.to_string(), "--json", "state"]);
serde_json::from_str::<Value>(text.trim())
.ok()
.and_then(|v| v.get("state").and_then(Value::as_str).map(str::to_string))
.unwrap_or_default()
}
pub fn create_pr(
&self,
cwd: &Path,
branch: &str,
base: &str,
title: &str,
body: &str,
) -> Result<PrRef> {
let title = self.clean_title(title)?;
let body = self.clean(body)?;
self.gh_at(
Some(cwd),
&[
"pr", "create", "--base", base, "--head", branch, "--title", &title, "--body",
&body,
],
)
.map_err(|e| spar_err!("could not open a PR for {branch}. {}", e.last_line()))?;
self.pr_for_branch(branch).ok_or_else(|| {
spar_err!("PR creation reported success but none was found for {branch}")
})
}
pub fn comment_pr(&self, number: i64, body: &str) -> Result<()> {
let body = self.clean(body)?;
self.gh(&["pr", "comment", &number.to_string(), "--body", &body])
.map(|_| ())
}
pub fn comment_issue(&self, number: i64, body: &str) -> Result<()> {
let body = self.clean(body)?;
self.gh(&["issue", "comment", &number.to_string(), "--body", &body])
.map(|_| ())
}
pub fn close_issue(&self, number: i64, body: &str) -> Result<()> {
self.comment_issue(number, body)?;
let n = number.to_string();
if self
.gh(&["issue", "close", &n, "--reason", "not planned"])
.is_ok()
{
return Ok(());
}
self.gh(&["issue", "close", &n]).map(|_| ()).map_err(|e| {
spar_err!(
"commented on #{number} but could not close it: {}",
e.last_line()
)
})
}
pub fn create_issue(&self, title: &str, body: &str) -> Result<String> {
let title = self.clean_title(title)?;
let body = self.clean_body(body)?;
Ok(self
.gh(&["issue", "create", "--title", &title, "--body", &body])?
.trim()
.to_string())
}
pub fn find_issue_by_title(&self, title: &str) -> Option<String> {
#[derive(Deserialize)]
struct Row {
title: String,
url: String,
}
let needle = title.trim().to_lowercase();
if needle.is_empty() {
return None;
}
let query: String = title
.chars()
.filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
.take(120)
.collect();
let text = self.gh_try(&[
"issue",
"list",
"--state",
"all",
"--limit",
"100",
"--search",
query.trim(),
"--json",
"number,title,url",
]);
serde_json::from_str::<Vec<Row>>(text.trim())
.ok()?
.into_iter()
.find(|row| row.title.trim().to_lowercase() == needle)
.map(|row| row.url)
}
pub fn merge_pr(&self, number: i64) -> Result<()> {
let n = number.to_string();
match self.gh(&["pr", "merge", &n, "--squash", "--delete-branch"]) {
Ok(_) => Ok(()),
Err(e) => {
if self.pr_state(number) == "MERGED" {
logdim!(
"PR #{number} merged; branch cleanup did not finish: {}",
e.last_line()
);
Ok(())
} else {
Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
}
}
}
}
pub fn append_local_followup(&self, title: &str, body: &str) -> Option<String> {
let path = self.root.join(STATE_DIR).join("followups.md");
let heading = format!("## {}", title.trim());
if let Ok(existing) = std::fs::read_to_string(&path) {
if existing.contains(&heading) {
logdim!("follow-up already noted: {title}");
return None;
}
}
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
use std::io::Write;
let entry = format!("{heading}\n\n{}\n\n", body.trim());
match std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
{
Ok(mut file) => {
let _ = file.write_all(entry.as_bytes());
Some(format!("note: {}", title.trim()))
}
Err(e) => {
logdim!("could not write {}: {e}", path.display());
None
}
}
}
pub fn state_path(&self, number: i64) -> PathBuf {
self.root
.join(STATE_DIR)
.join("state")
.join(format!("pr-{number}.json"))
}
fn read_local_state(&self, number: i64) -> Option<PersistedState> {
let path = self.state_path(number);
let text = std::fs::read_to_string(&path).ok()?;
match serde_json::from_str(&text) {
Ok(state) => Some(state),
Err(_) => {
logdim!("could not read {}, starting fresh", path.display());
None
}
}
}
pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
if let Some(local) = self.read_local_state(pr.number) {
return Some(local);
}
if self.state_store.writes_pr() {
return self.read_pr_state(pr.number);
}
None
}
fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
for body in self.state_comment_bodies(number).into_iter().rev() {
if let Some(state) = parse_state_comment(&body) {
return Some(state);
}
}
None
}
pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
if self.state_store.writes_local() {
write_json_atomic(&self.state_path(number), state)?;
}
if self.state_store.writes_pr() {
self.write_pr_state(number, state)?;
}
Ok(())
}
fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
let body = format!(
"{STATE_MARKER}\n{}\n-->",
serde_json::to_string_pretty(state)?
);
if let Some(id) = self.state_comment_id(number) {
let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
let field = format!("body={body}");
self.gh_try(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"]);
return Ok(());
}
self.gh(&["pr", "comment", &number.to_string(), "--body", &body])
.map(|_| ())
}
fn comments_json(&self, number: i64) -> Vec<Value> {
let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
parse_comment_pages(&self.gh_try(&["api", "--paginate", &path]))
}
fn state_comments(&self, number: i64) -> Vec<(i64, String)> {
self.comments_json(number)
.into_iter()
.filter_map(|c| {
let body = c.get("body").and_then(Value::as_str)?.to_string();
if !body.contains("spar:state") {
return None;
}
let id = c.get("id").and_then(Value::as_i64)?;
Some((id, body))
})
.collect()
}
fn state_comment_bodies(&self, number: i64) -> Vec<String> {
self.state_comments(number)
.into_iter()
.map(|(_, b)| b)
.collect()
}
fn state_comment_id(&self, number: i64) -> Option<i64> {
self.state_comments(number).last().map(|(id, _)| *id)
}
pub fn clear_state(&self, number: i64) {
let path = self.state_path(number);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(path.with_extension("json.tmp"));
}
pub fn prune_state(&self) -> Vec<String> {
let base = self.root.join(STATE_DIR).join("state");
let Ok(entries) = std::fs::read_dir(&base) else {
return Vec::new();
};
let mut names: Vec<String> = entries
.flatten()
.filter_map(|e| e.file_name().to_str().map(str::to_string))
.filter(|n| n.starts_with("pr-") && n.ends_with(".json"))
.collect();
names.sort();
let mut removed = Vec::new();
for name in names {
let Ok(number) = name[3..name.len() - 5].parse::<i64>() else {
continue;
};
if is_finished(&self.pr_state(number)) {
let _ = std::fs::remove_file(base.join(&name));
removed.push(format!("state {name}"));
}
}
removed
}
pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
#[derive(Deserialize)]
struct Row {
number: i64,
}
let numbers = numbers.unwrap_or_else(|| {
let text = self.gh_try(&[
"pr", "list", "--state", "all", "--limit", "200", "--json", "number",
]);
serde_json::from_str::<Vec<Row>>(text.trim())
.unwrap_or_default()
.into_iter()
.map(|r| r.number)
.collect()
});
let mut removed = Vec::new();
for number in numbers {
if !is_finished(&self.pr_state(number)) {
continue;
}
for (id, _) in self.state_comments(number) {
let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
self.gh_try(&["api", "-X", "DELETE", &path, "--silent"]);
removed.push(format!("state comment on PR #{number}"));
}
}
removed
}
pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
let base = self.root.join(WORKTREE_DIR);
let mut removed = Vec::new();
if let Ok(entries) = std::fs::read_dir(&base) {
let mut names: Vec<String> = entries
.flatten()
.filter(|e| e.path().is_dir())
.filter_map(|e| e.file_name().to_str().map(str::to_string))
.collect();
names.sort();
for name in names {
if let Some(rest) = name.strip_prefix("review-") {
let number: i64 = rest.parse().unwrap_or(-1);
if !(force_all || is_finished(&self.pr_state(number))) {
continue;
}
self.release_review_worktree(number);
removed.push(name);
continue;
}
let branch = format!("{}{name}", self.branch_prefix);
if !(force_all || self.worktree_is_done(&branch)) {
continue;
}
self.remove_worktree_at(&base.join(&name));
self.git_try(&["branch", "-D", &branch]);
self.forget_branch(&branch);
removed.push(name);
}
}
if !removed.is_empty() {
self.git_try(&["worktree", "prune"]);
}
removed.extend(self.prune_branches(force_all));
removed
}
pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
let branches: Vec<String> = self.known_branches().keys().cloned().collect();
if branches.is_empty() {
return Vec::new();
}
let checked_out: Vec<String> = self
.git_try(&["worktree", "list", "--porcelain"])
.lines()
.filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
.collect();
let existing: Vec<String> = self
.git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
.lines()
.filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
.collect();
let mut removed = Vec::new();
for branch in branches {
if !existing.contains(&branch) {
self.forget_branch(&branch); continue;
}
if checked_out.contains(&branch) {
continue;
}
if !(force_all || self.worktree_is_done(&branch)) {
continue;
}
match self.git(&["branch", "-D", &branch]) {
Ok(_) => {
self.forget_branch(&branch);
removed.push(format!("branch {branch}"));
}
Err(e) => {
logdim!("could not delete {branch}: {}", e.last_line());
}
}
}
removed
}
fn worktree_is_done(&self, branch: &str) -> bool {
#[derive(Deserialize)]
struct Row {
state: String,
}
let entry = branch
.strip_prefix(self.branch_prefix.as_str())
.unwrap_or(branch);
if let Some(rest) = entry.strip_prefix("pr-") {
return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
}
if entry.starts_with("issue-") {
let text = self.gh_try(&[
"pr", "list", "--head", branch, "--state", "all", "--json", "state",
]);
let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
}
false
}
}
#[derive(Debug, Clone, serde::Serialize, Deserialize)]
pub struct BranchRecord {
pub kind: String,
pub number: i64,
}
pub fn review_ref(number: i64) -> String {
format!("refs/spar/pr-{number}")
}
pub fn is_finished(state: &str) -> bool {
matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
}
pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
}
let tmp = path.with_extension(format!(
"{}.tmp",
path.extension().and_then(|e| e.to_str()).unwrap_or("json")
));
std::fs::write(&tmp, serde_json::to_vec_pretty(value)?)
.map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
std::fs::rename(&tmp, path)
.map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
Ok(())
}
pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Row {
number: i64,
#[serde(default)]
url: String,
#[serde(default)]
title: String,
#[serde(default)]
closing_issues_references: Vec<IssueRef>,
}
serde_json::from_str::<Vec<Row>>(json.trim())
.ok()?
.into_iter()
.find(|row| {
row.closing_issues_references
.iter()
.any(|linked| linked.number == issue)
})
.map(|row| PrRef {
number: row.number,
url: row.url,
title: row.title,
})
}
pub fn parse_comment_pages(text: &str) -> Vec<Value> {
let mut out = Vec::new();
for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
match value {
Ok(Value::Array(items)) => out.extend(items),
Ok(other) => out.push(other),
Err(_) => break,
}
}
out
}
pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
let marker = body.find(STATE_MARKER)?;
let start = body[marker..].find('{')? + marker;
let end = body.rfind('}')?;
if end <= start {
return None;
}
match serde_json::from_str(&body[start..=end]) {
Ok(state) => Some(state),
Err(_) => {
logdim!("found a spar state comment but could not parse it");
None
}
}
}
pub fn self_binary() -> Result<PathBuf> {
if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
let path = PathBuf::from(path);
if proc::is_executable(&path) {
return Ok(path);
}
bail!(
"SPAR_SELF_BIN is set to {}, which is not executable",
path.display()
);
}
std::env::current_exe()
.map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
}
fn bool_env(value: bool) -> &'static str {
if value {
"1"
} else {
"0"
}
}
pub fn sh_quote(text: &str) -> String {
format!("'{}'", text.replace('\'', r"'\''"))
}
pub fn style_from_env() -> Style {
let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
Style {
ban_em_dash: flag("SPAR_BAN_EM_DASH"),
ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
..Style::permissive()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::StateStore;
use crate::model::{Ledger, Status};
fn repo_for_titles() -> Repo {
Repo {
root: PathBuf::from("/nonexistent"),
style: Style::default(),
branch_prefix: String::new(),
state_store: StateStore::Local,
followups: crate::config::Followups::Issues,
}
}
#[test]
fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
let repo = repo_for_titles();
for raw in [
"Retry loop spins \u{2014} Retry-After parses to zero",
"plain title",
" spread over\nlines ",
"\u{1F916} Generated with something",
&format!("a \u{2014} {}", "very long title ".repeat(20)),
&"x".repeat(300),
&format!("{} \u{2014} end", "y".repeat(88)),
&{
let tail = "a\u{2014}b c\u{2014}d";
let pad = Style::default().max_title_chars - tail.chars().count();
format!("{}{tail}", "w".repeat(pad))
},
] {
let once = repo.clean_title(raw).unwrap();
let twice = repo.clean_title(&once).unwrap();
assert_eq!(once, twice, "not idempotent for {raw:?}");
assert!(
once.chars().count() <= repo.style.max_title_chars,
"over budget: {once:?}"
);
assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
}
}
#[test]
fn a_title_with_an_em_dash_survives_as_readable_text() {
let repo = repo_for_titles();
assert_eq!(
"Retry loop spins, Retry-After parses to zero",
repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
.unwrap()
);
}
#[test]
fn sh_quote_survives_a_quote() {
assert_eq!(r"'a'\''b'", sh_quote("a'b"));
}
#[test]
fn sh_quote_wraps_a_space() {
assert_eq!(
"'/Applications/My App/spar'",
sh_quote("/Applications/My App/spar")
);
}
#[test]
fn finished_states_are_recognised_case_insensitively() {
assert!(is_finished("MERGED"));
assert!(is_finished("closed"));
assert!(!is_finished("OPEN"));
assert!(!is_finished(""));
}
fn state() -> PersistedState {
PersistedState {
version: 1,
round: 4,
next_actor: "codex".into(),
status: Status::Pending,
ledger: Ledger::new(),
filed: vec![],
}
}
#[test]
fn a_state_comment_round_trips() {
let body = format!(
"{STATE_MARKER}\n{}\n-->",
serde_json::to_string(&state()).unwrap()
);
let back = parse_state_comment(&body).unwrap();
assert_eq!(4, back.round);
assert_eq!("codex", back.next_actor);
}
#[test]
fn the_state_block_is_an_html_comment() {
let body = format!(
"{STATE_MARKER}\n{}\n-->",
serde_json::to_string(&state()).unwrap()
);
assert!(body.starts_with("<!--"));
assert!(body.trim_end().ends_with("-->"));
assert!(!body[..body.find('{').unwrap()].contains("-->"));
}
#[test]
fn an_unrelated_json_block_is_not_state() {
assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
}
#[test]
fn a_malformed_state_comment_is_none_not_a_panic() {
assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
}
#[test]
fn atomic_write_leaves_no_temp_file() {
let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("state").join("pr-7.json");
write_json_atomic(&path, &state()).unwrap();
let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
.unwrap()
.flatten()
.filter_map(|e| e.file_name().to_str().map(str::to_string))
.collect();
assert_eq!(vec!["pr-7.json".to_string()], files);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn atomic_write_overwrites_rather_than_accumulating() {
let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("pr-7.json");
for round in 1..4 {
let mut s = state();
s.round = round;
write_json_atomic(&path, &s).unwrap();
}
let back: PersistedState =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(3, back.round);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn style_from_env_defaults_to_enforcing() {
std::env::remove_var("SPAR_BAN_EM_DASH");
std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
let style = style_from_env();
assert!(style.ban_em_dash && style.ban_ai_attribution);
assert!(
!style.terse,
"the commit filter must not truncate a commit message"
);
}
}
#[cfg(test)]
mod comment_page_tests {
use super::*;
#[test]
fn a_single_merged_array_is_read() {
let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
assert_eq!(2, pages.len());
assert_eq!(Some(2), pages[1]["id"].as_i64());
}
#[test]
fn concatenated_pages_from_an_older_gh_are_read_too() {
let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
assert_eq!(2, pages.len());
}
#[test]
fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
let pages = parse_comment_pages(text);
assert_eq!(2, pages.len(), "{pages:?}");
assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
}
#[test]
fn empty_output_is_no_comments_not_a_panic() {
assert!(parse_comment_pages("").is_empty());
assert!(parse_comment_pages(" ").is_empty());
assert!(parse_comment_pages("[]").is_empty());
}
#[test]
fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
}
#[test]
fn state_is_found_in_the_last_matching_comment() {
let payload = |round: u32| {
format!(
"{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
)
};
let text = serde_json::to_string(&serde_json::json!([
{"id": 1, "body": payload(1)},
{"id": 2, "body": "looks good to me"},
{"id": 3, "body": payload(5)},
]))
.unwrap();
let pages = parse_comment_pages(&text);
let last = pages
.iter()
.rev()
.find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
.unwrap();
assert_eq!(5, last.round);
}
}
#[cfg(test)]
mod linked_pr_tests {
use super::*;
const REAL_PAYLOAD: &str = r#"[
{"number":14252,"title":"fix: reject leading-dash branch names",
"url":"https://github.com/cli/cli/pull/14252",
"closingIssuesReferences":[{"id":"I_kwDO","number":14238,
"repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
"url":"https://github.com/cli/cli/issues/14238"}]},
{"number":14217,"title":"another change",
"url":"https://github.com/cli/cli/pull/14217",
"closingIssuesReferences":[{"id":"I_kwDO","number":9761,
"repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
"url":"https://github.com/cli/cli/issues/9761"}]},
{"number":14200,"title":"unlinked work",
"url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
]"#;
#[test]
fn a_linked_pr_is_found_whatever_its_branch_is_called() {
let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
assert_eq!(14252, pr.number);
assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
}
#[test]
fn the_right_pr_is_picked_out_of_several() {
assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
}
#[test]
fn an_issue_nobody_is_working_on_finds_nothing() {
assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
}
#[test]
fn an_unlinked_pr_is_never_matched() {
for issue in [14200, 0, 1] {
if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
assert_ne!(14200, pr.number, "matched a PR that closes nothing");
}
}
}
#[test]
fn empty_or_broken_output_is_none_rather_than_a_panic() {
assert!(find_linked_pr("", 1).is_none());
assert!(find_linked_pr("[]", 1).is_none());
assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
assert!(find_linked_pr("[{\"number\":", 1).is_none());
}
#[test]
fn pr_view_reads_the_cross_repository_flag() {
let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
"baseRefName":"main","state":"OPEN",
"closingIssuesReferences":[],"isCrossRepository":true}"#;
let pr: PrView = serde_json::from_str(json).unwrap();
assert!(pr.is_cross_repository);
assert!(pr.is_open());
let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
assert!(
!serde_json::from_str::<PrView>(&same_repo)
.unwrap()
.is_cross_repository
);
}
}