mod common;
use beads_rust::model::{Comment, Dependency, DependencyType, Issue, IssueType, Priority, Status};
use beads_rust::storage::SqliteStorage;
use chrono::Utc;
use common::cli::{
BrRun, BrWorkspace, extract_json_payload, parse_json_value, parse_list_issues, run_br,
run_br_smoke_at_root_with_env,
};
use common::isolated_workspace_failure_fixture;
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
#[cfg(target_os = "linux")]
use std::process::{Command as StdCommand, Stdio};
use std::thread::sleep;
use std::time::Duration;
fn parse_created_id(stdout: &str) -> String {
let line = stdout.lines().next().unwrap_or("");
let normalized = line
.strip_prefix("✓ ")
.or_else(|| line.strip_prefix("✗ "))
.unwrap_or(line);
let id_part = normalized
.strip_prefix("Created ")
.and_then(|rest| rest.split(':').next())
.unwrap_or("");
id_part.trim().to_string()
}
fn make_issue(id: &str, title: &str, now: chrono::DateTime<Utc>) -> Issue {
Issue {
id: id.to_string(),
title: title.to_string(),
status: Status::Open,
priority: Priority::MEDIUM,
issue_type: IssueType::Task,
created_at: now,
updated_at: now,
content_hash: None,
description: None,
design: None,
acceptance_criteria: None,
notes: None,
assignee: None,
owner: None,
estimated_minutes: None,
created_by: None,
closed_at: None,
close_reason: None,
closed_by_session: None,
due_at: None,
defer_until: None,
external_ref: None,
source_system: None,
source_repo: None,
source_repo_path: None,
agent_context: None,
deleted_at: None,
deleted_by: None,
delete_reason: None,
original_type: None,
compaction_level: None,
compacted_at: None,
compacted_at_commit: None,
original_size: None,
sender: None,
ephemeral: false,
pinned: false,
is_template: false,
labels: vec![],
dependencies: vec![],
comments: vec![],
}
}
fn dotted_parent_child_dependency(
issue_id: &str,
depends_on_id: &str,
now: chrono::DateTime<Utc>,
) -> Dependency {
Dependency {
issue_id: issue_id.to_string(),
depends_on_id: depends_on_id.to_string(),
dep_type: DependencyType::ParentChild,
created_at: now,
created_by: Some("tester".to_string()),
metadata: Some("{}".to_string()),
thread_id: None,
}
}
fn write_dotted_jsonl_fixture(workspace: &BrWorkspace) -> PathBuf {
let beads_dir = workspace.root.join(".beads");
fs::create_dir_all(&beads_dir).expect("create .beads");
let jsonl_path = beads_dir.join("issues.jsonl");
let now = Utc::now();
let parent = make_issue("bd-rchk0.5", "Dotted parent", now);
let mut target = make_issue("bd-rchk0.5.6", "Dotted target", now);
target
.dependencies
.push(dotted_parent_child_dependency(&target.id, &parent.id, now));
let mut child = make_issue("bd-rchk0.5.6.1", "Dotted child", now);
child
.dependencies
.push(dotted_parent_child_dependency(&child.id, &target.id, now));
let blocker = make_issue("bd-blocker7", "Dotted blocker", now);
let records = [&parent, &target, &child, &blocker]
.into_iter()
.map(|issue| serde_json::to_string(issue).expect("serialize dotted fixture"))
.collect::<Vec<_>>();
fs::write(&jsonl_path, records.join("\n") + "\n").expect("write dotted jsonl");
jsonl_path
}
fn assert_br_success(run: &BrRun, context: &str) {
assert!(run.status.success(), "{context}: {}", run.stderr);
}
fn parse_json_array(stdout: &str, context: &str) -> Vec<Value> {
serde_json::from_str(&extract_json_payload(stdout)).expect(context)
}
fn read_jsonl_values(path: &Path) -> Vec<Value> {
fs::read_to_string(path)
.expect("read jsonl")
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str(line).expect("parse exported issue"))
.collect()
}
fn prepare_merge_conflict_workspace() -> (BrWorkspace, String) {
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init_merge_conflict");
assert!(init.status.success(), "init failed: {}", init.stderr);
let create = run_br(
&workspace,
["create", "Merge conflict"],
"create_merge_seed",
);
assert!(create.status.success(), "create failed: {}", create.stderr);
let issue_id = parse_created_id(&create.stdout);
let flush = run_br(&workspace, ["sync", "--flush-only"], "flush_merge_conflict");
assert!(flush.status.success(), "flush failed: {}", flush.stderr);
let jsonl_path = workspace.root.join(".beads").join("issues.jsonl");
let base_snapshot_path = workspace.root.join(".beads").join("beads.base.jsonl");
fs::copy(&jsonl_path, &base_snapshot_path).expect("seed base snapshot");
let local_update = run_br(
&workspace,
[
"update",
&issue_id,
"--description",
"Local description",
"--no-auto-flush",
],
"local_merge_update",
);
assert!(
local_update.status.success(),
"local update failed: {}",
local_update.stderr
);
let contents = fs::read_to_string(&jsonl_path).expect("read jsonl");
let mut rewritten = Vec::new();
for line in contents.lines().filter(|line| !line.trim().is_empty()) {
let mut issue: Value = serde_json::from_str(line).expect("parse issue jsonl");
if issue["id"].as_str() == Some(issue_id.as_str()) {
issue["description"] = Value::String("External description".to_string());
issue["updated_at"] = Value::String("2999-01-01T00:00:00Z".to_string());
}
rewritten.push(serde_json::to_string(&issue).expect("serialize issue jsonl"));
}
fs::write(&jsonl_path, rewritten.join("\n") + "\n").expect("write jsonl");
(workspace, issue_id)
}
fn assert_issue_description(workspace: &BrWorkspace, issue_id: &str, expected: &str) {
let show = run_br(workspace, ["show", issue_id, "--json"], "show_merge_result");
assert!(show.status.success(), "show failed: {}", show.stderr);
let payload = extract_json_payload(&show.stdout);
let issues: Vec<Value> = serde_json::from_str(&payload).expect("parse show json");
assert_eq!(issues[0]["description"].as_str(), Some(expected));
}
#[cfg(target_os = "linux")]
fn clear_br_env_for_std_command(cmd: &mut StdCommand) {
for (key, _) in std::env::vars_os() {
let key = key.to_string_lossy();
if key.starts_with("BD_")
|| key.starts_with("BEADS_")
|| matches!(
key.as_ref(),
"BR_DISABLE_READ_ONLY_FAST_OPEN"
| "BR_OUTPUT_FORMAT"
| "TOON_DEFAULT_FORMAT"
| "TOON_STATS"
)
{
cmd.env_remove(key.as_ref());
}
}
}
#[test]
fn e2e_basic_lifecycle() {
let _log = common::test_log("e2e_basic_lifecycle");
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let create = run_br(&workspace, ["create", "Test issue"], "create");
assert!(create.status.success(), "create failed: {}", create.stderr);
let id = parse_created_id(&create.stdout);
assert!(!id.is_empty(), "missing created id");
let update_args = vec![
"update".to_string(),
id.clone(),
"--status".to_string(),
"in_progress".to_string(),
"--priority".to_string(),
"1".to_string(),
"--assignee".to_string(),
"alice".to_string(),
];
let update = run_br(&workspace, update_args, "update");
assert!(update.status.success(), "update failed: {}", update.stderr);
let list = run_br(&workspace, ["list", "--json"], "list");
assert!(list.status.success(), "list failed: {}", list.stderr);
let list_json = parse_list_issues(&list.stdout);
assert!(
list_json
.iter()
.any(|item| item["id"] == id && item["status"] == "in_progress"),
"updated issue not found in list"
);
let list_text = run_br(&workspace, ["list"], "list_text");
assert!(
list_text.status.success(),
"list text failed: {}",
list_text.stderr
);
assert!(
list_text.stdout.contains("Test issue"),
"list text missing issue title"
);
let show = run_br(&workspace, ["show", &id, "--json"], "show");
assert!(show.status.success(), "show failed: {}", show.stderr);
let show_payload = extract_json_payload(&show.stdout);
let show_json: Vec<Value> = serde_json::from_str(&show_payload).expect("show json");
assert_eq!(show_json[0]["id"], id);
let show_text = run_br(&workspace, ["show", &id], "show_text");
assert!(
show_text.status.success(),
"show text failed: {}",
show_text.stderr
);
assert!(
show_text.stdout.contains("Test issue"),
"show text missing title"
);
let close_args = vec![
"update".to_string(),
id,
"--status".to_string(),
"closed".to_string(),
];
let close = run_br(&workspace, close_args, "close");
assert!(close.status.success(), "close failed: {}", close.stderr);
}
#[test]
#[cfg(target_os = "linux")]
fn json_stdout_write_failure_exits_with_io_error() {
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init_stdout_failure");
assert!(init.status.success(), "init failed: {}", init.stderr);
let create = run_br(
&workspace,
["create", "stdout failure probe"],
"create_stdout_failure",
);
assert!(create.status.success(), "create failed: {}", create.stderr);
let dev_full = fs::OpenOptions::new()
.write(true)
.open("/dev/full")
.expect("open /dev/full");
let mut cmd = StdCommand::new(assert_cmd::cargo::cargo_bin!("br"));
cmd.current_dir(&workspace.root);
cmd.args(["list", "--json", "--no-auto-import", "--no-auto-flush"]);
clear_br_env_for_std_command(&mut cmd);
cmd.env("NO_COLOR", "1");
cmd.env("RUST_LOG", "beads_rust=debug");
cmd.env("RUST_BACKTRACE", "1");
cmd.env("HOME", &workspace.root);
cmd.stdout(Stdio::from(dev_full));
cmd.stderr(Stdio::piped());
let output = cmd.output().expect("run br with /dev/full stdout");
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(
output.status.code(),
Some(8),
"stdout write failure should exit as I/O error; stderr={stderr}"
);
assert!(
stderr.contains("failed to serialize JSON output"),
"stderr should report the output serialization failure: {stderr}"
);
}
#[test]
fn e2e_non_hermetic_smoke_existing_workspace_preserves_env_sensitive_paths() {
let _log =
common::test_log("e2e_non_hermetic_smoke_existing_workspace_preserves_env_sensitive_paths");
let fixture = isolated_workspace_failure_fixture("metadata_custom_paths")
.expect("metadata_custom_paths fixture");
let runner_root = fixture.root.join("ambient-env-smoke");
fs::create_dir_all(&runner_root).expect("create smoke runner root");
let external_beads_dir = fixture.root.join(".beads");
let external_beads_dir_str = external_beads_dir.display().to_string();
let custom_db_str = external_beads_dir.join("custom.db").display().to_string();
let custom_jsonl_str = external_beads_dir
.join("custom.jsonl")
.display()
.to_string();
let smoke_env = || {
vec![
("BEADS_DIR".to_string(), external_beads_dir_str.clone()),
("BR_OUTPUT_FORMAT".to_string(), "json".to_string()),
]
};
let where_cmd = run_br_smoke_at_root_with_env(
&runner_root,
["where"],
smoke_env(),
"non_hermetic_where_existing_workspace",
);
assert!(
where_cmd.status.success(),
"where smoke failed: {}",
where_cmd.stderr
);
let where_json: Value =
serde_json::from_str(&extract_json_payload(&where_cmd.stdout)).expect("where smoke json");
assert_eq!(
where_json["path"].as_str(),
Some(external_beads_dir_str.as_str())
);
assert_eq!(
where_json["database_path"].as_str(),
Some(custom_db_str.as_str())
);
assert_eq!(
where_json["jsonl_path"].as_str(),
Some(custom_jsonl_str.as_str())
);
let info_cmd = run_br_smoke_at_root_with_env(
&runner_root,
["info"],
smoke_env(),
"non_hermetic_info_existing_workspace",
);
assert!(
info_cmd.status.success(),
"info smoke failed: {}",
info_cmd.stderr
);
let info_json: Value =
serde_json::from_str(&extract_json_payload(&info_cmd.stdout)).expect("info smoke json");
assert_eq!(
info_json["beads_dir"].as_str(),
Some(external_beads_dir_str.as_str())
);
assert_eq!(
info_json["database_path"].as_str(),
Some(custom_db_str.as_str())
);
assert_eq!(
info_json["jsonl_path"].as_str(),
Some(custom_jsonl_str.as_str())
);
assert!(
info_json["issue_count"].as_u64().is_some(),
"info smoke should report issue_count: {info_json}"
);
let sync_status_cmd = run_br_smoke_at_root_with_env(
&runner_root,
["sync", "--status"],
smoke_env(),
"non_hermetic_sync_status_existing_workspace",
);
assert!(
sync_status_cmd.status.success(),
"sync --status smoke failed: {}",
sync_status_cmd.stderr
);
let sync_status_json: Value =
serde_json::from_str(&extract_json_payload(&sync_status_cmd.stdout))
.expect("sync status smoke json");
assert_eq!(sync_status_json["jsonl_exists"].as_bool(), Some(true));
}
#[test]
fn e2e_update_claim_multiple_ids_is_all_or_nothing() {
let _log = common::test_log("e2e_update_claim_multiple_ids_is_all_or_nothing");
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init_claim_multiple_ids");
assert!(init.status.success(), "init failed: {}", init.stderr);
let create_first = run_br(
&workspace,
["create", "First claim target", "--json"],
"create_first_claim_target",
);
assert!(
create_first.status.success(),
"first create failed: {}",
create_first.stderr
);
let first_issue: Value = serde_json::from_str(&extract_json_payload(&create_first.stdout))
.expect("first create json");
let first_id = first_issue["id"]
.as_str()
.expect("first issue id")
.to_string();
let create_second = run_br(
&workspace,
["create", "Second claim target", "--json"],
"create_second_claim_target",
);
assert!(
create_second.status.success(),
"second create failed: {}",
create_second.stderr
);
let second_issue: Value = serde_json::from_str(&extract_json_payload(&create_second.stdout))
.expect("second create json");
let second_id = second_issue["id"]
.as_str()
.expect("second issue id")
.to_string();
let claim_second = run_br(
&workspace,
["--actor", "bob", "update", &second_id, "--claim", "--json"],
"claim_second_issue_bob",
);
assert!(
claim_second.status.success(),
"claim second failed: {}",
claim_second.stderr
);
let claim_both = run_br(
&workspace,
[
"--actor", "alice", "update", &first_id, &second_id, "--claim", "--json",
],
"claim_multiple_ids_atomic",
);
assert!(
!claim_both.status.success(),
"expected multi-id claim to fail when one issue is already assigned"
);
let show_first = run_br(
&workspace,
["show", &first_id, "--json"],
"show_first_after_failed_multi_claim",
);
assert!(
show_first.status.success(),
"show first failed: {}",
show_first.stderr
);
let first_after: Vec<Value> =
serde_json::from_str(&extract_json_payload(&show_first.stdout)).expect("show first json");
assert_eq!(first_after[0]["status"].as_str(), Some("open"));
assert!(first_after[0]["assignee"].is_null());
let show_second = run_br(
&workspace,
["show", &second_id, "--json"],
"show_second_after_failed_multi_claim",
);
assert!(
show_second.status.success(),
"show second failed: {}",
show_second.stderr
);
let second_after: Vec<Value> =
serde_json::from_str(&extract_json_payload(&show_second.stdout)).expect("show second json");
assert_eq!(second_after[0]["status"].as_str(), Some("in_progress"));
assert_eq!(second_after[0]["assignee"].as_str(), Some("bob"));
}
#[test]
fn e2e_create_updates_last_touched_context() {
let _log = common::test_log("e2e_create_updates_last_touched_context");
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init_create_last_touched");
assert!(init.status.success(), "init failed: {}", init.stderr);
let create = run_br(
&workspace,
["create", "Create updates last touched"],
"create_last_touched",
);
assert!(create.status.success(), "create failed: {}", create.stderr);
let created_id = parse_created_id(&create.stdout);
assert!(!created_id.is_empty(), "missing created id");
let update = run_br(
&workspace,
["update", "--status", "in_progress"],
"update_last_touched_after_create",
);
assert!(update.status.success(), "update failed: {}", update.stderr);
let show = run_br(
&workspace,
["show", &created_id, "--json"],
"show_last_touched_after_create",
);
assert!(show.status.success(), "show failed: {}", show.stderr);
let payload = extract_json_payload(&show.stdout);
let json: Vec<Value> = serde_json::from_str(&payload).expect("show json");
assert_eq!(json[0]["status"], "in_progress");
}
#[test]
fn e2e_create_dry_run_does_not_update_last_touched_context() {
let _log = common::test_log("e2e_create_dry_run_does_not_update_last_touched_context");
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init_create_dry_run_last_touched");
assert!(init.status.success(), "init failed: {}", init.stderr);
let seed = run_br(
&workspace,
["create", "Seed for dry-run last touched"],
"seed_create_dry_run_last_touched",
);
assert!(seed.status.success(), "seed create failed: {}", seed.stderr);
let seed_id = parse_created_id(&seed.stdout);
assert!(!seed_id.is_empty(), "missing seed id");
let dry_run = run_br(
&workspace,
[
"create",
"Dry-run should not move last touched",
"--dry-run",
],
"create_dry_run_last_touched",
);
assert!(
dry_run.status.success(),
"dry-run create failed: {}",
dry_run.stderr
);
let update = run_br(
&workspace,
["update", "--status", "in_progress"],
"update_after_create_dry_run",
);
assert!(update.status.success(), "update failed: {}", update.stderr);
let show = run_br(
&workspace,
["show", &seed_id, "--json"],
"show_after_create_dry_run",
);
assert!(show.status.success(), "show failed: {}", show.stderr);
let payload = extract_json_payload(&show.stdout);
let json: Vec<Value> = serde_json::from_str(&payload).expect("show json");
assert_eq!(json[0]["status"], "in_progress");
}
#[test]
fn e2e_no_db_create_updates_last_touched_after_flush() {
let _log = common::test_log("e2e_no_db_create_updates_last_touched_after_flush");
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init_no_db_create_last_touched");
assert!(init.status.success(), "init failed: {}", init.stderr);
let seed = run_br(
&workspace,
["create", "Seed issue"],
"seed_no_db_create_last_touched",
);
assert!(seed.status.success(), "seed create failed: {}", seed.stderr);
let sync = run_br(
&workspace,
["sync", "--flush-only"],
"sync_no_db_create_last_touched",
);
assert!(sync.status.success(), "sync failed: {}", sync.stderr);
let create = run_br(
&workspace,
["--no-db", "create", "No DB create updates last touched"],
"create_no_db_last_touched",
);
assert!(
create.status.success(),
"no-db create failed: {}",
create.stderr
);
let created_id = parse_created_id(&create.stdout);
assert!(!created_id.is_empty(), "missing created id");
let update = run_br(
&workspace,
["update", "--status", "in_progress"],
"update_last_touched_after_no_db_create",
);
assert!(update.status.success(), "update failed: {}", update.stderr);
let show = run_br(
&workspace,
["show", &created_id, "--json"],
"show_last_touched_after_no_db_create",
);
assert!(show.status.success(), "show failed: {}", show.stderr);
let payload = extract_json_payload(&show.stdout);
let json: Vec<Value> = serde_json::from_str(&payload).expect("show json");
assert_eq!(json[0]["status"], "in_progress");
}
#[test]
fn e2e_quick_capture() {
let _log = common::test_log("e2e_quick_capture");
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let quick = run_br(&workspace, ["q", "Quick", "issue"], "quick");
assert!(quick.status.success(), "quick failed: {}", quick.stderr);
let quick_id = quick.stdout.lines().next().unwrap_or("").trim().to_string();
assert!(!quick_id.is_empty(), "missing quick id");
assert!(quick_id.contains('-'), "unexpected quick id format");
}
#[test]
fn e2e_sync_roundtrip() {
let _log = common::test_log("e2e_sync_roundtrip");
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let create = run_br(
&workspace,
["create", "Original title", "--no-auto-flush"],
"create",
);
assert!(create.status.success(), "create failed: {}", create.stderr);
let id = parse_created_id(&create.stdout);
assert!(!id.is_empty(), "missing created id");
let sync = run_br(&workspace, ["sync", "--flush-only"], "sync_flush");
assert!(sync.status.success(), "sync flush failed: {}", sync.stderr);
assert!(
sync.stdout.contains("Exported"),
"sync flush text missing export message"
);
let jsonl_path = workspace.root.join(".beads").join("issues.jsonl");
assert!(jsonl_path.exists(), "issues.jsonl missing after flush");
let contents = fs::read_to_string(&jsonl_path).expect("read jsonl");
let mut updated_lines = Vec::new();
for line in contents.lines() {
if line.trim().is_empty() {
continue;
}
let mut issue: Value = serde_json::from_str(line).expect("parse issue");
if issue["title"] == "Original title" {
issue["title"] = Value::String("Modified title".to_string());
issue["updated_at"] = Value::String(Utc::now().to_rfc3339());
}
updated_lines.push(serde_json::to_string(&issue).expect("serialize issue"));
}
fs::write(&jsonl_path, updated_lines.join("\n") + "\n").expect("write jsonl");
let expected_jsonl = fs::read(&jsonl_path).expect("read edited jsonl bytes");
sleep(Duration::from_millis(50));
let sync_import = run_br(&workspace, ["sync", "--import-only"], "sync_import");
assert!(
sync_import.status.success(),
"sync import failed: {}",
sync_import.stderr
);
let post_import_jsonl = fs::read(&jsonl_path).expect("read jsonl after import");
assert_eq!(
post_import_jsonl, expected_jsonl,
"sync --import-only must not rewrite issues.jsonl"
);
let show = run_br(&workspace, ["show", &id, "--json"], "show_after_import");
assert!(show.status.success(), "show failed: {}", show.stderr);
let payload = extract_json_payload(&show.stdout);
let show_json: Vec<Value> = serde_json::from_str(&payload).expect("show json");
assert_eq!(show_json[0]["title"], "Modified title");
}
#[test]
fn e2e_sync_import_staleness_and_force() {
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let create = run_br(&workspace, ["create", "Stale issue"], "create");
assert!(create.status.success(), "create failed: {}", create.stderr);
let flush = run_br(&workspace, ["sync", "--flush-only"], "sync_flush_stale");
assert!(
flush.status.success(),
"sync flush failed: {}",
flush.stderr
);
let import_first = run_br(&workspace, ["sync", "--import-only"], "sync_import_first");
assert!(
import_first.status.success(),
"sync import first failed: {}",
import_first.stderr
);
let import_skip = run_br(&workspace, ["sync", "--import-only"], "sync_import_skip");
assert!(
import_skip.status.success(),
"sync import skip failed: {}",
import_skip.stderr
);
assert!(
import_skip
.stdout
.contains("JSONL is current (hash unchanged since last import)"),
"sync import skip missing current message"
);
let import_force = run_br(
&workspace,
["sync", "--import-only", "--force"],
"sync_import_force",
);
assert!(
import_force.status.success(),
"sync import force failed: {}",
import_force.stderr
);
assert!(
import_force.stdout.contains("Imported from JSONL"),
"sync import force missing header"
);
assert!(
import_force.stdout.contains("Processed: 1 issues"),
"sync import force missing processed count"
);
}
#[test]
fn e2e_sync_merge_resolution_flags_choose_db_or_jsonl() {
let (jsonl_workspace, jsonl_issue_id) = prepare_merge_conflict_workspace();
let manual = run_br(&jsonl_workspace, ["sync", "--merge"], "merge_manual");
assert!(
!manual.status.success(),
"manual merge should report conflict: stdout={} stderr={}",
manual.stdout,
manual.stderr
);
assert!(
manual.stderr.contains("BothModified")
&& manual.stderr.contains("--force-db")
&& manual.stderr.contains("--force-jsonl"),
"manual conflict should explain explicit resolution flags: {}",
manual.stderr
);
let force_jsonl = run_br(
&jsonl_workspace,
["sync", "--merge", "--force-jsonl", "--json"],
"merge_force_jsonl",
);
assert!(
force_jsonl.status.success(),
"force-jsonl merge failed: {}",
force_jsonl.stderr
);
assert_issue_description(&jsonl_workspace, &jsonl_issue_id, "External description");
let (db_workspace, db_issue_id) = prepare_merge_conflict_workspace();
let force_db = run_br(
&db_workspace,
["sync", "--merge", "--force-db", "--json"],
"merge_force_db",
);
assert!(
force_db.status.success(),
"force-db merge failed: {}",
force_db.stderr
);
assert_issue_description(&db_workspace, &db_issue_id, "Local description");
}
#[test]
fn e2e_sync_force_jsonl_merge_does_not_resurrect_local_tombstone() {
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init_tombstone_merge");
assert!(init.status.success(), "init failed: {}", init.stderr);
let create = run_br(
&workspace,
["create", "Merge tombstone seed"],
"create_tombstone_merge",
);
assert!(create.status.success(), "create failed: {}", create.stderr);
let issue_id = parse_created_id(&create.stdout);
assert!(!issue_id.is_empty(), "missing created id");
let flush = run_br(
&workspace,
["sync", "--flush-only"],
"flush_tombstone_merge",
);
assert!(flush.status.success(), "flush failed: {}", flush.stderr);
let beads_dir = workspace.root.join(".beads");
let jsonl_path = beads_dir.join("issues.jsonl");
let base_snapshot_path = beads_dir.join("beads.base.jsonl");
fs::copy(&jsonl_path, &base_snapshot_path).expect("seed base snapshot");
let delete = run_br(
&workspace,
[
"delete",
&issue_id,
"--force",
"--reason",
"local tombstone before merge",
"--no-auto-flush",
],
"delete_local_tombstone",
);
assert!(delete.status.success(), "delete failed: {}", delete.stderr);
let jsonl = fs::read_to_string(&jsonl_path).expect("read jsonl");
let mut issue: Value = serde_json::from_str(jsonl.trim()).expect("parse jsonl issue");
issue["title"] = Value::String("JSONL resurrection attempt".to_string());
issue["status"] = Value::String("open".to_string());
issue["updated_at"] = Value::String("2999-01-01T00:00:00Z".to_string());
fs::write(
&jsonl_path,
format!(
"{}\n",
serde_json::to_string(&issue).expect("serialize issue")
),
)
.expect("write resurrection jsonl");
let merge = run_br(
&workspace,
["sync", "--merge", "--force-jsonl", "--json"],
"merge_force_jsonl_tombstone",
);
assert!(
merge.status.success(),
"force-jsonl merge failed: stdout={} stderr={}",
merge.stdout,
merge.stderr
);
let show = run_br(
&workspace,
["show", &issue_id, "--json"],
"show_tombstone_merge",
);
assert!(show.status.success(), "show failed: {}", show.stderr);
let payload = extract_json_payload(&show.stdout);
let issues: Vec<Value> = serde_json::from_str(&payload).expect("parse show json");
assert_eq!(
issues[0]["status"].as_str(),
Some("tombstone"),
"force-jsonl merge must not resurrect a local tombstone"
);
assert_ne!(
issues[0]["title"].as_str(),
Some("JSONL resurrection attempt"),
"resurrection attempt should not win the merge"
);
let merged_jsonl = fs::read_to_string(&jsonl_path).expect("read merged jsonl");
assert!(
merged_jsonl.contains("\"status\":\"tombstone\""),
"merged JSONL should export the protected tombstone: {merged_jsonl}"
);
}
#[test]
fn e2e_no_db_read_write() {
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let create = run_br(&workspace, ["create", "Seed issue"], "create_seed");
assert!(create.status.success(), "create failed: {}", create.stderr);
let sync = run_br(&workspace, ["sync", "--flush-only"], "sync_flush");
assert!(sync.status.success(), "sync flush failed: {}", sync.stderr);
let jsonl_path = workspace.root.join(".beads").join("issues.jsonl");
assert!(jsonl_path.exists(), "issues.jsonl missing");
let contents = fs::read_to_string(&jsonl_path).expect("read jsonl");
let mut issues: Vec<Value> = contents
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str(line).expect("parse jsonl"))
.collect();
assert!(!issues.is_empty(), "seed jsonl empty");
let now = Utc::now().to_rfc3339();
let mut injected = issues[0].clone();
injected["id"] = Value::String("bd-nodb1".to_string());
injected["title"] = Value::String("Injected no-db".to_string());
injected["created_at"] = Value::String(now.clone());
injected["updated_at"] = Value::String(now);
issues.push(injected);
let rewritten: Vec<String> = issues
.into_iter()
.map(|issue| serde_json::to_string(&issue).expect("serialize jsonl"))
.collect();
fs::write(&jsonl_path, rewritten.join("\n") + "\n").expect("write jsonl");
let list = run_br(&workspace, ["--no-db", "list", "--json"], "list_no_db");
assert!(
list.status.success(),
"list --no-db failed: {}",
list.stderr
);
let list_json = parse_list_issues(&list.stdout);
assert!(
list_json.iter().any(|item| item["id"] == "bd-nodb1"),
"no-db list missing injected issue"
);
let create_no_db = run_br(
&workspace,
["--no-db", "create", "No DB create"],
"create_no_db",
);
assert!(
create_no_db.status.success(),
"create --no-db failed: {}",
create_no_db.stderr
);
let created_id = parse_created_id(&create_no_db.stdout);
assert!(!created_id.is_empty(), "no-db create missing id");
let updated = fs::read_to_string(&jsonl_path).expect("read jsonl after no-db");
assert!(
updated.contains("No DB create"),
"no-db create did not update JSONL"
);
}
#[test]
fn e2e_no_db_mixed_prefixes_are_supported() {
let workspace = BrWorkspace::new();
let beads_dir = workspace.root.join(".beads");
fs::create_dir_all(&beads_dir).expect("create .beads");
let jsonl_path = beads_dir.join("issues.jsonl");
let now = Utc::now();
let issue_a = make_issue("aa-abc", "Alpha issue", now);
let issue_b = make_issue("bb-def", "Beta issue", now);
let lines = [
serde_json::to_string(&issue_a).expect("serialize issue a"),
serde_json::to_string(&issue_b).expect("serialize issue b"),
];
fs::write(&jsonl_path, lines.join("\n") + "\n").expect("write jsonl");
let list = run_br(
&workspace,
["--no-db", "list", "--json"],
"list_no_db_mixed",
);
assert!(
list.status.success(),
"list --no-db should accept mixed prefixes: {}",
list.stderr
);
let issues = parse_list_issues(&list.stdout);
let ids: Vec<&str> = issues
.iter()
.filter_map(|issue| issue["id"].as_str())
.collect();
assert!(ids.contains(&"aa-abc"), "expected aa-abc in {ids:?}");
assert!(ids.contains(&"bb-def"), "expected bb-def in {ids:?}");
}
#[test]
fn e2e_dotted_ids_survive_no_db_import_update_dep_and_flush() {
let workspace = BrWorkspace::new();
let jsonl_path = write_dotted_jsonl_fixture(&workspace);
let no_db_show = run_br(
&workspace,
["--no-db", "show", "bd-rchk0.5.6", "--json"],
"dotted_no_db_show",
);
assert_br_success(&no_db_show, "no-db show failed for dotted id");
let shown = parse_json_array(&no_db_show.stdout, "show json");
assert_eq!(shown[0]["id"].as_str(), Some("bd-rchk0.5.6"));
let no_db_update = run_br(
&workspace,
[
"--no-db",
"update",
"bd-rchk0.5.6",
"--priority",
"1",
"--json",
],
"dotted_no_db_update",
);
assert_br_success(&no_db_update, "no-db update failed for dotted id");
let updated = parse_json_array(&no_db_update.stdout, "update json");
assert_eq!(updated[0]["id"].as_str(), Some("bd-rchk0.5.6"));
assert_eq!(updated[0]["priority"].as_i64(), Some(1));
let imported = run_br(
&workspace,
["sync", "--import-only", "--json"],
"dotted_import",
);
assert_br_success(&imported, "import failed for dotted ids");
let import_json = parse_json_value(&imported.stdout);
assert_eq!(import_json["created"].as_i64(), Some(4));
let db_show = run_br(
&workspace,
["show", "bd-rchk0.5.6", "--json"],
"dotted_db_show",
);
assert_br_success(&db_show, "db show failed for dotted id");
let db_show_json = parse_json_array(&db_show.stdout, "db show json");
assert_eq!(db_show_json[0]["id"].as_str(), Some("bd-rchk0.5.6"));
assert!(
db_show_json[0]["dependents"]
.as_array()
.is_some_and(|items| items
.iter()
.any(|item| item["id"].as_str() == Some("bd-rchk0.5.6.1"))),
"dotted child dependent should resolve to the exact parent"
);
let db_update = run_br(
&workspace,
[
"--no-auto-flush",
"update",
"bd-rchk0.5.6",
"--priority",
"0",
"--json",
],
"dotted_db_update",
);
assert_br_success(&db_update, "db update failed for dotted id");
let db_update_json = parse_json_array(&db_update.stdout, "db update json");
assert_eq!(db_update_json[0]["id"].as_str(), Some("bd-rchk0.5.6"));
assert_eq!(db_update_json[0]["priority"].as_i64(), Some(0));
let dep_add = run_br(
&workspace,
[
"--no-auto-flush",
"dep",
"add",
"bd-rchk0.5.6",
"bd-blocker7",
"--json",
],
"dotted_dep_add",
);
assert_br_success(&dep_add, "dep add failed for dotted id");
let flush = run_br(
&workspace,
["sync", "--flush-only", "--json"],
"dotted_flush",
);
assert_br_success(&flush, "flush failed after dotted mutations");
let exported_issues = read_jsonl_values(&jsonl_path);
assert_eq!(exported_issues.len(), 4);
let exported_target = exported_issues
.iter()
.find(|issue| issue["id"].as_str() == Some("bd-rchk0.5.6"))
.expect("exported dotted target");
assert_eq!(exported_target["priority"].as_i64(), Some(0));
assert!(
exported_target["dependencies"]
.as_array()
.is_some_and(|items| items
.iter()
.any(|item| item["depends_on_id"].as_str() == Some("bd-blocker7"))),
"exported dotted target should retain the added dependency"
);
}
#[test]
fn e2e_no_db_mutations_succeed_with_large_export_hash_batches() {
let _log = common::test_log("e2e_no_db_mutations_succeed_with_large_export_hash_batches");
let workspace = BrWorkspace::new();
let beads_dir = workspace.root.join(".beads");
fs::create_dir_all(&beads_dir).expect("create .beads");
let jsonl_path = beads_dir.join("issues.jsonl");
let now = Utc::now();
let seed_records: Vec<String> = (0..33)
.map(|idx| {
serde_json::to_string(&make_issue(
&format!("bd-a{idx:02}"),
&format!("Seed issue {idx}"),
now,
))
.expect("serialize seed issue")
})
.collect();
fs::write(&jsonl_path, seed_records.join("\n") + "\n").expect("write seed jsonl");
let create = run_br(
&workspace,
["--no-db", "create", "Large no-db create"],
"create_no_db_large_hash_batch",
);
assert!(
create.status.success(),
"create --no-db should succeed when export_hashes rewrite spans many rows: {}",
create.stderr
);
let created_id = parse_created_id(&create.stdout);
assert!(
!created_id.is_empty(),
"missing created id after no-db create"
);
let add_comment = run_br(
&workspace,
[
"--no-db",
"comments",
"add",
&created_id,
"Large no-db comment",
"--json",
],
"comment_no_db_large_hash_batch",
);
assert!(
add_comment.status.success(),
"comments add --no-db should succeed after large export_hash rewrite: {}",
add_comment.stderr
);
let add_dependency = run_br(
&workspace,
["--no-db", "dep", "add", &created_id, "bd-a00", "--json"],
"dep_add_no_db_large_hash_batch",
);
assert!(
add_dependency.status.success(),
"dep add --no-db should succeed after large export_hash rewrite: {}",
add_dependency.stderr
);
let created_record = fs::read_to_string(&jsonl_path)
.expect("read issues.jsonl")
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str::<Value>(line).expect("parse issue json"))
.find(|record| record["id"].as_str() == Some(created_id.as_str()))
.expect("created issue record in issues.jsonl");
assert_eq!(created_record["title"], "Large no-db create");
assert!(
created_record["comments"]
.as_array()
.is_some_and(|comments| comments
.iter()
.any(|comment| { comment["text"].as_str() == Some("Large no-db comment") })),
"created issue should retain the no-db comment mutation"
);
assert!(
created_record["dependencies"]
.as_array()
.is_some_and(|dependencies| dependencies
.iter()
.any(|dependency| { dependency["depends_on_id"].as_str() == Some("bd-a00") })),
"created issue should retain the no-db dependency mutation"
);
}
#[test]
fn e2e_sync_flush_only_succeeds_with_large_mixed_prefix_export_hash_rewrite() {
let _log = common::test_log(
"e2e_sync_flush_only_succeeds_with_large_mixed_prefix_export_hash_rewrite",
);
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let db_path = workspace.root.join(".beads").join("beads.db");
let mut storage = SqliteStorage::open(&db_path).expect("open workspace db");
let now = Utc::now();
let seeded_hashes: Vec<(String, String)> = (0..160)
.map(|idx| {
let prefix = if idx % 2 == 0 { "bd" } else { "br" };
let issue_id = format!("{prefix}-sync-{idx:03}");
let issue = make_issue(&issue_id, &format!("Seed issue {idx}"), now);
storage.create_issue(&issue, "tester").expect("seed issue");
(issue_id, format!("seed-hash-{idx:03}"))
})
.collect();
storage
.set_export_hashes(&seeded_hashes)
.expect("seed export hashes");
let flush = run_br(
&workspace,
["sync", "--flush-only", "--no-auto-import"],
"sync_flush_large_mixed_export_hash_rewrite",
);
assert!(
flush.status.success(),
"sync --flush-only should succeed when rewriting many existing mixed-prefix export hashes: {}",
flush.stderr
);
let jsonl_path = workspace.root.join(".beads").join("issues.jsonl");
let exported_count = fs::read_to_string(&jsonl_path)
.expect("read issues.jsonl")
.lines()
.filter(|line| !line.trim().is_empty())
.count();
assert_eq!(exported_count, seeded_hashes.len());
}
#[test]
fn e2e_sync_manifest() {
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let create = run_br(
&workspace,
["create", "Manifest issue", "--no-auto-flush"],
"create",
);
assert!(create.status.success(), "create failed: {}", create.stderr);
let sync = run_br(
&workspace,
["sync", "--flush-only", "--manifest"],
"sync_manifest",
);
assert!(
sync.status.success(),
"sync manifest failed: {}",
sync.stderr
);
let manifest_path = workspace.root.join(".beads").join(".manifest.json");
assert!(manifest_path.exists(), "manifest not created");
}
#[test]
fn e2e_sync_status_json() {
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let create = run_br(&workspace, ["create", "Status issue"], "create");
assert!(create.status.success(), "create failed: {}", create.stderr);
let status = run_br(&workspace, ["sync", "--status", "--json"], "sync_status");
assert!(
status.status.success(),
"sync status failed: {}",
status.stderr
);
let payload = extract_json_payload(&status.stdout);
let status_json: Value = serde_json::from_str(&payload).expect("sync status json");
assert!(status_json["dirty_count"].is_number());
}
#[test]
fn e2e_sync_witness_json_is_deterministic_and_read_only() {
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let first = run_br(&workspace, ["create", "Witness issue A"], "create_a");
assert!(first.status.success(), "create A failed: {}", first.stderr);
let second = run_br(&workspace, ["create", "Witness issue B"], "create_b");
assert!(
second.status.success(),
"create B failed: {}",
second.stderr
);
let flush = run_br(&workspace, ["sync", "--flush-only", "--json"], "sync_flush");
assert!(
flush.status.success(),
"sync flush failed: {}",
flush.stderr
);
let status_before = run_br(
&workspace,
["sync", "--status", "--json"],
"status_before_witness",
);
assert!(
status_before.status.success(),
"pre-witness status failed: {}",
status_before.stderr
);
let status_before_json: Value =
serde_json::from_str(&extract_json_payload(&status_before.stdout))
.expect("pre-witness status json");
assert_eq!(status_before_json["dirty_count"].as_u64(), Some(0));
let witness = run_br(
&workspace,
["sync", "--witness", "--witness-chunk-lines", "1", "--json"],
"sync_witness",
);
assert!(
witness.status.success(),
"sync witness failed: {}",
witness.stderr
);
let witness_json: Value =
serde_json::from_str(&extract_json_payload(&witness.stdout)).expect("sync witness json");
assert!(
witness_json["jsonl_path"]
.as_str()
.is_some_and(|path| path.ends_with(".beads/issues.jsonl")),
"unexpected witness path: {witness_json}"
);
let witness_body = &witness_json["witness"];
assert_eq!(witness_body["schema_version"], "br.jsonl-witness.v1");
assert_eq!(witness_body["chunk_size_lines"].as_u64(), Some(1));
assert_eq!(witness_body["line_count"].as_u64(), Some(2));
assert!(witness_body["byte_count"].as_u64().is_some_and(|n| n > 0));
assert_eq!(witness_body["root_hash"].as_str().map(str::len), Some(64));
assert_eq!(witness_body["chunks"].as_array().map(Vec::len), Some(2));
let witness_again = run_br(
&workspace,
["sync", "--witness", "--witness-chunk-lines", "1", "--json"],
"sync_witness_again",
);
assert!(
witness_again.status.success(),
"second sync witness failed: {}",
witness_again.stderr
);
let witness_again_json: Value =
serde_json::from_str(&extract_json_payload(&witness_again.stdout))
.expect("second sync witness json");
assert_eq!(
witness_body["root_hash"],
witness_again_json["witness"]["root_hash"]
);
let status_after = run_br(
&workspace,
["sync", "--status", "--json"],
"status_after_witness",
);
assert!(
status_after.status.success(),
"post-witness status failed: {}",
status_after.stderr
);
let status_after_json: Value =
serde_json::from_str(&extract_json_payload(&status_after.stdout))
.expect("post-witness status json");
assert_eq!(status_after_json["dirty_count"].as_u64(), Some(0));
}
fn assert_base_witness_reuse_plan(witness_json: &Value) {
let reuse_plan = &witness_json["base_reuse_plan"];
assert_eq!(
reuse_plan["comparison"]["safe_reuse_prefix_chunks"].as_u64(),
Some(1)
);
let schedule = &reuse_plan["schedule"];
assert_eq!(schedule["candidate_output_actions"].as_u64(), Some(2));
assert_eq!(schedule["metadata_only_drop_actions"].as_u64(), Some(0));
assert_eq!(schedule["reusable_actions"].as_u64(), Some(1));
assert_eq!(schedule["read_added_actions"].as_u64(), Some(1));
assert_eq!(schedule["max_parallel_candidate_actions"].as_u64(), Some(2));
assert_eq!(
schedule["deterministic_candidate_order"].as_bool(),
Some(true)
);
let actions = reuse_plan["actions"].as_array().expect("reuse actions");
assert_eq!(actions.len(), 2);
assert_eq!(actions[0]["action"].as_str(), Some("reuse_unchanged"));
assert_eq!(actions[0]["base_index"].as_u64(), Some(0));
assert_eq!(actions[0]["candidate_index"].as_u64(), Some(0));
assert_eq!(actions[1]["action"].as_str(), Some("read_added"));
assert!(actions[1]["base_index"].is_null());
assert_eq!(actions[1]["candidate_index"].as_u64(), Some(1));
let work_plan = &witness_json["base_parallel_work_plan"];
assert_eq!(work_plan["max_parallelism"].as_u64(), Some(1));
assert_eq!(work_plan["total_batches"].as_u64(), Some(2));
assert_eq!(work_plan["candidate_output_batches"].as_u64(), Some(2));
assert_eq!(work_plan["metadata_only_drop_batches"].as_u64(), Some(0));
assert_eq!(work_plan["deterministic_batch_order"].as_bool(), Some(true));
let batches = work_plan["batches"].as_array().expect("work batches");
assert_eq!(batches.len(), 2);
assert_eq!(batches[0]["kind"].as_str(), Some("candidate_output"));
assert_eq!(batches[0]["candidate_start_index"].as_u64(), Some(0));
assert_eq!(batches[0]["candidate_end_index"].as_u64(), Some(1));
assert_eq!(batches[0]["action_count"].as_u64(), Some(1));
assert_eq!(batches[0]["actions"].as_array().map(Vec::len), Some(1));
assert_eq!(batches[1]["kind"].as_str(), Some("candidate_output"));
assert_eq!(batches[1]["candidate_start_index"].as_u64(), Some(1));
assert_eq!(batches[1]["candidate_end_index"].as_u64(), Some(2));
assert_eq!(batches[1]["action_count"].as_u64(), Some(1));
let materialization = &witness_json["base_reuse_materialization"];
assert_eq!(materialization["reused_chunks"].as_u64(), Some(1));
assert_eq!(materialization["rebuilt_chunks"].as_u64(), Some(0));
assert_eq!(materialization["read_added_chunks"].as_u64(), Some(1));
assert_eq!(materialization["dropped_chunks"].as_u64(), Some(0));
assert_eq!(
materialization["output_byte_count"].as_u64(),
witness_json["witness"]["byte_count"].as_u64()
);
assert_eq!(
materialization["reused_byte_count"].as_u64(),
reuse_plan["schedule"]["reusable_byte_count"].as_u64()
);
assert_eq!(
materialization["read_added_byte_count"].as_u64(),
reuse_plan["schedule"]["read_added_byte_count"].as_u64()
);
}
#[test]
fn e2e_sync_flush_export_parallelism_preserves_jsonl_bytes() {
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init_parallel_export");
assert!(init.status.success(), "init failed: {}", init.stderr);
let now = Utc::now();
let records = (0..300)
.map(|index| {
let id = format!("bd-pex{index:04}");
let mut issue = make_issue(&id, &format!("Parallel export issue {index:04}"), now);
issue.description = Some(format!(
"Synthetic JSONL export payload {index:04} with enough stable text to exercise ordered line preparation."
));
issue.assignee = Some(format!("agent-{:03}", index % 64));
issue.labels = vec![
"parallel-export".to_string(),
"jsonl".to_string(),
format!("lane-{:02}", index % 16),
];
issue.comments.push(Comment {
id: i64::from(index) + 1,
issue_id: id,
author: format!("agent-{:03}", index % 64),
body: format!(
"Deterministic comment payload {index:04} for serde_json export parity."
),
created_at: now,
});
serde_json::to_string(&issue).expect("serialize parallel export fixture issue")
})
.collect::<Vec<_>>();
let jsonl_path = workspace.root.join(".beads").join("issues.jsonl");
fs::write(&jsonl_path, records.join("\n") + "\n").expect("write parallel export fixture");
let import = run_br(
&workspace,
["sync", "--import-only", "--force", "--json"],
"import_parallel_export_fixture",
);
assert!(
import.status.success(),
"import fixture failed: {}",
import.stderr
);
let serial = run_br(
&workspace,
[
"sync",
"--flush-only",
"--force",
"--export-parallelism",
"1",
"--json",
],
"flush_parallel_export_serial",
);
assert!(
serial.status.success(),
"serial export failed: {}",
serial.stderr
);
let serial_json: Value =
serde_json::from_str(&extract_json_payload(&serial.stdout)).expect("serial flush json");
let serial_bytes = fs::read(&jsonl_path).expect("read serial jsonl");
let parallel = run_br(
&workspace,
[
"sync",
"--flush-only",
"--force",
"--export-parallelism",
"4",
"--json",
],
"flush_parallel_export_parallel",
);
assert!(
parallel.status.success(),
"parallel export failed: {}",
parallel.stderr
);
let parallel_json: Value =
serde_json::from_str(&extract_json_payload(¶llel.stdout)).expect("parallel flush json");
let parallel_bytes = fs::read(&jsonl_path).expect("read parallel jsonl");
assert_eq!(parallel_bytes, serial_bytes);
assert_eq!(serial_json["exported_issues"].as_u64(), Some(300));
assert_eq!(parallel_json["exported_issues"].as_u64(), Some(300));
assert_eq!(parallel_json["content_hash"], serial_json["content_hash"]);
}
#[test]
fn e2e_sync_witness_reports_base_snapshot_drift() {
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init_base_witness");
assert!(init.status.success(), "init failed: {}", init.stderr);
let first = run_br(
&workspace,
["create", "Base witness issue A"],
"create_base_witness_a",
);
assert!(first.status.success(), "create A failed: {}", first.stderr);
let first_flush = run_br(
&workspace,
["sync", "--flush-only", "--json"],
"sync_flush_base_witness_a",
);
assert!(
first_flush.status.success(),
"first sync flush failed: {}",
first_flush.stderr
);
let second = run_br(
&workspace,
["create", "Base witness issue B"],
"create_base_witness_b",
);
assert!(
second.status.success(),
"create B failed: {}",
second.stderr
);
let second_flush = run_br(
&workspace,
["sync", "--flush-only", "--json"],
"sync_flush_base_witness_b",
);
assert!(
second_flush.status.success(),
"second sync flush failed: {}",
second_flush.stderr
);
let jsonl_path = workspace.root.join(".beads").join("issues.jsonl");
let base_snapshot_path = workspace.root.join(".beads").join("beads.base.jsonl");
let current_jsonl = fs::read_to_string(&jsonl_path).expect("read current jsonl");
let first_candidate_line = current_jsonl
.lines()
.next()
.expect("candidate jsonl should contain at least one issue");
fs::write(&base_snapshot_path, format!("{first_candidate_line}\n"))
.expect("seed base witness snapshot");
let witness = run_br(
&workspace,
[
"sync",
"--witness",
"--witness-chunk-lines",
"1",
"--witness-parallelism",
"1",
"--json",
],
"sync_witness_base_compare",
);
assert!(
witness.status.success(),
"sync witness failed: {}",
witness.stderr
);
let witness_json: Value =
serde_json::from_str(&extract_json_payload(&witness.stdout)).expect("sync witness json");
assert!(
witness_json["base_jsonl_path"]
.as_str()
.is_some_and(|path| path.ends_with(".beads/beads.base.jsonl")),
"unexpected base witness path: {witness_json}"
);
let comparison = &witness_json["base_comparison"];
assert_eq!(comparison["schema_versions_match"].as_bool(), Some(true));
assert_eq!(comparison["chunk_size_lines_match"].as_bool(), Some(true));
assert_eq!(comparison["drift_detected"].as_bool(), Some(true));
assert_eq!(comparison["base_line_count"].as_u64(), Some(1));
assert_eq!(comparison["candidate_line_count"].as_u64(), Some(2));
assert_eq!(comparison["unchanged_chunks"].as_u64(), Some(1));
assert_eq!(comparison["changed_chunks"].as_u64(), Some(0));
assert_eq!(comparison["added_chunks"].as_u64(), Some(1));
assert_eq!(comparison["removed_chunks"].as_u64(), Some(0));
assert_eq!(comparison["safe_reuse_prefix_chunks"].as_u64(), Some(1));
assert_eq!(comparison["first_changed_chunk_index"].as_u64(), Some(1));
assert_base_witness_reuse_plan(&witness_json);
}
#[test]
fn e2e_version_text() {
let workspace = BrWorkspace::new();
let version = run_br(&workspace, ["version"], "version");
assert!(
version.status.success(),
"version failed: {}",
version.stderr
);
assert!(
version.stdout.contains("br version"),
"version output missing header"
);
}
#[test]
fn e2e_doctor_json() {
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let doctor = run_br(&workspace, ["doctor", "--json"], "doctor_json");
assert!(doctor.status.success(), "doctor failed: {}", doctor.stderr);
let payload = extract_json_payload(&doctor.stdout);
let doctor_json: Value = serde_json::from_str(&payload).expect("doctor json");
assert!(doctor_json["checks"].is_array(), "doctor checks missing");
}
#[test]
fn e2e_sync_status_text() {
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let status = run_br(&workspace, ["sync", "--status"], "sync_status_text");
assert!(
status.status.success(),
"sync status text failed: {}",
status.stderr
);
assert!(
status.stdout.contains("Sync Status"),
"sync status text missing header"
);
}
#[test]
fn e2e_version_json() {
let workspace = BrWorkspace::new();
let version = run_br(&workspace, ["version", "--json"], "version_json");
assert!(
version.status.success(),
"version json failed: {}",
version.stderr
);
let payload = extract_json_payload(&version.stdout);
let version_json: Value = serde_json::from_str(&payload).expect("version json");
assert!(version_json["version"].is_string());
}
#[test]
fn e2e_sync_conflict_markers_aborts_import() {
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let create = run_br(&workspace, ["create", "Test issue"], "create");
assert!(create.status.success(), "create failed: {}", create.stderr);
let flush = run_br(&workspace, ["sync", "--flush-only"], "sync_flush");
assert!(
flush.status.success(),
"sync flush failed: {}",
flush.stderr
);
let jsonl_path = workspace.root.join(".beads").join("issues.jsonl");
let original = fs::read_to_string(&jsonl_path).expect("read jsonl");
let conflicted = format!(
"<<<<<<< HEAD\n{}\n=======\n{}\n>>>>>>> feature-branch\n",
original.trim(),
original.trim()
);
fs::write(&jsonl_path, conflicted).expect("write conflicted jsonl");
let import = run_br(
&workspace,
["sync", "--import-only", "--force"],
"sync_import_conflict",
);
assert!(
!import.status.success(),
"import should fail with conflict markers"
);
assert!(
import.stderr.contains("Merge conflict markers detected")
|| import.stdout.contains("Merge conflict markers detected"),
"error message should mention conflict markers: stdout={}, stderr={}",
import.stdout,
import.stderr
);
}
#[test]
fn e2e_sync_tombstone_preservation() {
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let create = run_br(&workspace, ["create", "Issue to delete"], "create");
assert!(create.status.success(), "create failed: {}", create.stderr);
let id = parse_created_id(&create.stdout);
let delete = run_br(
&workspace,
["delete", &id, "--force", "--reason", "Testing tombstone"],
"delete",
);
assert!(delete.status.success(), "delete failed: {}", delete.stderr);
let show = run_br(&workspace, ["show", &id, "--json"], "show_tombstone");
assert!(
show.status.success(),
"show tombstone failed: {}",
show.stderr
);
let payload = extract_json_payload(&show.stdout);
let show_json: Vec<Value> = serde_json::from_str(&payload).expect("show json");
assert_eq!(
show_json[0]["status"], "tombstone",
"issue should be tombstone"
);
let flush = run_br(&workspace, ["sync", "--flush-only"], "sync_flush");
assert!(
flush.status.success(),
"sync flush failed: {}",
flush.stderr
);
let jsonl_path = workspace.root.join(".beads").join("issues.jsonl");
let contents = fs::read_to_string(&jsonl_path).expect("read jsonl");
assert!(
contents.contains("\"status\":\"tombstone\""),
"JSONL should contain tombstone status"
);
let workspace2 = BrWorkspace::new();
let init2 = run_br(&workspace2, ["init"], "init2");
assert!(init2.status.success(), "init2 failed: {}", init2.stderr);
let jsonl_path2 = workspace2.root.join(".beads").join("issues.jsonl");
fs::copy(&jsonl_path, &jsonl_path2).expect("copy jsonl");
let import = run_br(
&workspace2,
["sync", "--import-only", "--force"],
"sync_import",
);
assert!(import.status.success(), "import failed: {}", import.stderr);
let show2 = run_br(&workspace2, ["show", &id, "--json"], "show_after_import");
assert!(
show2.status.success(),
"show after import failed: {}",
show2.stderr
);
let payload2 = extract_json_payload(&show2.stdout);
let show_json2: Vec<Value> = serde_json::from_str(&payload2).expect("show json after import");
assert_eq!(
show_json2[0]["status"], "tombstone",
"tombstone should be preserved after import"
);
}
#[test]
fn e2e_sync_tombstone_protection() {
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let create = run_br(&workspace, ["create", "Protected issue"], "create");
assert!(create.status.success(), "create failed: {}", create.stderr);
let id = parse_created_id(&create.stdout);
let delete = run_br(
&workspace,
["delete", &id, "--force", "--reason", "Tombstone test"],
"delete",
);
assert!(delete.status.success(), "delete failed: {}", delete.stderr);
let flush = run_br(&workspace, ["sync", "--flush-only"], "sync_flush");
assert!(
flush.status.success(),
"sync flush failed: {}",
flush.stderr
);
let jsonl_path = workspace.root.join(".beads").join("issues.jsonl");
let contents = fs::read_to_string(&jsonl_path).expect("read jsonl");
let mut modified_lines = Vec::new();
for line in contents.lines() {
if line.trim().is_empty() {
continue;
}
let mut issue: Value = serde_json::from_str(line).expect("parse issue");
if issue["status"] == "tombstone" {
issue["status"] = Value::String("open".to_string());
issue["updated_at"] = Value::String(Utc::now().to_rfc3339());
}
modified_lines.push(serde_json::to_string(&issue).expect("serialize"));
}
fs::write(&jsonl_path, modified_lines.join("\n") + "\n").expect("write modified jsonl");
sleep(Duration::from_millis(50));
let import = run_br(
&workspace,
["sync", "--import-only", "--force"],
"sync_import_resurrect",
);
assert!(import.status.success(), "import failed: {}", import.stderr);
let show = run_br(
&workspace,
["show", &id, "--json"],
"show_after_resurrect_attempt",
);
assert!(show.status.success(), "show failed: {}", show.stderr);
let payload = extract_json_payload(&show.stdout);
let show_json: Vec<Value> = serde_json::from_str(&payload).expect("show json");
assert_eq!(
show_json[0]["status"], "tombstone",
"tombstone protection should prevent resurrection"
);
}
#[test]
fn e2e_sync_content_hash_consistency() {
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let create1 = run_br(
&workspace,
["create", "Issue A", "--no-auto-flush"],
"create1",
);
assert!(
create1.status.success(),
"create1 failed: {}",
create1.stderr
);
let create2 = run_br(
&workspace,
["create", "Issue B", "--no-auto-flush"],
"create2",
);
assert!(
create2.status.success(),
"create2 failed: {}",
create2.stderr
);
let flush1 = run_br(
&workspace,
["sync", "--flush-only", "--json"],
"sync_flush1",
);
assert!(
flush1.status.success(),
"sync flush1 failed: {}",
flush1.stderr
);
let payload1 = extract_json_payload(&flush1.stdout);
let flush_json1: Value = serde_json::from_str(&payload1).expect("flush json1");
let hash1 = flush_json1["content_hash"].as_str().expect("content_hash1");
let flush2 = run_br(
&workspace,
["sync", "--flush-only", "--force", "--json"],
"sync_flush2",
);
assert!(
flush2.status.success(),
"sync flush2 failed: {}",
flush2.stderr
);
let payload2 = extract_json_payload(&flush2.stdout);
let flush_json2: Value = serde_json::from_str(&payload2).expect("flush json2");
let hash2 = flush_json2["content_hash"].as_str().expect("content_hash2");
assert_eq!(
hash1, hash2,
"content hash should be consistent for same content"
);
let status = run_br(&workspace, ["sync", "--status", "--json"], "sync_status");
assert!(
status.status.success(),
"sync status failed: {}",
status.stderr
);
let status_payload = extract_json_payload(&status.stdout);
let status_json: Value = serde_json::from_str(&status_payload).expect("status json");
let stored_hash = status_json["jsonl_content_hash"]
.as_str()
.expect("stored hash");
assert_eq!(stored_hash, hash2, "stored hash should match export hash");
}
#[test]
fn e2e_jsonl_discovery_prefers_issues() {
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let create = run_br(&workspace, ["create", "Discovery test"], "create");
assert!(create.status.success(), "create failed: {}", create.stderr);
let id = parse_created_id(&create.stdout);
let flush = run_br(&workspace, ["sync", "--flush-only"], "sync_flush");
assert!(
flush.status.success(),
"sync flush failed: {}",
flush.stderr
);
let issues_path = workspace.root.join(".beads").join("issues.jsonl");
assert!(issues_path.exists(), "issues.jsonl should be created");
let beads_path = workspace.root.join(".beads").join("beads.jsonl");
fs::write(&beads_path, "{\"id\": \"fake-id\", \"title\": \"Legacy issue\", \"status\": \"open\", \"issue_type\": \"task\", \"priority\": 2, \"labels\": [], \"created_at\": \"2026-01-01T00:00:00Z\", \"updated_at\": \"2026-01-01T00:00:00Z\", \"ephemeral\": false, \"pinned\": false, \"is_template\": false, \"dependencies\": [], \"comments\": []}\n").expect("write legacy");
let import = run_br(
&workspace,
["sync", "--import-only", "--force"],
"sync_import",
);
assert!(import.status.success(), "import failed: {}", import.stderr);
let show = run_br(&workspace, ["show", &id, "--json"], "show_original");
assert!(
show.status.success(),
"show original failed: {}",
show.stderr
);
let show_fake = run_br(&workspace, ["show", "fake-id", "--json"], "show_fake");
let fake_payload = extract_json_payload(&show_fake.stdout);
let fake_json: Vec<Value> = serde_json::from_str(&fake_payload).unwrap_or_default();
assert!(
fake_json.is_empty() || show_fake.stderr.contains("not found"),
"fake issue from beads.jsonl should not be imported when issues.jsonl exists"
);
}
#[test]
fn e2e_jsonl_discovery_uses_legacy_when_no_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");
if issues_path.exists() {
fs::remove_file(&issues_path).expect("remove issues.jsonl");
}
let beads_path = workspace.root.join(".beads").join("beads.jsonl");
fs::write(&beads_path, "{\"id\": \"bd-legacy1\", \"title\": \"Legacy issue\", \"status\": \"open\", \"issue_type\": \"task\", \"priority\": 2, \"labels\": [], \"created_at\": \"2026-01-01T00:00:00Z\", \"updated_at\": \"2026-01-01T00:00:00Z\", \"ephemeral\": false, \"pinned\": false, \"is_template\": false, \"dependencies\": [], \"comments\": []}\n").expect("write legacy");
let import = run_br(
&workspace,
["sync", "--import-only", "--force"],
"sync_import_legacy",
);
assert!(
import.status.success(),
"import legacy failed: {}",
import.stderr
);
let show = run_br(&workspace, ["show", "bd-legacy1", "--json"], "show_legacy");
assert!(show.status.success(), "show legacy failed: {}", show.stderr);
let payload = extract_json_payload(&show.stdout);
let show_json: Vec<Value> = serde_json::from_str(&payload).expect("show json");
assert_eq!(
show_json[0]["title"], "Legacy issue",
"legacy issue should be imported from beads.jsonl"
);
}