use crate::error::{BallError, Result};
use crate::store::Store;
use crate::task::Task;
use crate::{git, git_state};
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::Path;
const STATE_BRANCH: &str = "balls/tasks";
const TASKS_REL: &str = ".balls/tasks";
#[derive(Debug, PartialEq, Eq)]
pub enum Reconciled {
AlreadyUpToDate,
Joined { replayed: usize, renamed: usize },
}
struct LocalTask {
json: String,
notes: String,
}
pub fn reconcile(store: &Store, target: &str) -> Result<Reconciled> {
let sd = store.state_worktree_dir();
if !git::git_has_remote(&sd, target) {
return Err(BallError::Other(format!(
"no git remote `{target}`; add it (git remote add {target} <url>) and retry"
)));
}
if !git::git_fetch(&sd, target)? {
return Err(BallError::Other(format!("could not fetch from `{target}`")));
}
if !git_state::has_remote_branch(&sd, target, STATE_BRANCH) {
return Err(BallError::Other(format!(
"`{target}` has no {STATE_BRANCH} branch — nothing to join"
)));
}
let target_ref = format!("refs/remotes/{target}/{STATE_BRANCH}");
let target_sha = git::git_resolve_sha(&sd, &target_ref)?;
if git::git_is_ancestor(&sd, &target_sha, STATE_BRANCH) {
return Ok(Reconciled::AlreadyUpToDate);
}
let tdir = sd.join(TASKS_REL);
let local = read_local_tasks(&tdir)?;
let target_ids = git_state::ls_task_ids(&sd, &target_ref)?;
let mut replay: Vec<String> = Vec::new();
let mut clashes: Vec<String> = Vec::new();
for (id, lt) in &local {
if target_ids.contains(id) {
let theirs = git_state::show_file(&sd, &target_ref, &json_rel(id))?;
if theirs.as_deref() != Some(lt.json.as_str()) {
clashes.push(id.clone());
}
} else {
replay.push(id.clone());
}
}
git::git_reset_hard(&sd, &target_ref)?;
let id_len = store.load_config()?.id_length;
let mut used: BTreeSet<String> = target_ids;
used.extend(local.keys().cloned());
let mut rename: BTreeMap<String, String> = BTreeMap::new();
for id in &clashes {
let nid = fresh_id(&local[id].json, id_len, &used)?;
used.insert(nid.clone());
rename.insert(id.clone(), nid);
}
for id in replay.iter().chain(clashes.iter()) {
write_task(&tdir, id, &local[id], &rename)?;
}
if !replay.is_empty() || !clashes.is_empty() {
git::git_add_all(&sd)?;
git::git_commit(
&sd,
&format!(
"balls: remaster reconcile ({} replayed, {} renamed)",
replay.len(),
clashes.len()
),
)?;
}
Ok(Reconciled::Joined {
replayed: replay.len(),
renamed: clashes.len(),
})
}
pub fn detach(store: &Store) -> Result<()> {
git_state::reroot_orphan(
&store.state_worktree_dir(),
STATE_BRANCH,
"balls: remaster --detach (standalone)",
)
}
fn json_rel(id: &str) -> String {
format!("{TASKS_REL}/{id}.json")
}
fn read_local_tasks(tdir: &Path) -> Result<BTreeMap<String, LocalTask>> {
let mut out = BTreeMap::new();
if !tdir.exists() {
return Ok(out);
}
for entry in fs::read_dir(tdir)? {
let path = entry?.path();
let Some(id) = path
.file_name()
.and_then(|s| s.to_str())
.and_then(|s| s.strip_suffix(".json"))
.map(str::to_string)
else {
continue;
};
let notes_path = tdir.join(format!("{id}.notes.jsonl"));
out.insert(
id,
LocalTask {
json: fs::read_to_string(&path)?,
notes: fs::read_to_string(¬es_path).unwrap_or_default(),
},
);
}
Ok(out)
}
fn fresh_id(json: &str, id_len: usize, used: &BTreeSet<String>) -> Result<String> {
let t: Task = serde_json::from_str(json)
.map_err(|e| BallError::InvalidTask(format!("clash task: {e}")))?;
next_free_id(&t.title, t.created_at, id_len, |id| used.contains(id))
}
fn next_free_id(
title: &str,
start: chrono::DateTime<chrono::Utc>,
id_len: usize,
taken: impl Fn(&str) -> bool,
) -> Result<String> {
let mut ts = start;
for _ in 0..=1000 {
let id = Task::generate_id(title, ts, id_len);
if !taken(&id) {
return Ok(id);
}
ts += chrono::Duration::milliseconds(1);
}
Err(BallError::Other(
"could not generate a clash-free id after 1000 tries".into(),
))
}
fn write_task(
tdir: &Path,
id: &str,
lt: &LocalTask,
rename: &BTreeMap<String, String>,
) -> Result<()> {
let mut v: Value = serde_json::from_str(<.json)
.map_err(|e| BallError::InvalidTask(format!("{id}: {e}")))?;
let eid = rename.get(id).cloned().unwrap_or_else(|| id.to_string());
remap_refs(&mut v, &eid, rename);
let task: Task = serde_json::from_value(v)
.map_err(|e| BallError::InvalidTask(format!("{id}: {e}")))?;
task.save(&tdir.join(format!("{eid}.json")))?;
if !lt.notes.is_empty() {
fs::write(tdir.join(format!("{eid}.notes.jsonl")), <.notes)?;
}
Ok(())
}
fn remap_refs(v: &mut Value, eid: &str, rename: &BTreeMap<String, String>) {
let map = |s: &str| rename.get(s).map_or(s, String::as_str).to_string();
let Some(o) = v.as_object_mut() else { return };
o.insert("id".into(), Value::String(eid.to_string()));
if let Some(Value::String(p)) = o.get("parent") {
let np = map(p);
o.insert("parent".into(), Value::String(np));
}
remap_array(o.get_mut("depends_on"), &map);
remap_field_array(o.get_mut("links"), "target", &map);
remap_field_array(o.get_mut("closed_children"), "id", &map);
}
fn remap_array(v: Option<&mut Value>, map: &impl Fn(&str) -> String) {
let Some(Value::Array(a)) = v else { return };
for e in a.iter_mut() {
if let Value::String(s) = e {
*s = map(s);
}
}
}
fn remap_field_array(v: Option<&mut Value>, key: &str, map: &impl Fn(&str) -> String) {
let Some(Value::Array(a)) = v else { return };
for e in a.iter_mut() {
let Some(o) = e.as_object_mut() else { continue };
if let Some(Value::String(s)) = o.get(key) {
let n = map(s);
o.insert(key.into(), Value::String(n));
}
}
}
#[cfg(test)]
#[path = "remaster_tests.rs"]
mod tests;