use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use tempfile::TempDir;
const BIN: &str = env!("CARGO_BIN_EXE_deciduous");
struct Team {
root: TempDir,
}
struct Dev {
name: String,
dir: PathBuf,
home: PathBuf,
path: String,
}
fn base_path() -> String {
let git = Command::new("sh")
.args(["-c", "command -v git"])
.output()
.unwrap();
let git = String::from_utf8_lossy(&git.stdout).trim().to_string();
let git_dir = Path::new(&git).parent().unwrap().display().to_string();
assert!(
!Path::new(&git_dir).join("deciduous").exists(),
"git shares a directory with an installed deciduous ({git_dir})"
);
format!("{git_dir}:/usr/bin:/bin:/usr/sbin:/sbin")
}
fn with_bin_on_path() -> String {
let bin_dir = Path::new(BIN).parent().unwrap();
format!("{}:{}", bin_dir.display(), base_path())
}
impl Team {
fn new() -> Self {
let root = TempDir::new().unwrap();
let origin = root.path().join("origin.git");
let out = Command::new("git")
.args(["init", "--bare", "-q", "-b", "main"])
.arg(&origin)
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
Self { root }
}
fn origin(&self) -> PathBuf {
self.root.path().join("origin.git")
}
fn founder(&self, name: &str) -> Dev {
let dev = self.dev(name);
fs::create_dir_all(&dev.dir).unwrap();
dev.git(&["init", "-q", "-b", "main"]);
dev.configure();
fs::create_dir_all(dev.dir.join(".deciduous")).unwrap();
fs::write(
dev.dir.join(".gitattributes"),
".deciduous/graph.json merge=deciduous linguist-generated=true\n",
)
.unwrap();
fs::write(
dev.dir.join(".gitignore"),
".deciduous/*.db\n.deciduous/*.db-*\n",
)
.unwrap();
dev.ok(&["sync"]);
dev.commit_all("setup");
dev.git(&["remote", "add", "origin", self.origin().to_str().unwrap()]);
dev.git(&["push", "-q", "-u", "origin", "main"]);
dev
}
fn join(&self, name: &str) -> Dev {
let dev = self.dev(name);
let out = Command::new("git")
.args(["clone", "-q"])
.arg(self.origin())
.arg(&dev.dir)
.env("HOME", &dev.home)
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
dev.configure();
dev.ok(&["sync"]);
dev
}
fn dev(&self, name: &str) -> Dev {
let home = self.root.path().join("homes").join(name);
fs::create_dir_all(&home).unwrap();
Dev {
name: name.to_string(),
dir: self.root.path().join(name),
home,
path: with_bin_on_path(),
}
}
}
impl Dev {
fn clone_handle(&self) -> Dev {
Dev {
name: self.name.clone(),
dir: self.dir.clone(),
home: self.home.clone(),
path: self.path.clone(),
}
}
fn configure(&self) {
self.git(&["config", "user.name", &self.name]);
self.git(&[
"config",
"user.email",
&format!("{}@example.test", self.name),
]);
self.git(&["config", "pull.rebase", "false"]);
self.git(&["config", "commit.gpgsign", "false"]);
}
fn cmd(&self, program: &str) -> Command {
let mut c = Command::new(program);
c.current_dir(&self.dir)
.env("HOME", &self.home)
.env("PATH", &self.path)
.env("DECIDUOUS_NO_SERVER", "1")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env_remove("DECIDUOUS_DB_PATH")
.env_remove("DECIDUOUS_MCP_TOKEN")
.env_remove("DECIDUOUS_API_TOKEN")
.env_remove("XDG_CONFIG_HOME")
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE");
c
}
fn run(&self, args: &[&str]) -> Output {
self.cmd(BIN).args(args).output().expect("run deciduous")
}
fn ok(&self, args: &[&str]) -> String {
let out = self.run(args);
assert!(
out.status.success(),
"[{}] deciduous {:?} failed:\n{}\n{}",
self.name,
args,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).to_string()
}
fn fails(&self, args: &[&str]) -> (String, String) {
let out = self.run(args);
assert!(
!out.status.success(),
"[{}] deciduous {:?} should have failed:\n{}",
self.name,
args,
String::from_utf8_lossy(&out.stdout)
);
(
String::from_utf8_lossy(&out.stdout).to_string(),
String::from_utf8_lossy(&out.stderr).to_string(),
)
}
fn git_out(&self, args: &[&str]) -> Output {
self.cmd("git").args(args).output().expect("run git")
}
fn git(&self, args: &[&str]) -> String {
let out = self.git_out(args);
assert!(
out.status.success(),
"[{}] git {:?} failed:\n{}\n{}",
self.name,
args,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).to_string()
}
fn commit_all(&self, msg: &str) {
self.git(&[
"add",
".gitattributes",
".gitignore",
".deciduous/graph.json",
]);
let out = self.git_out(&["commit", "-q", "-m", msg]);
let text = String::from_utf8_lossy(&out.stdout);
assert!(
out.status.success() || text.contains("nothing to commit"),
"[{}] commit failed: {}{}",
self.name,
text,
String::from_utf8_lossy(&out.stderr)
);
}
fn commit_graph(&self, msg: &str) {
self.git(&["add", ".deciduous/graph.json"]);
self.git(&["commit", "-q", "-m", msg]);
}
fn graph_path(&self) -> PathBuf {
self.dir.join(".deciduous").join("graph.json")
}
fn graph_text(&self) -> String {
fs::read_to_string(self.graph_path()).unwrap()
}
fn doc(&self) -> Value {
serde_json::from_str(&self.graph_text()).unwrap()
}
fn write_doc(&self, doc: &Value) {
let mut s = serde_json::to_string_pretty(doc).unwrap();
s.push('\n');
fs::write(self.graph_path(), s).unwrap();
}
fn add(&self, node_type: &str, title: &str, extra: &[&str]) -> i32 {
let mut args = vec!["add", node_type, title];
args.extend_from_slice(extra);
let out = self.ok(&args);
out.split_whitespace()
.nth(2)
.and_then(|s| s.parse().ok())
.unwrap_or_else(|| panic!("no id in: {out}"))
}
fn change_id(&self, id: i32) -> String {
let shown: Value =
serde_json::from_str(&self.ok(&["show", &id.to_string(), "--json"])).unwrap();
shown["change_id"]
.as_str()
.or_else(|| shown["node"]["change_id"].as_str())
.unwrap_or_else(|| panic!("no change_id in {shown}"))
.to_string()
}
fn nodes(&self) -> Vec<Value> {
let graph: Value = serde_json::from_str(&self.ok(&["graph"])).unwrap();
graph["nodes"].as_array().cloned().unwrap_or_default()
}
fn node_by_title(&self, title: &str) -> Option<Value> {
self.nodes().into_iter().find(|n| n["title"] == title)
}
}
fn node_record(change_id: &str, title: &str, updated_at: &str) -> Value {
serde_json::json!({
"change_id": change_id,
"node_type": "goal",
"title": title,
"status": "pending",
"created_at": "2026-01-01T00:00:00+00:00",
"updated_at": updated_at,
"author": "teammate",
})
}
#[test]
fn a_digit_only_change_id_prefix_is_never_silently_a_local_id() {
let team = Team::new();
let alice = team.founder("alice");
let one = alice.add("goal", "local one", &[]);
let two = alice.add("goal", "local two", &[]);
assert_eq!((one, two), (1, 2));
let mut doc = alice.doc();
let nodes = doc["nodes"].as_object_mut().unwrap();
for (cid, title) in [
("0002abcd-1111-4111-8111-111111111111", "teammate target"),
("37650685-2222-4222-8222-222222222222", "all digits"),
("c0ffee00", "short id"),
("c0ffee00-4444-4444-8444-444444444444", "long id"),
] {
nodes.insert(
cid.into(),
node_record(cid, title, "2026-01-02T00:00:00+00:00"),
);
}
alice.write_doc(&doc);
let out = alice.ok(&["sync"]);
assert!(out.contains("4 nodes imported"), "{out}");
let (_, err) = alice.fails(&["delete", "0002"]);
assert!(err.contains("#2") && err.contains("local two"), "{err}");
assert!(
err.contains("0002abcd") && err.contains("teammate target"),
"{err}"
);
assert!(alice.node_by_title("local two").is_some());
assert!(alice.node_by_title("teammate target").is_some());
let shown = alice.ok(&["show", "37650685"]);
assert!(shown.contains("all digits"), "{shown}");
assert!(alice.ok(&["show", "#2"]).contains("local two"));
assert!(alice.ok(&["show", "2"]).contains("local two"));
let (_, err) = alice.fails(&["show", "9999"]);
assert!(err.contains("9999"), "{err}");
let shown = alice.ok(&["show", "c0ffee00"]);
assert!(shown.contains("short id"), "{shown}");
let shown = alice.ok(&["show", "c0ffee00-4444"]);
assert!(shown.contains("long id"), "{shown}");
}
#[test]
fn fields_this_version_does_not_know_survive_every_write_and_merge() {
let team = Team::new();
let alice = team.founder("alice");
let goal = alice.add("goal", "Ship it", &[]);
let action = alice.add("action", "Write it", &[]);
alice.ok(&["link", &goal.to_string(), &action.to_string(), "-r", "how"]);
let goal_cid = alice.change_id(goal);
let mut doc = alice.doc();
doc["documents"] = serde_json::json!({"d1": {"node": goal_cid, "sha": "abc"}});
let node = &mut doc["nodes"][&goal_cid];
node["priority"] = "p1".into();
node["reviewers"] = serde_json::json!(["bob", "carol"]);
let edge_key = doc["edges"]
.as_object()
.unwrap()
.keys()
.next()
.unwrap()
.clone();
doc["edges"][&edge_key]["confidence_note"] = "strong".into();
alice.write_doc(&doc);
alice.commit_graph("fields from the future");
alice.git(&["push", "-q"]);
alice.add("observation", "Unrelated", &[]);
alice.ok(&["status", &goal.to_string(), "active"]);
alice.ok(&["prompt", &goal.to_string(), "why"]);
let doc = alice.doc();
assert_eq!(doc["nodes"][&goal_cid]["priority"], "p1", "{doc:#}");
assert_eq!(doc["nodes"][&goal_cid]["reviewers"][1], "carol", "{doc:#}");
assert_eq!(doc["nodes"][&goal_cid]["status"], "active", "{doc:#}");
assert_eq!(
doc["edges"][&edge_key]["confidence_note"], "strong",
"{doc:#}"
);
assert_eq!(doc["documents"]["d1"]["sha"], "abc", "{doc:#}");
alice.ok(&["sync"]);
let out = alice.ok(&["sync", "--check"]);
assert!(out.contains("already agree"), "{out}");
alice.commit_graph("unrelated work");
let bob = team.join("bob");
bob.ok(&["status", &goal_cid[..8], "completed"]);
bob.add("goal", "Bob's own", &[]);
bob.commit_graph("bob");
bob.git(&["push", "-q"]);
alice.git(&["pull", "-q", "--no-edit"]);
let doc = alice.doc();
assert_eq!(doc["nodes"][&goal_cid]["priority"], "p1", "{doc:#}");
assert_eq!(
doc["edges"][&edge_key]["confidence_note"], "strong",
"{doc:#}"
);
assert_eq!(doc["documents"]["d1"]["sha"], "abc", "{doc:#}");
alice.ok(&["sync"]);
let doc = alice.doc();
assert_eq!(doc["nodes"][&goal_cid]["priority"], "p1", "{doc:#}");
assert_eq!(doc["nodes"][&goal_cid]["status"], "completed", "{doc:#}");
assert_eq!(doc["documents"]["d1"]["sha"], "abc", "{doc:#}");
}
fn status_of(dev: &Dev, reference: &str) -> String {
let shown: Value = serde_json::from_str(&dev.ok(&["show", reference, "--json"])).unwrap();
shown["status"]
.as_str()
.or_else(|| shown["node"]["status"].as_str())
.unwrap_or_else(|| panic!("no status in {shown}"))
.to_string()
}
#[test]
fn a_local_edit_is_never_reverted_by_a_future_updated_at() {
let team = Team::new();
let alice = team.founder("alice");
let dated = alice.add("action", "dated", &["--date", "2099-01-01"]);
let out = alice.ok(&["status", &dated.to_string(), "completed"]);
assert!(out.to_lowercase().contains("updated"), "{out}");
alice.ok(&["prompt", &dated.to_string(), "the real prompt"]);
let out = alice.ok(&["sync"]);
assert_eq!(status_of(&alice, &dated.to_string()), "completed", "{out}");
let rec = alice.doc()["nodes"][alice.change_id(dated)].clone();
assert_eq!(rec["status"], "completed", "{rec:#}");
assert_eq!(rec["metadata"]["prompt"], "the real prompt", "{rec:#}");
let out = alice.ok(&["sync", "--check"]);
assert!(out.contains("already agree"), "{out}");
let doomed = alice.add("action", "doomed", &["--date", "2099-01-01"]);
let doomed_cid = alice.change_id(doomed);
alice.ok(&["delete", &doomed.to_string()]);
alice.ok(&["sync"]);
assert!(
alice.node_by_title("doomed").is_none(),
"deleted node came back"
);
assert!(
alice.doc()["nodes"][&doomed_cid]["deleted_at"].is_string(),
"{:#}",
alice.doc()["nodes"][&doomed_cid]
);
let goal = alice.add("goal", "Shared goal", &[]);
let goal_cid = alice.change_id(goal);
alice.commit_graph("goal");
alice.git(&["push", "-q"]);
let bob = team.join("bob");
let mut doc = bob.doc();
let tomorrow = (chrono_like_now_plus_days(1)).to_string();
doc["nodes"][&goal_cid]["status"] = "active".into();
doc["nodes"][&goal_cid]["updated_at"] = tomorrow.clone().into();
bob.write_doc(&doc);
bob.ok(&["sync"]);
bob.commit_graph("skewed clock");
bob.git(&["push", "-q"]);
alice.git(&["pull", "-q", "--no-edit"]);
alice.ok(&["sync"]);
assert_eq!(status_of(&alice, &goal.to_string()), "active");
alice.ok(&["status", &goal.to_string(), "superseded"]);
alice.ok(&["sync"]);
assert_eq!(status_of(&alice, &goal.to_string()), "superseded");
alice.commit_graph("supersede");
alice.git(&["push", "-q"]);
bob.git(&["pull", "-q", "--no-edit"]);
bob.ok(&["sync"]);
assert_eq!(status_of(&bob, &goal_cid[..8]), "superseded");
}
fn chrono_like_now_plus_days(days: i64) -> String {
(chrono::Utc::now() + chrono::Duration::days(days)).to_rfc3339()
}
fn titles(dev: &Dev) -> Vec<String> {
let mut t: Vec<String> = dev
.nodes()
.iter()
.map(|n| n["title"].as_str().unwrap().to_string())
.collect();
t.sort();
t
}
fn unmerged(dev: &Dev) -> String {
dev.git(&["ls-files", "-u", "--", ".deciduous/graph.json"])
}
#[test]
fn a_merge_driver_that_is_not_on_path_is_caught_and_finished_by_sync() {
let team = Team::new();
let alice = team.founder("alice");
let shared = alice.add("goal", "Shared", &[]);
alice.commit_graph("shared");
alice.git(&["push", "-q"]);
let bob = team.join("bob");
bob.ok(&["status", &alice.change_id(shared)[..8], "completed"]);
bob.add("goal", "Bob only", &[]);
bob.commit_graph("bob");
bob.git(&["push", "-q"]);
alice.add("goal", "Alice only", &[]);
alice.commit_graph("alice");
let gui = Dev {
path: base_path(),
..alice.clone_handle()
};
let out = gui.git_out(&["pull", "--no-edit"]);
assert!(!out.status.success());
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(text.contains("CONFLICT"), "{text}");
assert!(!alice.graph_text().contains("<<<<<<<"));
assert!(!unmerged(&alice).is_empty());
let (out, _) = alice.fails(&["sync", "--check"]);
assert!(out.contains("unmerged"), "{out}");
let out = alice.ok(&["sync"]);
assert!(out.contains("unmerged"), "{out}");
assert!(unmerged(&alice).is_empty(), "still unmerged after sync");
assert_eq!(
titles(&alice),
["Alice only", "Bob only", "Shared"],
"{out}"
);
assert_eq!(status_of(&alice, &shared.to_string()), "completed");
alice.git(&["commit", "-q", "--no-edit"]);
let out = alice.ok(&["sync", "--check"]);
assert!(out.contains("already agree"), "{out}");
}
#[test]
fn a_committed_conflict_does_not_break_every_later_merge() {
let team = Team::new();
let alice = team.founder("alice");
alice.add("goal", "Base", &[]);
alice.commit_graph("base");
alice.git(&["push", "-q"]);
let carol = team.join("carol");
carol.git(&["config", "--remove-section", "merge.deciduous"]);
let bob = team.join("bob");
bob.ok(&["status", "1", "active"]);
bob.add("goal", "Bob first", &[]);
bob.commit_graph("bob first");
bob.git(&["push", "-q"]);
carol.ok(&["status", "1", "completed"]);
carol.add("goal", "Carol first", &[]);
carol.commit_graph("carol first");
let out = carol.git_out(&["pull", "--no-edit"]);
assert!(
!out.status.success(),
"{}",
String::from_utf8_lossy(&out.stdout)
);
assert!(
carol.graph_text().contains("<<<<<<<"),
"{}{}\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
carol.graph_text()
);
carol.git(&["add", ".deciduous/graph.json"]);
carol.git(&["commit", "-q", "--no-edit"]);
carol.git(&["push", "-q"]);
bob.git(&["pull", "-q", "--no-edit"]);
bob.ok(&["sync"]);
bob.add("goal", "Bob after", &[]);
bob.commit_graph("bob after");
alice.git(&["pull", "-q", "--no-edit"]);
alice.ok(&["sync"]);
alice.add("goal", "Alice after", &[]);
alice.commit_graph("alice after");
alice.git(&["push", "-q"]);
let out = bob.git_out(&["pull", "--no-edit"]);
assert!(
out.status.success(),
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
bob.ok(&["sync"]);
assert_eq!(
titles(&bob),
[
"Alice after",
"Base",
"Bob after",
"Bob first",
"Carol first"
]
);
}
#[test]
fn sync_check_fails_while_edges_wait_or_records_are_unreadable() {
let team = Team::new();
let alice = team.founder("alice");
let goal = alice.add("goal", "Here", &[]);
let goal_cid = alice.change_id(goal);
let out = alice.ok(&["sync", "--check"]);
assert!(out.contains("already agree"), "{out}");
let clean = alice.doc();
let mut doc = clean.clone();
let missing = "99999999-0000-4000-8000-000000000000";
let id = deciduous::edge_id(&goal_cid, missing, "leads_to");
doc["edges"][&id] = serde_json::json!({
"edge_id": id,
"from_change_id": goal_cid,
"to_change_id": missing,
"edge_type": "leads_to",
"created_at": "2026-01-01T00:00:00+00:00",
});
alice.write_doc(&doc);
let (out, _) = alice.fails(&["sync", "--check"]);
assert!(out.contains("waits for node 99999999"), "{out}");
let out = alice.ok(&["sync"]);
assert!(out.contains("waits for node 99999999"), "{out}");
alice.fails(&["sync", "--check"]);
let mut doc = clean.clone();
let mut rec = doc["nodes"][&goal_cid].clone();
rec["change_id"] = "aaaaaaaa-0000-4000-8000-000000000000".into();
doc["nodes"]["bbbbbbbb-0000-4000-8000-000000000000"] = rec;
alice.write_doc(&doc);
let (out, _) = alice.fails(&["sync", "--check"]);
assert!(out.contains("could not be read"), "{out}");
alice.write_doc(&clean);
let out = alice.ok(&["sync", "--check"]);
assert!(out.contains("already agree"), "{out}");
}
fn edges(dev: &Dev) -> Vec<Value> {
let graph: Value = serde_json::from_str(&dev.ok(&["graph"])).unwrap();
graph["edges"].as_array().cloned().unwrap_or_default()
}
#[test]
fn a_relinked_edge_carries_its_new_rationale_to_clones_that_had_the_edge() {
let team = Team::new();
let alice = team.founder("alice");
let goal = alice.add("goal", "Pick a store", &[]);
let option = alice.add("option", "Postgres", &[]);
alice.ok(&[
"link",
&goal.to_string(),
&option.to_string(),
"-r",
"option",
]);
alice.commit_graph("option");
alice.git(&["push", "-q"]);
let carol = team.join("carol");
assert_eq!(edges(&carol)[0]["rationale"], "option");
alice.ok(&["unlink", &goal.to_string(), &option.to_string()]);
alice.ok(&[
"link",
&goal.to_string(),
&option.to_string(),
"-r",
"chosen",
]);
alice.commit_graph("chosen");
alice.git(&["push", "-q"]);
carol.git(&["pull", "-q", "--no-edit"]);
let out = carol.ok(&["sync"]);
let e = edges(&carol);
assert_eq!(e.len(), 1, "{out}");
assert_eq!(e[0]["rationale"], "chosen", "{out}");
let out = carol.ok(&["sync", "--check"]);
assert!(out.contains("already agree"), "{out}");
carol.ok(&[
"unlink",
&alice_cid(&alice, goal),
&alice_cid(&alice, option),
]);
carol.ok(&[
"link",
&alice_cid(&alice, goal),
&alice_cid(&alice, option),
"-r",
"final",
]);
carol.commit_graph("final");
carol.git(&["push", "-q"]);
alice.git(&["pull", "-q", "--no-edit"]);
let out = alice.ok(&["sync"]);
assert_eq!(edges(&alice)[0]["rationale"], "final", "{out}");
let rec = alice.doc()["edges"]
.as_object()
.unwrap()
.values()
.next()
.unwrap()
.clone();
assert_eq!(rec["rationale"], "final", "{rec:#}");
}
fn alice_cid(alice: &Dev, id: i32) -> String {
alice.change_id(id)[..8].to_string()
}
#[test]
fn a_resurrected_node_comes_back_with_the_edges_and_tags_its_delete_took() {
let team = Team::new();
let alice = team.founder("alice");
let goal = alice.add("goal", "Goal", &[]);
let action = alice.add("action", "Contested", &[]);
let other = alice.add("action", "Unrelated", &[]);
alice.ok(&[
"link",
&goal.to_string(),
&action.to_string(),
"-r",
"does it",
]);
alice.ok(&["link", &goal.to_string(), &other.to_string(), "-r", "also"]);
alice.ok(&["themes", "create", "infra"]);
alice.ok(&["tag", "add", &action.to_string(), "infra"]);
let action_cid = alice.change_id(action);
let other_cid = alice.change_id(other);
alice.commit_graph("graph");
alice.git(&["push", "-q"]);
let bob = team.join("bob");
alice.ok(&["delete", &action.to_string()]);
alice.ok(&["unlink", &goal.to_string(), &other.to_string()]);
alice.commit_graph("delete");
alice.git(&["push", "-q"]);
bob.ok(&["status", &action_cid[..8], "completed"]);
bob.commit_graph("edit");
bob.git(&["pull", "-q", "--no-edit"]);
let out = bob.ok(&["sync"]);
assert!(bob.node_by_title("Contested").is_some(), "{out}");
let e = edges(&bob);
assert!(
e.iter().any(|e| e["to_change_id"] == action_cid.as_str()),
"Bob lost the edge of the node that came back: {out}\n{e:#?}"
);
assert!(
!e.iter().any(|e| e["to_change_id"] == other_cid.as_str()),
"{e:#?}"
);
let tags = bob.ok(&["tag", "list", &action_cid[..8]]);
assert!(tags.contains("infra"), "{tags}");
bob.git(&["push", "-q"]);
alice.git(&["pull", "-q", "--no-edit"]);
let out = alice.ok(&["sync"]);
let back = alice
.node_by_title("Contested")
.expect("node back on alice");
assert_eq!(back["status"], "completed");
let e = edges(&alice);
assert!(
e.iter().any(|e| e["to_change_id"] == action_cid.as_str()),
"{out}\n{e:#?}"
);
assert!(!e.iter().any(|e| e["to_change_id"] == other_cid.as_str()));
let tags = alice.ok(&["tag", "list", &action_cid[..8]]);
assert!(tags.contains("infra"), "{tags}");
let out = alice.ok(&["sync", "--check"]);
assert!(out.contains("already agree"), "{out}");
}
#[test]
fn a_record_filed_twice_under_one_key_merges_instead_of_last_wins() {
let team = Team::new();
let alice = team.founder("alice");
let goal = alice.add("goal", "Twice", &[]);
let cid = alice.change_id(goal);
let rec = alice.doc()["nodes"][&cid].clone();
let mut newer = rec.clone();
newer["status"] = "completed".into();
newer["updated_at"] = "2099-01-02T00:00:00+00:00".into();
let mut older = rec.clone();
older["updated_at"] = "2099-01-01T00:00:00+00:00".into();
older["description"] = "only in the older copy".into();
let text = format!(
"{{\n \"version\": 1,\n \"edges\": {{}},\n \"nodes\": {{\n \"{cid}\": {},\n \"{cid}\": {}\n }}\n}}\n",
serde_json::to_string(&newer).unwrap(),
serde_json::to_string(&older).unwrap()
);
fs::write(alice.graph_path(), text).unwrap();
let out = alice.ok(&["sync"]);
assert_eq!(status_of(&alice, &goal.to_string()), "completed", "{out}");
let shown = alice.ok(&["show", &goal.to_string()]);
assert!(shown.contains("only in the older copy"), "{shown}");
}
#[test]
fn an_unreadable_graph_file_says_how_to_recover_not_to_rerun_what_failed() {
let team = Team::new();
let alice = team.founder("alice");
alice.add("goal", "Here", &[]);
fs::write(alice.graph_path(), "{\"version\": 1, \"nodes\": {").unwrap();
let out = alice.run(&["sync"]);
assert!(!out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("git checkout"), "{err}");
assert!(!err.contains("run `deciduous sync`"), "{err}");
assert_eq!(alice.graph_text(), "{\"version\": 1, \"nodes\": {");
}
fn tombstone_edges_and_tags_in_2099(dev: &Dev) {
let mut doc = dev.doc();
for kind in ["edges", "tags"] {
for rec in doc[kind].as_object_mut().unwrap().values_mut() {
rec["deleted_at"] = "2099-01-01T00:00:00+00:00".into();
}
}
dev.write_doc(&doc);
}
#[test]
fn a_relink_or_retag_is_never_reverted_by_a_future_tombstone() {
let team = Team::new();
let alice = team.founder("alice");
let goal = alice.add("goal", "Goal", &[]);
let opt = alice.add("option", "Opt", &[]);
alice.ok(&["link", &goal.to_string(), &opt.to_string(), "-r", "option"]);
alice.ok(&["themes", "create", "t"]);
alice.ok(&["tag", "add", &goal.to_string(), "t"]);
let goal_cid = alice_cid(&alice, goal);
let opt_cid = alice_cid(&alice, opt);
alice.commit_graph("graph");
alice.git(&["push", "-q"]);
let bob = team.join("bob");
alice.ok(&["unlink", &goal.to_string(), &opt.to_string()]);
alice.ok(&["tag", "remove", &goal.to_string(), "t"]);
tombstone_edges_and_tags_in_2099(&alice);
alice.ok(&["sync"]);
alice.commit_graph("skewed unlink");
alice.git(&["push", "-q"]);
bob.git(&["pull", "-q", "--no-edit"]);
let out = bob.ok(&["sync"]);
assert!(edges(&bob).is_empty(), "{out}");
bob.ok(&["link", &goal_cid, &opt_cid, "-r", "option"]);
bob.ok(&["tag", "add", &goal_cid, "t"]);
let out = bob.ok(&["sync"]);
let e = edges(&bob);
assert_eq!(e.len(), 1, "the relink was reverted: {out}");
assert_eq!(e[0]["rationale"], "option");
let tags = bob.ok(&["tag", "list", &goal_cid]);
assert!(
tags.contains('t') && !tags.contains("no themes"),
"the retag was reverted: {out}\n{tags}"
);
let doc = bob.doc();
for kind in ["edges", "tags"] {
for rec in doc[kind].as_object().unwrap().values() {
assert!(rec.get("deleted_at").is_none(), "{kind}: {rec:#}");
}
}
let out = bob.ok(&["sync", "--check"]);
assert!(out.contains("already agree"), "{out}");
bob.commit_graph("relink");
bob.git(&["push", "-q"]);
alice.git(&["pull", "-q", "--no-edit"]);
let out = alice.ok(&["sync"]);
assert_eq!(edges(&alice).len(), 1, "{out}");
let tags = alice.ok(&["tag", "list", &goal.to_string()]);
assert!(!tags.contains("no themes"), "{out}\n{tags}");
}
fn write_conflicted(dev: &Dev, ours: &Value, theirs: &Value) {
let text = format!(
"<<<<<<< HEAD\n{}\n=======\n{}\n>>>>>>> theirs\n",
serde_json::to_string_pretty(ours).unwrap(),
serde_json::to_string_pretty(theirs).unwrap()
);
fs::write(dev.graph_path(), text).unwrap();
}
#[test]
fn an_edit_is_refused_while_the_graph_file_is_unreadable_instead_of_lost() {
let team = Team::new();
let alice = team.founder("alice");
let dated = alice.add("action", "dated", &["--date", "2099-01-01"]);
let cid = alice.change_id(dated);
let doc = alice.doc();
let mut theirs = doc.clone();
theirs["nodes"][&cid]["description"] = "from the other side".into();
write_conflicted(&alice, &doc, &theirs);
let out = alice.run(&["status", &dated.to_string(), "completed"]);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
!out.status.success(),
"status reported success on an unreadable graph file:\n{}{}",
String::from_utf8_lossy(&out.stdout),
err
);
assert!(
err.contains("graph.json") && err.contains("conflict markers"),
"{err}"
);
assert_eq!(status_of(&alice, &dated.to_string()), "pending");
let (_, err) = alice.fails(&["add", "goal", "new while broken"]);
assert!(err.contains("conflict markers"), "{err}");
assert!(alice.node_by_title("new while broken").is_none());
alice.ok(&["sync"]);
alice.ok(&["status", &dated.to_string(), "completed"]);
alice.ok(&["sync"]);
assert_eq!(status_of(&alice, &dated.to_string()), "completed");
}
fn diverged_alice_and_bob(team: &Team) -> Dev {
let alice = team.founder("alice");
alice.add("goal", "Shared", &[]);
alice.commit_graph("shared");
alice.git(&["push", "-q"]);
let bob = team.join("bob");
bob.add("goal", "Bob only", &[]);
bob.commit_graph("bob");
bob.git(&["push", "-q"]);
alice.add("goal", "Alice only", &[]);
alice.commit_graph("alice");
alice
}
fn git_without_driver(dev: &Dev, args: &[&str]) -> Output {
let gui = Dev {
path: base_path(),
..dev.clone_handle()
};
gui.git_out(args)
}
#[test]
fn a_failed_driver_merge_staged_by_hand_is_still_caught_and_finished_by_sync() {
let team = Team::new();
let alice = diverged_alice_and_bob(&team);
let out = git_without_driver(&alice, &["pull", "--no-edit"]);
assert!(!out.status.success());
alice.git(&["add", ".deciduous/graph.json"]);
assert!(unmerged(&alice).is_empty());
assert!(!alice.graph_text().contains("Bob only"));
let (out, _) = alice.fails(&["sync", "--check"]);
assert!(out.contains("MERGE_HEAD"), "{out}");
let out = alice.ok(&["sync"]);
assert!(out.contains("MERGE_HEAD"), "{out}");
assert_eq!(
titles(&alice),
["Alice only", "Bob only", "Shared"],
"{out}"
);
let staged = alice.git(&["show", ":.deciduous/graph.json"]);
assert!(
staged.contains("Bob only"),
"the merge result is not staged"
);
alice.git(&["commit", "-q", "--no-edit"]);
let out = alice.ok(&["sync", "--check"]);
assert!(out.contains("already agree"), "{out}");
let committed = alice.git(&["show", "HEAD:.deciduous/graph.json"]);
assert!(committed.contains("Bob only"));
}
#[test]
fn a_failed_driver_rebase_staged_by_hand_is_still_caught_and_finished_by_sync() {
let team = Team::new();
let alice = diverged_alice_and_bob(&team);
let out = git_without_driver(&alice, &["pull", "--rebase"]);
assert!(!out.status.success());
alice.git(&["add", ".deciduous/graph.json"]);
assert!(unmerged(&alice).is_empty());
let (out, _) = alice.fails(&["sync", "--check"]);
assert!(out.contains("REBASE_HEAD"), "{out}");
alice.ok(&["sync"]);
let staged = alice.git(&["show", ":.deciduous/graph.json"]);
assert!(
staged.contains("Bob only") && staged.contains("Alice only"),
"{staged}"
);
let out = alice
.cmd("git")
.args(["-c", "core.editor=true", "rebase", "--continue"])
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let committed = alice.git(&["show", "HEAD:.deciduous/graph.json"]);
assert!(committed.contains("Bob only") && committed.contains("Alice only"));
let out = alice.ok(&["sync", "--check"]);
assert!(out.contains("already agree"), "{out}");
}
#[test]
fn a_merge_stopped_on_another_file_leaves_a_driver_merged_graph_alone() {
let team = Team::new();
let alice = team.founder("alice");
fs::write(alice.dir.join("notes.txt"), "base\n").unwrap();
alice.git(&["add", "notes.txt"]);
alice.add("goal", "Shared", &[]);
alice.commit_graph("shared");
alice.git(&["push", "-q"]);
let bob = team.join("bob");
bob.ok(&["status", "1", "completed"]);
bob.add("goal", "Bob only", &[]);
fs::write(bob.dir.join("notes.txt"), "bob\n").unwrap();
bob.git(&["add", "notes.txt"]);
bob.commit_graph("bob");
bob.git(&["push", "-q"]);
alice.ok(&["prompt", "1", "alice's prompt"]);
alice.add("goal", "Alice only", &[]);
fs::write(alice.dir.join("notes.txt"), "alice\n").unwrap();
alice.git(&["add", "notes.txt"]);
alice.commit_graph("alice");
let out = alice.git_out(&["pull", "--no-edit"]);
assert!(!out.status.success());
assert!(unmerged(&alice).is_empty());
let merged = alice.graph_text();
let out = alice.ok(&["sync"]);
assert!(!out.contains("MERGE_HEAD"), "{out}");
assert_eq!(titles(&alice), ["Alice only", "Bob only", "Shared"]);
assert_eq!(status_of(&alice, "1"), "completed");
let out = alice.ok(&["sync", "--check"]);
assert!(out.contains("already agree"), "{out}");
assert_eq!(alice.graph_text(), merged);
}
#[test]
fn a_merge_whose_ancestor_will_not_parse_is_refused_not_decided_by_timestamp() {
let team = Team::new();
let alice = team.founder("alice");
let goal = alice.add("goal", "A", &[]);
let cid = alice.change_id(goal);
let good = alice.doc();
let ours_at = (chrono::Utc::now() + chrono::Duration::hours(1)).to_rfc3339();
let theirs_at = (chrono::Utc::now() + chrono::Duration::hours(2)).to_rfc3339();
fs::write(alice.graph_path(), "{\"version\": 1, \"nodes\": {\n").unwrap();
alice.commit_graph("broken");
let at = |doc: &Value, field: &str, value: &str, when: &str| {
let mut d = doc.clone();
d["nodes"][&cid][field] = value.into();
d["nodes"][&cid]["updated_at"] = when.into();
d
};
alice.git(&["checkout", "-q", "-b", "theirs"]);
alice.write_doc(&at(&good, "status", "completed", &theirs_at));
alice.commit_graph("theirs: completed an hour after ours");
alice.git(&["checkout", "-q", "main"]);
alice.write_doc(&at(&good, "title", "OURS RENAMED", &ours_at));
alice.commit_graph("ours: renamed");
let out = alice.git_out(&["merge", "--no-edit", "theirs"]);
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
!out.status.success(),
"the merge went through: {text}\n{}",
alice.graph_text()
);
assert!(text.contains("common ancestor"), "{text}");
assert!(!unmerged(&alice).is_empty());
let (out, _) = alice.fails(&["sync", "--check"]);
assert!(out.contains("common ancestor"), "{out}");
let (out, err) = alice.fails(&["sync"]);
assert!(
format!("{out}{err}").contains("common ancestor"),
"{out}{err}"
);
assert!(!unmerged(&alice).is_empty());
assert!(alice.graph_text().contains("OURS RENAMED"));
let base = alice.dir.join("base.json");
fs::write(&base, "{\"version\": 1, \"nodes\": {\n").unwrap();
let ours = alice.dir.join("ours.json");
let theirs = alice.dir.join("theirs.json");
fs::write(
&ours,
serde_json::to_string(&at(&good, "title", "OURS RENAMED", &ours_at)).unwrap(),
)
.unwrap();
fs::write(
&theirs,
serde_json::to_string(&at(&good, "status", "completed", &theirs_at)).unwrap(),
)
.unwrap();
let out = alice.run(&[
"merge-record",
base.to_str().unwrap(),
ours.to_str().unwrap(),
theirs.to_str().unwrap(),
]);
assert!(
!out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
assert!(fs::read_to_string(&ours).unwrap().contains("OURS RENAMED"));
let theirs_blob = alice.git(&["show", ":3:.deciduous/graph.json"]);
fs::write(&theirs, theirs_blob).unwrap();
alice.ok(&[
"merge-record",
"/dev/null",
".deciduous/graph.json",
theirs.to_str().unwrap(),
]);
alice.git(&["add", ".deciduous/graph.json"]);
alice.ok(&["sync"]);
assert_eq!(status_of(&alice, &goal.to_string()), "completed");
}
fn pull_teammate_node(dev: &Dev, change_id: &str, title: &str) {
let mut doc = dev.doc();
doc["nodes"][change_id] = node_record(change_id, title, "2026-01-02T00:00:00+00:00");
dev.write_doc(&doc);
}
#[test]
fn a_prefix_of_a_pulled_but_unsynced_node_is_not_taken_as_a_local_id() {
let team = Team::new();
let alice = team.founder("alice");
alice.add("goal", "local one", &[]);
alice.add("goal", "local two", &[]);
alice.add("goal", "abcd local", &[]);
let local_abcd = alice.change_id(3);
pull_teammate_node(
&alice,
"0002abcd-1111-4111-8111-111111111111",
"teammate target",
);
let (_, err) = alice.fails(&["delete", "0002"]);
assert!(
err.contains("local two") && err.contains("0002abcd"),
"{err}"
);
assert!(err.contains("deciduous sync"), "{err}");
assert!(alice.node_by_title("local two").is_some());
alice.fails(&["show", "0002"]);
let prefix = &local_abcd[..4];
let twin = format!("{prefix}ffff-2222-4222-8222-222222222222");
pull_teammate_node(&alice, &twin, "teammate twin");
let (_, err) = alice.fails(&["status", prefix, "completed"]);
assert!(err.contains("teammate twin"), "{err}");
assert_eq!(status_of(&alice, "3"), "pending");
let (_, err) = alice.fails(&["show", &twin[..9]]);
assert!(
err.contains("deciduous sync") && err.contains("teammate twin"),
"{err}"
);
alice.ok(&["show", "#2"]);
alice.ok(&["sync"]);
let shown = alice.ok(&["show", "0002a"]);
assert!(shown.contains("teammate target"), "{shown}");
}
fn mcp_call(dev: &Dev, tool: &str, arguments: Value) -> Value {
use std::io::{BufRead, BufReader, Write};
let mut child = dev
.cmd(BIN)
.arg("mcp")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.unwrap();
let mut stdin = child.stdin.take().unwrap();
let msgs = [
serde_json::json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}),
serde_json::json!({"jsonrpc":"2.0","method":"notifications/initialized"}),
serde_json::json!({"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":tool,"arguments":arguments}}),
];
for m in msgs {
writeln!(stdin, "{m}").unwrap();
}
drop(stdin);
let reader = BufReader::new(child.stdout.take().unwrap());
let mut answer = Value::Null;
for line in reader.lines() {
let v: Value = serde_json::from_str(&line.unwrap()).unwrap();
if v["id"] == 2 {
answer = v;
}
}
child.wait().unwrap();
answer
}
#[test]
fn an_mcp_node_id_is_never_truncated_to_another_node() {
let team = Team::new();
let alice = team.founder("alice");
alice.add("goal", "A", &[]);
alice.add("goal", "B", &[]);
let answer = mcp_call(
&alice,
"delete_node",
serde_json::json!({"node_id": 4294967298u64, "dry_run": true}),
);
let text = answer.to_string();
assert!(!text.contains("Would delete node #2"), "{text}");
assert!(text.contains("4294967298"), "{text}");
assert_eq!(answer["result"]["isError"], true, "{text}");
let cid = "37650685-2222-4222-8222-222222222222";
pull_teammate_node(&alice, cid, "all digits");
alice.ok(&["sync"]);
let answer = mcp_call(
&alice,
"delete_node",
serde_json::json!({"node_id": 37650685, "dry_run": true}),
);
let text = answer.to_string();
assert!(text.contains("all digits"), "{text}");
}
fn serve_export(body: String) -> String {
use std::io::{BufRead, BufReader, Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut request_line = String::new();
reader.read_line(&mut request_line).unwrap_or(0);
let mut length = 0usize;
loop {
let mut line = String::new();
if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" {
break;
}
if let Some(v) = line.to_lowercase().strip_prefix("content-length:") {
length = v.trim().parse().unwrap_or(0);
}
}
let mut sink = vec![0; length];
let _ = reader.read_exact(&mut sink);
let payload = if request_line.starts_with("GET /export") {
body.clone()
} else if request_line.starts_with("POST /claim") {
r#"{"claim":"unchecked"}"#.to_string()
} else if request_line.starts_with("POST /locate") {
r#"{"workspaces":[]}"#.to_string()
} else {
"{}".to_string()
};
let _ = write!(
stream,
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
payload.len(),
payload
);
}
});
url
}
#[test]
fn remote_pull_merges_into_graph_json_instead_of_overwriting_it() {
let team = Team::new();
let alice = team.founder("alice");
let a = alice.add("goal", "A", &[]);
let b = alice.add("action", "B", &[]);
let c = alice.add("action", "C", &[]);
let (a, b, c) = (a.to_string(), b.to_string(), c.to_string());
alice.ok(&["link", &a, &b, "-r", "old"]);
alice.ok(&["link", &a, &c, "-r", "to c"]);
let doc = alice.doc();
let nodes: Vec<Value> = doc["nodes"]
.as_object()
.unwrap()
.values()
.map(|n| {
serde_json::json!({
"change_id": n["change_id"], "node_type": n["node_type"],
"title": n["title"], "status": n["status"],
"created_at": n["created_at"], "updated_at": n["updated_at"],
})
})
.collect();
let server_edges: Vec<Value> = doc["edges"]
.as_object()
.unwrap()
.values()
.map(|e| {
serde_json::json!({
"from_change_id": e["from_change_id"], "to_change_id": e["to_change_id"],
"edge_type": e["edge_type"], "rationale": e["rationale"],
"created_at": e["created_at"],
})
})
.collect();
let url = serve_export(
serde_json::json!({"nodes": nodes, "edges": server_edges, "documents": []}).to_string(),
);
alice.ok(&["unlink", &a, &c]);
alice.ok(&["unlink", &a, &b]);
alice.ok(&["link", &a, &b, "-r", "new local relink"]);
let mut doc = alice.doc();
for n in doc["nodes"].as_object_mut().unwrap().values_mut() {
n["priority"] = "high".into();
}
alice.write_doc(&doc);
alice.ok(&["sync"]);
fs::write(
alice.dir.join(".deciduous").join("config.toml"),
format!("[remote]\nurl = \"{url}\"\nworkspace = \"w\"\n"),
)
.unwrap();
let out = alice
.cmd(BIN)
.args(["remote", "pull"])
.env("DECIDUOUS_MCP_TOKEN", "t")
.output()
.unwrap();
let said = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(out.status.success(), "{said}");
let doc = alice.doc();
for n in doc["nodes"].as_object().unwrap().values() {
assert_eq!(
n["priority"], "high",
"remote pull dropped a field: {n:#}\n{said}"
);
}
let e = edges(&alice);
assert_eq!(e.len(), 1, "the unlinked edge came back: {e:#?}\n{said}");
assert_eq!(e[0]["rationale"], "new local relink", "{said}");
let tomb = doc["edges"]
.as_object()
.unwrap()
.values()
.filter(|e| e["deleted_at"].is_string())
.count();
assert_eq!(tomb, 1, "{doc:#}");
let out = alice.ok(&["sync", "--check"]);
assert!(out.contains("already agree"), "{out}");
}
#[test]
fn indented_conflict_markers_are_not_sent_back_to_the_sync_that_cannot_merge_them() {
let team = Team::new();
let alice = team.founder("alice");
alice.add("goal", "Here", &[]);
let text = alice.graph_text().replacen(
" \"nodes\": {",
" \"nodes\": {\n <<<<<<< HEAD\n =======\n >>>>>>> theirs",
1,
);
fs::write(alice.graph_path(), &text).unwrap();
for args in [&["sync"][..], &["sync", "--check"][..]] {
let out = alice.run(args);
assert!(!out.status.success());
let said = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(!said.contains("run `deciduous sync`"), "{args:?}: {said}");
assert!(said.contains("by hand"), "{args:?}: {said}");
}
assert_eq!(alice.graph_text(), text);
}
#[test]
#[ignore = "G6 deferred: needs a record of which rows are unpublished local writes"]
fn a_node_added_on_a_branch_stays_on_that_branch() {
let team = Team::new();
let alice = team.founder("alice");
alice.add("goal", "On main", &[]);
alice.commit_graph("main");
alice.git(&["checkout", "-q", "-b", "spike"]);
alice.add("goal", "Spike", &[]);
alice.commit_graph("spike");
alice.git(&["checkout", "-q", "main"]);
let out = alice.ok(&["sync"]);
assert!(!alice.graph_text().contains("Spike"), "{out}");
assert!(alice.git(&["status", "--porcelain"]).is_empty(), "{out}");
}
#[test]
fn sync_on_an_older_commit_leaves_the_tree_clean() {
let team = Team::new();
let alice = team.founder("alice");
alice.add("goal", "First", &[]);
alice.commit_graph("first");
alice.add("goal", "Second", &[]);
alice.commit_graph("second");
alice.git(&["checkout", "-q", "HEAD~1"]);
let before = alice.git(&["status", "--porcelain"]);
let out = alice.ok(&["sync"]);
assert_eq!(alice.git(&["status", "--porcelain"]), before, "{out}");
alice.git(&["checkout", "-q", "main"]);
}
#[test]
#[ignore = "deferred: needs per-field stamps in graph.json records (ABA on a pulled value)"]
fn model_7833_a_field_set_back_to_the_ancestors_value_survives_the_merge() {
let team = Team::new();
let alice = team.founder("alice");
let goal = alice.add("goal", "G", &[]);
let cid = alice.change_id(goal);
alice.commit_all("G");
alice.git(&["push", "-q", "origin", "main"]);
let bob = team.join("bob");
let pulled = (chrono::Utc::now() + chrono::Duration::seconds(1)).to_rfc3339();
for dev in [&alice, &bob] {
let mut doc = dev.doc();
doc["nodes"][&cid]["status"] = "rejected".into();
doc["nodes"][&cid]["updated_at"] = pulled.clone().into();
dev.write_doc(&doc);
dev.ok(&["sync"]);
}
std::thread::sleep(std::time::Duration::from_millis(1100));
alice.ok(&["status", &goal.to_string(), "pending"]);
alice.ok(&["sync"]);
alice.commit_graph("alice: pending again");
alice.git(&["push", "-q", "origin", "main"]);
bob.commit_graph("bob: pulled rejected");
bob.git(&["pull", "-q", "--no-rebase", "origin", "main"]);
bob.ok(&["sync"]);
assert_eq!(
status_of(&bob, &cid),
"pending",
"alice's later edit back to the ancestor's value was lost in the merge"
);
}