use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use crate::git;
const TOKEN_DIR: &str = "balls-seen";
pub(crate) fn mint(invocation: &Path, store: &Path, id: &str) {
let _ = try_mint(invocation, store, id);
}
fn try_mint(invocation: &Path, store: &Path, id: &str) -> io::Result<()> {
match head_blob(store, id) {
Some(blob) => write_token(&mint_home(invocation, store)?, id, &blob),
None => Ok(()), }
}
pub(crate) fn guard(store: &Path, invocation: &Path, id: &str, actor: &str) -> io::Result<Vec<PathBuf>> {
let cur = head_blob(store, id).ok_or_else(|| {
io::Error::other(format!("close {id}: tasks/{id}.md is not at the store tip — the store checkout is out of step; run `bl prime`"))
})?;
let anchor = anchor_commit(store, id, actor);
if anchor.as_ref().is_some_and(|sha| blob_at(store, sha, id).as_deref() == Some(cur.as_str())) {
return Ok(Vec::new()); }
let tokens: Vec<PathBuf> = union(store, invocation, id)?
.into_iter()
.map(|home| home.join(TOKEN_DIR).join(id))
.filter(|p| fs::read_to_string(p).is_ok_and(|c| c.trim() == cur))
.collect();
if !tokens.is_empty() {
return Ok(tokens);
}
let diff = unseen_diff(store, anchor.as_deref(), id)?;
write_token(&mint_home(invocation, store)?, id, &cur)?;
Err(io::Error::other(format!(
"close {id}: tasks/{id}.md changed since your last touch of it — the unseen diff:\n\n{diff}\n\
the diff above is now acknowledged; re-run `bl close {id}` to seal exactly this content \
(a further edit refuses again with its own diff)"
)))
}
pub(crate) fn consume(tokens: &[PathBuf]) {
for token in tokens {
let _ = fs::remove_file(token);
}
}
pub(crate) fn sweep(store: &Path) -> io::Result<()> {
let dir = gitdir(store)?.join(TOKEN_DIR);
let Ok(entries) = fs::read_dir(&dir) else {
return Ok(()); };
for entry in entries {
let entry = entry?;
let id = entry.file_name().to_string_lossy().into_owned();
if !crate::taskfile::exists(store, &id) {
fs::remove_file(entry.path())?;
}
}
Ok(())
}
fn mint_home(invocation: &Path, store: &Path) -> io::Result<PathBuf> {
match work_gitdir(invocation) {
Some(dir) => Ok(dir),
None => gitdir(store),
}
}
fn union(store: &Path, invocation: &Path, id: &str) -> io::Result<Vec<PathBuf>> {
let mut homes = vec![gitdir(store)?];
let common = rev_parse(invocation, "--git-common-dir").map(|c| c.join("worktrees").join(id));
for home in [common, work_gitdir(invocation)].into_iter().flatten() {
if !homes.contains(&home) {
homes.push(home);
}
}
Ok(homes)
}
fn gitdir(dir: &Path) -> io::Result<PathBuf> {
rev_parse(dir, "--git-dir").ok_or_else(|| io::Error::other(format!("{}: not a git checkout — run `bl prime`", dir.display())))
}
fn work_gitdir(invocation: &Path) -> Option<PathBuf> {
let head = git::run(invocation, &["rev-parse", "--abbrev-ref", "HEAD"], None).ok()?;
if !head.trim().starts_with("work/") {
return None;
}
let (dir, common) = (rev_parse(invocation, "--git-dir")?, rev_parse(invocation, "--git-common-dir")?);
(dir != common).then_some(dir)
}
fn rev_parse(dir: &Path, what: &str) -> Option<PathBuf> {
let out = git::run(dir, &["rev-parse", "--path-format=absolute", what], None).ok()?;
Some(PathBuf::from(out.trim()))
}
fn head_blob(store: &Path, id: &str) -> Option<String> {
blob_at(store, "HEAD", id)
}
fn blob_at(store: &Path, treeish: &str, id: &str) -> Option<String> {
let out = git::run(store, &["rev-parse", &format!("{treeish}:tasks/{id}.md")], None).ok()?;
Some(out.trim().to_string())
}
fn anchor_commit(store: &Path, id: &str, actor: &str) -> Option<String> {
let path = format!("tasks/{id}.md");
let format = "--format=%H%x1f%(trailers:key=bl-actor,valueonly,separator=%x2C)";
let log = git::run(store, &["log", format, "--", &path], None).ok()?;
log.lines().find_map(|line| {
let (sha, by) = line.split_once('\u{1f}')?;
(by.trim() == actor).then(|| sha.to_string())
})
}
fn unseen_diff(store: &Path, anchor: Option<&str>, id: &str) -> io::Result<String> {
let base = match anchor {
Some(sha) => sha.to_string(),
None => git::run(store, &["mktree"], Some(""))?.trim().to_string(),
};
git::run(store, &["diff", &base, "HEAD", "--", &format!("tasks/{id}.md")], None)
}
fn write_token(home: &Path, id: &str, blob: &str) -> io::Result<()> {
let dir = home.join(TOKEN_DIR);
fs::create_dir_all(&dir)?;
fs::write(dir.join(id), format!("{blob}\n"))
}
#[cfg(test)]
#[path = "seen_tests.rs"]
mod tests;