mod common;
use common::cli::{BrWorkspace, run_br};
use serde_json::Value;
use std::fs;
fn parse_created_id(stdout: &str) -> String {
let line = stdout.lines().next().unwrap_or("");
let normalized = line.strip_prefix("✓ ").unwrap_or(line);
normalized
.strip_prefix("Created ")
.and_then(|rest| rest.split(':').next())
.unwrap_or("")
.trim()
.to_string()
}
fn make_jsonl_record_stale(issues_path: &std::path::Path, id: &str) {
let contents = fs::read_to_string(issues_path).expect("read jsonl");
let mut out = String::new();
let mut rewrote = false;
for line in contents.lines() {
if line.trim().is_empty() {
continue;
}
let mut record: Value = serde_json::from_str(line).expect("parse jsonl line");
if record.get("id").and_then(Value::as_str) == Some(id) {
record["title"] = Value::from("Stale remote title");
record["updated_at"] = Value::from("2000-01-01T00:00:00Z");
if let Some(obj) = record.as_object_mut() {
obj.remove("content_hash");
}
rewrote = true;
}
out.push_str(&serde_json::to_string(&record).expect("serialize"));
out.push('\n');
}
assert!(rewrote, "expected to find {id} in JSONL");
fs::write(issues_path, out).expect("write jsonl");
}
const MERGED_ISSUE_LINE: &str = "{\"id\":\"bd-merged1\",\"title\":\"Merged from another worktree\",\"status\":\"open\",\"priority\":2,\"issue_type\":\"task\",\"created_at\":\"2026-01-01T00:00:00Z\",\"updated_at\":\"2026-01-01T00:00:00Z\"}\n";
fn armed_workspace_with_merged_issue() -> (BrWorkspace, std::path::PathBuf) {
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let issues_path = workspace.root.join(".beads").join("issues.jsonl");
let create = run_br(
&workspace,
[
"create",
"Local issue",
"--no-auto-flush",
"--no-auto-import",
],
"create_local",
);
assert!(create.status.success(), "create failed: {}", create.stderr);
let local_id = parse_created_id(&create.stdout);
assert!(!local_id.is_empty(), "no id in: {}", create.stdout);
let flush = run_br(
&workspace,
["sync", "--flush-only", "--no-auto-import"],
"seed_flush",
);
assert!(
flush.status.success(),
"seed flush failed: {}",
flush.stderr
);
make_jsonl_record_stale(&issues_path, &local_id);
let import = run_br(
&workspace,
["sync", "--import-only", "--no-auto-flush"],
"arming_import",
);
assert!(import.status.success(), "import failed: {}", import.stderr);
let status = run_br(
&workspace,
[
"sync",
"--status",
"--json",
"--no-auto-flush",
"--no-auto-import",
],
"status_after_arming",
);
assert!(status.status.success(), "status failed: {}", status.stderr);
let mut contents = fs::read_to_string(&issues_path).expect("read jsonl");
contents.push_str(MERGED_ISSUE_LINE);
fs::write(&issues_path, contents).expect("append merged issue");
(workspace, issues_path)
}
#[test]
fn e2e_flush_only_needs_flush_does_not_destroy_merged_issues() {
let _log = common::test_log("e2e_flush_only_needs_flush_does_not_destroy_merged_issues");
let (workspace, issues_path) = armed_workspace_with_merged_issue();
let flush = run_br(
&workspace,
["sync", "--flush-only", "--no-auto-import"],
"post_merge_flush",
);
let jsonl_after = fs::read_to_string(&issues_path).expect("read jsonl");
assert!(
jsonl_after.contains("bd-merged1"),
"flush destroyed the merged issue bd-merged1; JSONL after:\n{jsonl_after}"
);
assert!(
!flush.status.success(),
"flush must refuse while the DB is stale relative to JSONL; stdout: {} stderr: {}",
flush.stdout,
flush.stderr
);
assert!(
flush.stderr.contains("Refusing to export stale database"),
"expected stale-database guard, got stderr: {}",
flush.stderr
);
assert!(
flush.stderr.contains("bd-merged1"),
"guard should name the would-be-lost issue; stderr: {}",
flush.stderr
);
let import = run_br(
&workspace,
["sync", "--import-only", "--no-auto-flush"],
"recovery_import",
);
assert!(
import.status.success(),
"recovery import failed: {}",
import.stderr
);
let flush2 = run_br(
&workspace,
["sync", "--flush-only", "--no-auto-import"],
"recovery_flush",
);
assert!(
flush2.status.success(),
"recovery flush failed: {}",
flush2.stderr
);
let jsonl_final = fs::read_to_string(&issues_path).expect("read jsonl");
assert!(
jsonl_final.contains("bd-merged1"),
"merged issue must survive the lossless recovery flush"
);
}
#[test]
fn e2e_auto_flush_needs_flush_does_not_destroy_merged_issues() {
let _log = common::test_log("e2e_auto_flush_needs_flush_does_not_destroy_merged_issues");
let (workspace, issues_path) = armed_workspace_with_merged_issue();
let create = run_br(
&workspace,
[
"create",
"Another local issue",
"--no-auto-import",
"--allow-stale",
],
"create_with_auto_flush",
);
let jsonl_after = fs::read_to_string(&issues_path).expect("read jsonl");
assert!(
jsonl_after.contains("bd-merged1"),
"auto-flush destroyed the merged issue bd-merged1 (create exit={:?}); JSONL after:\n{jsonl_after}",
create.status.code()
);
}
#[test]
fn e2e_hard_delete_flush_still_prunes_purged_issues() {
let _log = common::test_log("e2e_hard_delete_flush_still_prunes_purged_issues");
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let issues_path = workspace.root.join(".beads").join("issues.jsonl");
let keep = run_br(
&workspace,
["create", "Keeper", "--no-auto-flush", "--no-auto-import"],
"create_keeper",
);
assert!(keep.status.success(), "create failed: {}", keep.stderr);
let victim = run_br(
&workspace,
["create", "Victim", "--no-auto-flush", "--no-auto-import"],
"create_victim",
);
assert!(victim.status.success(), "create failed: {}", victim.stderr);
let victim_id = parse_created_id(&victim.stdout);
assert!(!victim_id.is_empty(), "no id in: {}", victim.stdout);
let flush = run_br(
&workspace,
["sync", "--flush-only", "--no-auto-import"],
"seed_flush",
);
assert!(
flush.status.success(),
"seed flush failed: {}",
flush.stderr
);
assert!(
fs::read_to_string(&issues_path)
.expect("read jsonl")
.contains(&victim_id),
"victim must be in JSONL before the purge"
);
let del = run_br(
&workspace,
[
"delete",
&victim_id,
"--force",
"--no-auto-flush",
"--no-auto-import",
],
"soft_delete",
);
assert!(del.status.success(), "delete failed: {}", del.stderr);
let purge = run_br(
&workspace,
[
"delete",
&victim_id,
"--hard",
"--force",
"--no-auto-flush",
"--no-auto-import",
],
"hard_delete",
);
assert!(
purge.status.success(),
"hard delete failed: {}",
purge.stderr
);
let flush2 = run_br(
&workspace,
["sync", "--flush-only", "--no-auto-import"],
"post_purge_flush",
);
assert!(
flush2.status.success(),
"flush after purge must succeed without --force; stderr: {}",
flush2.stderr
);
let jsonl_after = fs::read_to_string(&issues_path).expect("read jsonl");
assert!(
!jsonl_after.contains(&victim_id),
"purged issue must be pruned from JSONL"
);
assert!(
jsonl_after.contains("Keeper"),
"keeper issue must survive the post-purge flush"
);
}