#![allow(
clippy::items_after_statements,
clippy::format_push_string,
clippy::too_many_lines
)]
mod common;
use common::cli::{BrWorkspace, run_br};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
fn visit_dir(dir: &Path, base: &Path, hash_map: &mut BTreeMap<String, String>) {
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
let rel_path = path
.strip_prefix(base)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
if path.is_file() {
if let Ok(contents) = fs::read(&path) {
let mut digest = Sha256::new();
digest.update(&contents);
let hash = beads_rust::util::hex_encode(&digest.finalize());
hash_map.insert(rel_path, hash);
}
} else if path.is_dir() {
visit_dir(&path, base, hash_map);
}
}
}
}
fn hash_directory_contents(dir: &Path) -> BTreeMap<String, String> {
let mut hash_map = BTreeMap::new();
if !dir.exists() {
return hash_map;
}
visit_dir(dir, dir, &mut hash_map);
hash_map
}
fn get_git_status(dir: &Path) -> String {
Command::new("git")
.args(["status", "--porcelain"])
.current_dir(dir)
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
.unwrap_or_default()
}
fn get_head_commit(dir: &Path) -> Option<String> {
Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(dir)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
}
fn get_commit_count(dir: &Path) -> usize {
Command::new("git")
.args(["rev-list", "--count", "HEAD"])
.current_dir(dir)
.output()
.ok()
.filter(|o| o.status.success())
.map_or(0, |o| {
String::from_utf8_lossy(&o.stdout)
.trim()
.parse()
.unwrap_or(0)
})
}
fn init_git_repo(workspace: &BrWorkspace) {
let init = Command::new("git")
.args(["init"])
.current_dir(&workspace.root)
.output()
.expect("git init");
assert!(init.status.success(), "git init failed");
let _ = Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(&workspace.root)
.output();
let _ = Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&workspace.root)
.output();
let src_dir = workspace.root.join("src");
fs::create_dir_all(&src_dir).expect("create src dir");
fs::write(
src_dir.join("main.rs"),
"fn main() { println!(\"Hello\"); }",
)
.expect("write main.rs");
let _ = Command::new("git")
.args(["add", "."])
.current_dir(&workspace.root)
.output();
let commit = Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(&workspace.root)
.output()
.expect("git commit");
assert!(commit.status.success(), "initial commit failed");
}
#[test]
fn regression_sync_export_does_not_create_commits() {
let workspace = BrWorkspace::new();
init_git_repo(&workspace);
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let create1 = run_br(
&workspace,
["create", "Test issue 1", "--no-auto-flush"],
"create1",
);
assert!(
create1.status.success(),
"create1 failed: {}",
create1.stderr
);
let create2 = run_br(
&workspace,
["create", "Test issue 2", "--no-auto-flush"],
"create2",
);
assert!(
create2.status.success(),
"create2 failed: {}",
create2.stderr
);
let commit_before = get_head_commit(&workspace.root);
let commit_count_before = get_commit_count(&workspace.root);
let git_status_before = get_git_status(&workspace.root);
let git_dir_hash_before = hash_directory_contents(&workspace.root.join(".git"));
let sync = run_br(&workspace, ["sync", "--flush-only"], "sync_export");
assert!(sync.status.success(), "sync export failed: {}", sync.stderr);
let commit_after = get_head_commit(&workspace.root);
let commit_count_after = get_commit_count(&workspace.root);
let git_dir_hash_after = hash_directory_contents(&workspace.root.join(".git"));
assert_eq!(
commit_before, commit_after,
"SAFETY VIOLATION: sync export created a git commit!\n\
Before: {commit_before:?}\n\
After: {commit_after:?}"
);
assert_eq!(
commit_count_before, commit_count_after,
"SAFETY VIOLATION: sync export changed commit count!\n\
Before: {commit_count_before}\n\
After: {commit_count_after}"
);
let filter_transient = |hashes: &BTreeMap<String, String>| -> BTreeMap<String, String> {
hashes
.iter()
.filter(|(k, _)| {
let is_lock = Path::new(k)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("lock"));
!is_lock
&& !k.contains("index")
&& !k.contains("FETCH_HEAD")
&& !k.contains("ORIG_HEAD")
})
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
};
let filtered_before = filter_transient(&git_dir_hash_before);
let filtered_after = filter_transient(&git_dir_hash_after);
for (path, hash) in &filtered_after {
assert!(
filtered_before.contains_key(path),
"SAFETY VIOLATION: sync export created new file in .git/: {path}\n\
Hash: {hash}"
);
}
for (path, hash_before) in &filtered_before {
if let Some(hash_after) = filtered_after.get(path) {
assert!(
hash_before == hash_after,
"SAFETY VIOLATION: sync export modified file in .git/: {path}\n\
Before: {hash_before}\n\
After: {hash_after}"
);
}
}
eprintln!(
"[PASS] sync export did not create commits or mutate .git\n\
- Commit before: {:?}\n\
- Commit after: {:?}\n\
- Status before: {:?}\n\
- .git files checked: {}",
commit_before,
commit_after,
git_status_before.trim(),
filtered_after.len()
);
}
#[test]
fn regression_sync_import_does_not_create_commits() {
let workspace = BrWorkspace::new();
init_git_repo(&workspace);
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let create = run_br(
&workspace,
["create", "Original issue", "--no-auto-flush"],
"create",
);
assert!(create.status.success(), "create failed: {}", create.stderr);
let flush = run_br(&workspace, ["sync", "--flush-only"], "flush");
assert!(flush.status.success(), "flush failed: {}", flush.stderr);
let commit_before = get_head_commit(&workspace.root);
let commit_count_before = get_commit_count(&workspace.root);
let git_dir_hash_before = hash_directory_contents(&workspace.root.join(".git"));
let import = run_br(
&workspace,
["sync", "--import-only", "--force"],
"sync_import",
);
assert!(
import.status.success(),
"sync import failed: {}",
import.stderr
);
let commit_after = get_head_commit(&workspace.root);
let commit_count_after = get_commit_count(&workspace.root);
let git_dir_hash_after = hash_directory_contents(&workspace.root.join(".git"));
assert_eq!(
commit_before, commit_after,
"SAFETY VIOLATION: sync import created a git commit!\n\
Before: {commit_before:?}\n\
After: {commit_after:?}"
);
assert_eq!(
commit_count_before, commit_count_after,
"SAFETY VIOLATION: sync import changed commit count!\n\
Before: {commit_count_before}\n\
After: {commit_count_after}"
);
let filter_transient = |hashes: &BTreeMap<String, String>| -> BTreeMap<String, String> {
hashes
.iter()
.filter(|(k, _)| {
let is_lock = Path::new(k)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("lock"));
!is_lock
&& !k.contains("index")
&& !k.contains("FETCH_HEAD")
&& !k.contains("ORIG_HEAD")
})
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
};
let filtered_before = filter_transient(&git_dir_hash_before);
let filtered_after = filter_transient(&git_dir_hash_after);
for (path, hash) in &filtered_after {
assert!(
filtered_before.contains_key(path),
"SAFETY VIOLATION: sync import created new file in .git/: {path}"
);
assert!(
filtered_before.get(path) == Some(hash),
"SAFETY VIOLATION: sync import modified file in .git/: {path}"
);
}
eprintln!(
"[PASS] sync import did not create commits or mutate .git\n\
- Commit before: {commit_before:?}\n\
- Commit after: {commit_after:?}"
);
}
#[test]
fn regression_full_sync_cycle_does_not_touch_git() {
let workspace = BrWorkspace::new();
init_git_repo(&workspace);
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let _ = run_br(
&workspace,
["create", "Bug fix", "-t", "bug", "--no-auto-flush"],
"create_bug",
);
let _ = run_br(
&workspace,
["create", "New feature", "-t", "feature", "--no-auto-flush"],
"create_feature",
);
let _ = run_br(
&workspace,
["create", "Documentation", "-t", "docs", "--no-auto-flush"],
"create_docs",
);
let baseline_commit = get_head_commit(&workspace.root);
let baseline_count = get_commit_count(&workspace.root);
let baseline_git_hash = hash_directory_contents(&workspace.root.join(".git"));
let flush1 = run_br(&workspace, ["sync", "--flush-only"], "flush1");
assert!(flush1.status.success(), "flush1 failed");
let jsonl_path = workspace.root.join(".beads").join("issues.jsonl");
let original = fs::read_to_string(&jsonl_path).expect("read jsonl");
let modified = original.replace("Bug fix", "Critical bug fix");
fs::write(&jsonl_path, modified).expect("write jsonl");
let import = run_br(
&workspace,
["sync", "--import-only", "--force"],
"import_modified",
);
assert!(import.status.success(), "import failed");
let flush2 = run_br(&workspace, ["sync", "--flush-only", "--force"], "flush2");
assert!(flush2.status.success(), "flush2 failed");
let status = run_br(&workspace, ["sync", "--status"], "status");
assert!(status.status.success(), "status failed");
let final_commit = get_head_commit(&workspace.root);
let final_count = get_commit_count(&workspace.root);
let final_git_hash = hash_directory_contents(&workspace.root.join(".git"));
assert_eq!(
baseline_commit, final_commit,
"SAFETY VIOLATION: full sync cycle created git commits!"
);
assert_eq!(
baseline_count, final_count,
"SAFETY VIOLATION: full sync cycle changed commit count!"
);
let filter_transient = |hashes: &BTreeMap<String, String>| -> BTreeMap<String, String> {
hashes
.iter()
.filter(|(k, _)| {
let is_lock = Path::new(k)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("lock"));
!is_lock && !k.contains("index") && !k.contains("HEAD")
})
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
};
let baseline_filtered = filter_transient(&baseline_git_hash);
let final_filtered = filter_transient(&final_git_hash);
let mut violations = Vec::new();
for (path, hash) in &final_filtered {
match baseline_filtered.get(path) {
None => violations.push(format!("NEW: {path}")),
Some(old_hash) if old_hash != hash => violations.push(format!("MODIFIED: {path}")),
_ => {}
}
}
assert!(
violations.is_empty(),
"SAFETY VIOLATION: full sync cycle mutated .git/:\n{}",
violations.join("\n")
);
eprintln!(
"[PASS] full sync cycle did not touch git\n\
- Operations: init -> create x3 -> export -> modify -> import -> export -> status\n\
- Commits unchanged: {:?}\n\
- .git files verified: {}",
baseline_commit,
final_filtered.len()
);
}
#[test]
fn regression_sync_manifest_does_not_touch_git() {
let workspace = BrWorkspace::new();
init_git_repo(&workspace);
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let create = run_br(
&workspace,
["create", "Manifest test issue", "--no-auto-flush"],
"create",
);
assert!(create.status.success(), "create failed: {}", create.stderr);
let commit_before = get_head_commit(&workspace.root);
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 file should be created");
let commit_after = get_head_commit(&workspace.root);
assert_eq!(
commit_before, commit_after,
"SAFETY VIOLATION: sync --manifest created git commit!"
);
eprintln!("[PASS] sync --manifest did not touch git");
}
#[test]
fn regression_sync_never_touches_source_files() {
let workspace = BrWorkspace::new();
init_git_repo(&workspace);
let src_dir = workspace.root.join("src");
fs::write(src_dir.join("lib.rs"), "pub fn hello() {}").expect("write lib.rs");
fs::write(src_dir.join("util.rs"), "pub fn util() {}").expect("write util.rs");
fs::write(
workspace.root.join("Cargo.toml"),
"[package]\nname = \"test\"\nversion = \"0.1.0\"",
)
.expect("write Cargo.toml");
let source_files = [
workspace.root.join("src").join("main.rs"),
workspace.root.join("src").join("lib.rs"),
workspace.root.join("src").join("util.rs"),
workspace.root.join("Cargo.toml"),
];
let hashes_before: BTreeMap<_, _> = source_files
.iter()
.filter(|p| p.exists())
.map(|p| {
let content = fs::read(p).unwrap();
let mut hasher = Sha256::new();
hasher.update(&content);
(p.clone(), beads_rust::util::hex_encode(&hasher.finalize()))
})
.collect();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed");
let create = run_br(
&workspace,
["create", "Test issue", "--no-auto-flush"],
"create",
);
assert!(create.status.success(), "create failed");
let flush = run_br(&workspace, ["sync", "--flush-only"], "flush");
assert!(flush.status.success(), "flush failed");
let import = run_br(&workspace, ["sync", "--import-only", "--force"], "import");
assert!(import.status.success(), "import failed");
let hashes_after: BTreeMap<_, _> = source_files
.iter()
.filter(|p| p.exists())
.map(|p| {
let content = fs::read(p).unwrap();
let mut hasher = Sha256::new();
hasher.update(&content);
(p.clone(), beads_rust::util::hex_encode(&hasher.finalize()))
})
.collect();
for (path, hash_before) in &hashes_before {
let hash_after = hashes_after
.get(path)
.unwrap_or_else(|| panic!("Source file deleted: {path:?}"));
assert_eq!(
hash_before, hash_after,
"SAFETY VIOLATION: sync modified source file: {path:?}"
);
}
assert_eq!(
hashes_before.len(),
hashes_after.len(),
"SAFETY VIOLATION: sync deleted source files!"
);
eprintln!(
"[PASS] sync never touched source files\n\
- Files verified: {:?}",
source_files
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().to_string())
.collect::<Vec<_>>()
);
}
fn is_allowed_sync_file(rel_path: &str) -> bool {
if !rel_path.starts_with(".beads/") && !rel_path.starts_with(".beads\\") {
return false;
}
let filename = Path::new(rel_path)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
const ALLOWED_EXACT_NAMES: &[&str] = &[".manifest.json", "metadata.json", "last-touched"];
if ALLOWED_EXACT_NAMES.iter().any(|&name| filename == name) {
return true;
}
if filename.ends_with(".jsonl.tmp") {
return true;
}
if let Some(prefix) = filename.strip_suffix(".tmp")
&& let Some((base, pid)) = prefix.rsplit_once(".jsonl.")
&& !base.is_empty()
&& !pid.is_empty()
&& pid.chars().all(|c| c.is_ascii_digit())
{
return true;
}
if filename.ends_with(".meta.json")
&& (rel_path.contains(".br_history/") || rel_path.contains(".br_history\\"))
{
return true;
}
if rel_path.contains(".br_recovery/") || rel_path.contains(".br_recovery\\") {
const RECOVERY_SUFFIXES: &[&str] = &[".bak", ".rebuild-failed", ".truncated-wal"];
if RECOVERY_SUFFIXES.iter().any(|s| filename.ends_with(s)) {
return true;
}
}
const ALLOWED_EXTENSIONS: &[&str] = &[
"db", "db-journal", "db-wal", "db-shm", "jsonl", "jsonl.tmp", ];
for ext in ALLOWED_EXTENSIONS {
if filename.ends_with(&format!(".{ext}")) {
return true;
}
}
false
}
#[derive(Debug)]
struct FileTreeSnapshot {
files: BTreeMap<String, (String, u64)>,
#[allow(dead_code)]
taken_at: std::time::SystemTime,
}
impl FileTreeSnapshot {
fn new(root: &Path) -> Self {
let mut files = BTreeMap::new();
Self::collect_files(root, root, &mut files);
Self {
files,
taken_at: std::time::SystemTime::now(),
}
}
fn collect_files(dir: &Path, base: &Path, files: &mut BTreeMap<String, (String, u64)>) {
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
let rel_path = path
.strip_prefix(base)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
if rel_path.starts_with("logs") || rel_path.starts_with("logs/") {
continue;
}
if path.is_file() {
if let Ok(contents) = fs::read(&path) {
let mut hasher = Sha256::new();
hasher.update(&contents);
let hash = beads_rust::util::hex_encode(&hasher.finalize());
let size = contents.len() as u64;
files.insert(rel_path, (hash, size));
}
} else if path.is_dir() {
Self::collect_files(&path, base, files);
}
}
}
}
fn diff(&self, after: &Self) -> FileTreeDiff {
let mut created = Vec::new();
let mut modified = Vec::new();
let mut deleted = Vec::new();
let mut unchanged = Vec::new();
for (path, (hash_after, size_after)) in &after.files {
match self.files.get(path) {
None => created.push(FileChange {
path: path.clone(),
hash_before: None,
hash_after: Some(hash_after.clone()),
size_before: None,
size_after: Some(*size_after),
}),
Some((hash_before, size_before)) if hash_before != hash_after => {
modified.push(FileChange {
path: path.clone(),
hash_before: Some(hash_before.clone()),
hash_after: Some(hash_after.clone()),
size_before: Some(*size_before),
size_after: Some(*size_after),
});
}
Some(_) => {
unchanged.push(path.clone());
}
}
}
for (path, (hash_before, size_before)) in &self.files {
if !after.files.contains_key(path) {
deleted.push(FileChange {
path: path.clone(),
hash_before: Some(hash_before.clone()),
hash_after: None,
size_before: Some(*size_before),
size_after: None,
});
}
}
FileTreeDiff {
created,
modified,
deleted,
unchanged,
}
}
}
#[derive(Debug)]
struct FileChange {
path: String,
hash_before: Option<String>,
hash_after: Option<String>,
size_before: Option<u64>,
size_after: Option<u64>,
}
impl FileChange {
fn format_detail(&self) -> String {
match (&self.hash_before, &self.hash_after) {
(None, Some(h)) => format!(
" CREATED: {} (size: {} bytes, hash: {}...)",
self.path,
self.size_after.unwrap_or(0),
&h[..16.min(h.len())]
),
(Some(h), None) => format!(
" DELETED: {} (was {} bytes, hash: {}...)",
self.path,
self.size_before.unwrap_or(0),
&h[..16.min(h.len())]
),
(Some(hb), Some(ha)) => format!(
" MODIFIED: {} ({} -> {} bytes)\n Before: {}...\n After: {}...",
self.path,
self.size_before.unwrap_or(0),
self.size_after.unwrap_or(0),
&hb[..16.min(hb.len())],
&ha[..16.min(ha.len())]
),
(None, None) => format!(" UNKNOWN: {}", self.path),
}
}
}
#[derive(Debug)]
struct FileTreeDiff {
created: Vec<FileChange>,
modified: Vec<FileChange>,
deleted: Vec<FileChange>,
unchanged: Vec<String>,
}
impl FileTreeDiff {
#[allow(dead_code)]
fn has_changes(&self) -> bool {
!self.created.is_empty() || !self.modified.is_empty() || !self.deleted.is_empty()
}
fn check_allowed_changes(&self) -> (Vec<&FileChange>, Vec<&FileChange>) {
let mut violations = Vec::new();
let mut allowed = Vec::new();
for change in &self.created {
if is_allowed_sync_file(&change.path) {
allowed.push(change);
} else {
violations.push(change);
}
}
for change in &self.modified {
if is_allowed_sync_file(&change.path) {
allowed.push(change);
} else {
violations.push(change);
}
}
for change in &self.deleted {
if is_allowed_sync_file(&change.path) {
allowed.push(change);
} else {
violations.push(change);
}
}
(violations, allowed)
}
fn format_log(&self) -> String {
let mut log = String::new();
if !self.created.is_empty() {
log.push_str(&format!(
"\n=== CREATED FILES ({}) ===\n",
self.created.len()
));
for change in &self.created {
log.push_str(&change.format_detail());
log.push('\n');
}
}
if !self.modified.is_empty() {
log.push_str(&format!(
"\n=== MODIFIED FILES ({}) ===\n",
self.modified.len()
));
for change in &self.modified {
log.push_str(&change.format_detail());
log.push('\n');
}
}
if !self.deleted.is_empty() {
log.push_str(&format!(
"\n=== DELETED FILES ({}) ===\n",
self.deleted.len()
));
for change in &self.deleted {
log.push_str(&change.format_detail());
log.push('\n');
}
}
if log.is_empty() {
log.push_str("No file changes detected.\n");
}
log.push_str(&format!(
"\n=== SUMMARY ===\n\
Created: {}\n\
Modified: {}\n\
Deleted: {}\n\
Unchanged: {}\n",
self.created.len(),
self.modified.len(),
self.deleted.len(),
self.unchanged.len()
));
log
}
}
#[test]
fn integration_sync_only_touches_allowed_files() {
let workspace = BrWorkspace::new();
create_realistic_project(&workspace);
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let _ = run_br(
&workspace,
[
"create",
"Feature: User authentication",
"-t",
"feature",
"-p",
"1",
"--no-auto-flush",
],
"create_feature",
);
let _ = run_br(
&workspace,
[
"create",
"Bug: Login fails on mobile",
"-t",
"bug",
"-p",
"0",
"--no-auto-flush",
],
"create_bug",
);
let _ = run_br(
&workspace,
[
"create",
"Task: Write unit tests",
"-t",
"task",
"--no-auto-flush",
],
"create_task",
);
let _ = run_br(
&workspace,
[
"create",
"Docs: Update README",
"-t",
"docs",
"-p",
"3",
"--no-auto-flush",
],
"create_docs",
);
eprintln!("\n[TEST 1] Testing sync export...");
let snapshot_before_export = FileTreeSnapshot::new(&workspace.root);
eprintln!(
" Snapshot before export: {} files",
snapshot_before_export.files.len()
);
let export = run_br(&workspace, ["sync", "--flush-only"], "sync_export");
assert!(
export.status.success(),
"sync export failed: {}\nLog: {}",
export.stderr,
fs::read_to_string(&export.log_path).unwrap_or_default()
);
let snapshot_after_export = FileTreeSnapshot::new(&workspace.root);
eprintln!(
" Snapshot after export: {} files",
snapshot_after_export.files.len()
);
let diff_export = snapshot_before_export.diff(&snapshot_after_export);
let (violations_export, allowed_export) = diff_export.check_allowed_changes();
let export_log = format!(
"=== SYNC EXPORT PHASE ===\n\
Command: br sync --flush-only\n\
Status: {}\n\
Duration: {:?}\n\n\
{}\n\n\
ALLOWED CHANGES:\n{}\n\n\
VIOLATIONS:\n{}",
export.status,
export.duration,
diff_export.format_log(),
if allowed_export.is_empty() {
" (none)".to_string()
} else {
allowed_export
.iter()
.map(|c| c.format_detail())
.collect::<Vec<_>>()
.join("\n")
},
if violations_export.is_empty() {
" (none)".to_string()
} else {
violations_export
.iter()
.map(|c| c.format_detail())
.collect::<Vec<_>>()
.join("\n")
}
);
let export_log_path = workspace.log_dir.join("sync_export_diff.log");
fs::write(&export_log_path, &export_log).expect("write export log");
assert!(
violations_export.is_empty(),
"SAFETY VIOLATION: sync export modified files outside allowed list!\n\n\
{}\n\n\
Detailed log: {}",
violations_export
.iter()
.map(|c| c.format_detail())
.collect::<Vec<_>>()
.join("\n"),
export_log_path.display()
);
eprintln!(
" [PASS] Export modified {} allowed files, 0 violations",
allowed_export.len()
);
eprintln!("\n[TEST 2] Testing sync import...");
let jsonl_path = workspace.root.join(".beads").join("issues.jsonl");
if jsonl_path.exists() {
let original = fs::read_to_string(&jsonl_path).expect("read jsonl");
let modified = original.replace("User authentication", "User auth v2");
fs::write(&jsonl_path, modified).expect("write modified jsonl");
}
let snapshot_before_import = FileTreeSnapshot::new(&workspace.root);
eprintln!(
" Snapshot before import: {} files",
snapshot_before_import.files.len()
);
let import = run_br(
&workspace,
["sync", "--import-only", "--force"],
"sync_import",
);
assert!(
import.status.success(),
"sync import failed: {}\nLog: {}",
import.stderr,
fs::read_to_string(&import.log_path).unwrap_or_default()
);
let snapshot_after_import = FileTreeSnapshot::new(&workspace.root);
eprintln!(
" Snapshot after import: {} files",
snapshot_after_import.files.len()
);
let diff_import = snapshot_before_import.diff(&snapshot_after_import);
let (violations_import, allowed_import) = diff_import.check_allowed_changes();
let import_log = format!(
"=== SYNC IMPORT PHASE ===\n\
Command: br sync --import-only --force\n\
Status: {}\n\
Duration: {:?}\n\n\
{}\n\n\
ALLOWED CHANGES:\n{}\n\n\
VIOLATIONS:\n{}",
import.status,
import.duration,
diff_import.format_log(),
if allowed_import.is_empty() {
" (none)".to_string()
} else {
allowed_import
.iter()
.map(|c| c.format_detail())
.collect::<Vec<_>>()
.join("\n")
},
if violations_import.is_empty() {
" (none)".to_string()
} else {
violations_import
.iter()
.map(|c| c.format_detail())
.collect::<Vec<_>>()
.join("\n")
}
);
let import_log_path = workspace.log_dir.join("sync_import_diff.log");
fs::write(&import_log_path, &import_log).expect("write import log");
assert!(
violations_import.is_empty(),
"SAFETY VIOLATION: sync import modified files outside allowed list!\n\n\
{}\n\n\
Detailed log: {}",
violations_import
.iter()
.map(|c| c.format_detail())
.collect::<Vec<_>>()
.join("\n"),
import_log_path.display()
);
eprintln!(
" [PASS] Import modified {} allowed files, 0 violations",
allowed_import.len()
);
eprintln!("\n[TEST 3] Testing full sync cycle...");
let snapshot_before_cycle = FileTreeSnapshot::new(&workspace.root);
let _ = run_br(
&workspace,
["create", "Chore: Update deps", "-t", "chore"],
"create_chore",
);
let _ = run_br(&workspace, ["sync", "--flush-only"], "cycle_flush1");
let _ = run_br(
&workspace,
["sync", "--import-only", "--force"],
"cycle_import",
);
let _ = run_br(
&workspace,
["sync", "--flush-only", "--force"],
"cycle_flush2",
);
let snapshot_after_cycle = FileTreeSnapshot::new(&workspace.root);
let diff_cycle = snapshot_before_cycle.diff(&snapshot_after_cycle);
let (violations_cycle, allowed_cycle) = diff_cycle.check_allowed_changes();
let cycle_log = format!(
"=== FULL SYNC CYCLE ===\n\
Operations: create -> flush -> import -> flush\n\n\
{}\n\n\
ALLOWED CHANGES:\n{}\n\n\
VIOLATIONS:\n{}",
diff_cycle.format_log(),
if allowed_cycle.is_empty() {
" (none)".to_string()
} else {
allowed_cycle
.iter()
.map(|c| c.format_detail())
.collect::<Vec<_>>()
.join("\n")
},
if violations_cycle.is_empty() {
" (none)".to_string()
} else {
violations_cycle
.iter()
.map(|c| c.format_detail())
.collect::<Vec<_>>()
.join("\n")
}
);
let cycle_log_path = workspace.log_dir.join("sync_cycle_diff.log");
fs::write(&cycle_log_path, &cycle_log).expect("write cycle log");
assert!(
violations_cycle.is_empty(),
"SAFETY VIOLATION: full sync cycle modified files outside allowed list!\n\n\
{}\n\n\
Detailed log: {}",
violations_cycle
.iter()
.map(|c| c.format_detail())
.collect::<Vec<_>>()
.join("\n"),
cycle_log_path.display()
);
eprintln!(
" [PASS] Full cycle modified {} allowed files, 0 violations",
allowed_cycle.len()
);
eprintln!(
"\n[PASS] Integration test: sync only touches allowed files\n\
- Export: {} allowed changes, 0 violations\n\
- Import: {} allowed changes, 0 violations\n\
- Full cycle: {} allowed changes, 0 violations\n\
- Total files in workspace: {}\n\
- Logs available in: {}",
allowed_export.len(),
allowed_import.len(),
allowed_cycle.len(),
snapshot_after_cycle.files.len(),
workspace.log_dir.display()
);
}
fn create_realistic_project(workspace: &BrWorkspace) {
let src_dir = workspace.root.join("src");
fs::create_dir_all(&src_dir).expect("create src dir");
fs::write(
src_dir.join("main.rs"),
"fn main() {\n println!(\"Hello, world!\");\n}\n",
)
.expect("write main.rs");
fs::write(src_dir.join("lib.rs"), "pub mod utils;\npub mod models;\n").expect("write lib.rs");
let utils_dir = src_dir.join("utils");
fs::create_dir_all(&utils_dir).expect("create utils dir");
fs::write(utils_dir.join("mod.rs"), "pub mod helpers;\n").expect("write utils/mod.rs");
fs::write(
utils_dir.join("helpers.rs"),
"pub fn helper() -> i32 { 42 }\n",
)
.expect("write helpers.rs");
let models_dir = src_dir.join("models");
fs::create_dir_all(&models_dir).expect("create models dir");
fs::write(
models_dir.join("mod.rs"),
"pub struct User { name: String }\n",
)
.expect("write models/mod.rs");
let tests_dir = workspace.root.join("tests");
fs::create_dir_all(&tests_dir).expect("create tests dir");
fs::write(
tests_dir.join("integration_tests.rs"),
"#[test]\nfn test_something() { assert!(true); }\n",
)
.expect("write integration_tests.rs");
fs::write(
workspace.root.join("Cargo.toml"),
"[package]\nname = \"test-project\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
)
.expect("write Cargo.toml");
fs::write(workspace.root.join(".gitignore"), "/target\n").expect("write .gitignore");
let docs_dir = workspace.root.join("docs");
fs::create_dir_all(&docs_dir).expect("create docs dir");
fs::write(
docs_dir.join("README.md"),
"# Test Project\n\nThis is a test.\n",
)
.expect("write docs/README.md");
fs::write(
docs_dir.join("API.md"),
"# API Reference\n\n## Functions\n\n- `helper()`: Returns 42\n",
)
.expect("write API.md");
fs::write(
workspace.root.join(".editorconfig"),
"root = true\n\n[*]\nindent_style = space\n",
)
.expect("write .editorconfig");
let data_dir = workspace.root.join("data");
fs::create_dir_all(&data_dir).expect("create data dir");
fs::write(
data_dir.join("config.json"),
"{\"version\": 1, \"enabled\": true}\n",
)
.expect("write config.json");
fs::write(
data_dir.join("sample.csv"),
"id,name,value\n1,foo,100\n2,bar,200\n",
)
.expect("write sample.csv");
let assets_dir = workspace.root.join("assets");
fs::create_dir_all(&assets_dir).expect("create assets dir");
let png_header = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
fs::write(assets_dir.join("logo.png"), png_header).expect("write logo.png");
eprintln!(
"Created realistic project structure with {} source files",
count_files(&workspace.root)
);
}
fn count_files(dir: &Path) -> usize {
let mut count = 0;
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
count += 1;
} else if path.is_dir() {
count += count_files(&path);
}
}
}
count
}
#[test]
fn integration_sync_manifest_only_touches_allowed_files() {
let workspace = BrWorkspace::new();
create_realistic_project(&workspace);
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed");
let _ = run_br(
&workspace,
["create", "Test issue", "--no-auto-flush"],
"create",
);
let snapshot_before = FileTreeSnapshot::new(&workspace.root);
let sync = run_br(
&workspace,
["sync", "--flush-only", "--manifest"],
"sync_manifest",
);
assert!(
sync.status.success(),
"sync manifest failed: {}",
sync.stderr
);
let snapshot_after = FileTreeSnapshot::new(&workspace.root);
let diff = snapshot_before.diff(&snapshot_after);
let (violations, allowed) = diff.check_allowed_changes();
let log = format!("=== SYNC MANIFEST TEST ===\n\n{}\n", diff.format_log());
let log_path = workspace.log_dir.join("sync_manifest_diff.log");
fs::write(&log_path, &log).expect("write log");
assert!(
violations.is_empty(),
"SAFETY VIOLATION: sync --manifest modified files outside allowed list!\n\n\
{}\n\n\
Log: {}",
violations
.iter()
.map(|c| c.format_detail())
.collect::<Vec<_>>()
.join("\n"),
log_path.display()
);
let manifest_exists = workspace
.root
.join(".beads")
.join(".manifest.json")
.exists();
assert!(manifest_exists, "Manifest file should have been created");
eprintln!(
"[PASS] sync --manifest only touched {} allowed files",
allowed.len()
);
}
#[test]
fn integration_sync_after_recovery_artifact_present_does_not_touch_artifacts() {
let workspace = BrWorkspace::new();
create_realistic_project(&workspace);
eprintln!(
"[yyxo TEST] integration_sync_after_recovery_artifact_present_does_not_touch_artifacts"
);
eprintln!(" Phase 1: init + create issues");
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let _ = run_br(
&workspace,
["create", "Test issue", "-t", "task", "--no-auto-flush"],
"create_test",
);
let _ = run_br(&workspace, ["sync", "--flush-only"], "initial_flush");
eprintln!(" Phase 2: pre-place a stale .br_recovery artifact");
let recovery_dir = workspace.root.join(".beads").join(".br_recovery");
fs::create_dir_all(&recovery_dir).expect("create recovery dir");
let stale_artifact = recovery_dir.join("beads.db.20260101_000000_000000000.bak");
let stale_contents = b"STALE_RECOVERY_ARTIFACT_DO_NOT_TOUCH";
fs::write(&stale_artifact, stale_contents).expect("write stale artifact");
let stale_meta_before = fs::metadata(&stale_artifact).expect("stat stale");
let stale_mtime_before = stale_meta_before.modified().expect("mtime stale");
eprintln!(
" stale artifact: {} ({} bytes, mtime={:?})",
stale_artifact.display(),
stale_meta_before.len(),
stale_mtime_before
);
let snapshot_before = FileTreeSnapshot::new(&workspace.root);
eprintln!(" files before: {}", snapshot_before.files.len());
eprintln!(" Phase 3: run sync export + import (should NOT touch the stale artifact)");
let _ = run_br(
&workspace,
["create", "Another issue", "-t", "task"],
"create_2nd",
);
let _ = run_br(&workspace, ["sync", "--flush-only"], "second_flush");
let _ = run_br(
&workspace,
["sync", "--import-only", "--force"],
"second_import",
);
let snapshot_after = FileTreeSnapshot::new(&workspace.root);
eprintln!(" files after: {}", snapshot_after.files.len());
let stale_meta_after = fs::metadata(&stale_artifact).expect("stat stale (post-sync)");
let stale_mtime_after = stale_meta_after
.modified()
.expect("mtime stale (post-sync)");
let stale_contents_after = fs::read(&stale_artifact).expect("read stale (post-sync)");
assert_eq!(
stale_contents_after, stale_contents,
"PC-RECOVERY: pre-existing recovery artifact contents were modified by sync"
);
assert_eq!(
stale_mtime_before, stale_mtime_after,
"PC-RECOVERY: pre-existing recovery artifact mtime was changed by sync"
);
if let Ok(entries) = fs::read_dir(&recovery_dir) {
for entry in entries.flatten() {
let path = entry.path();
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let ext_matches =
|e: &str| -> bool { path.extension().is_some_and(|x| x.eq_ignore_ascii_case(e)) };
let valid =
ext_matches("bak") || ext_matches("rebuild-failed") || ext_matches("truncated-wal");
assert!(
valid,
"PC-RECOVERY: unexpected file in .br_recovery/: {name} (must end in .bak/.rebuild-failed/.truncated-wal)"
);
eprintln!(" recovery dir entry: {name} (valid suffix)");
}
}
eprintln!(
" [PASS] stale recovery artifact untouched; new artifacts (if any) have valid suffixes"
);
}
#[test]
fn integration_sync_does_not_create_or_modify_dotgit_anywhere() {
let workspace = BrWorkspace::new();
create_realistic_project(&workspace);
eprintln!("[yyxo TEST] integration_sync_does_not_create_or_modify_dotgit_anywhere");
eprintln!(" Phase 1: init + create + initial sync");
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let _ = run_br(
&workspace,
["create", "T1", "-t", "task", "--no-auto-flush"],
"create_1",
);
let _ = run_br(
&workspace,
["create", "T2", "-t", "bug", "--no-auto-flush"],
"create_2",
);
let dotgit_paths_before = collect_all_dotgit_paths(&workspace.root);
eprintln!(
" dotgit paths before: {} (expected 0)",
dotgit_paths_before.len()
);
assert!(
dotgit_paths_before.is_empty(),
"test fixture leaked dotgit paths before sync (test setup bug, not a sync regression): {dotgit_paths_before:?}"
);
eprintln!(" Phase 2: full sync cycle");
let _ = run_br(&workspace, ["sync", "--flush-only"], "flush");
let _ = run_br(
&workspace,
["sync", "--import-only", "--force"],
"import_force",
);
let _ = run_br(
&workspace,
["sync", "--flush-only", "--force"],
"flush_force",
);
let dotgit_paths_after = collect_all_dotgit_paths(&workspace.root);
eprintln!(
" dotgit paths after: {} (expected 0)",
dotgit_paths_after.len()
);
assert!(
dotgit_paths_after.is_empty(),
"PC-1/NGI-3 VIOLATION: sync created dotgit paths: {dotgit_paths_after:?}"
);
eprintln!(" [PASS] no .git/ paths created or modified by sync cycle");
}
#[test]
fn integration_sync_in_subdirectory_only_touches_nearest_beads_dir() {
let workspace = BrWorkspace::new();
create_realistic_project(&workspace);
eprintln!("[yyxo TEST] integration_sync_in_subdirectory_only_touches_nearest_beads_dir");
eprintln!(" Phase 1: init + create issues");
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let _ = run_br(
&workspace,
["create", "Subdir test", "-t", "task", "--no-auto-flush"],
"create_subdir",
);
let snapshot_before = FileTreeSnapshot::new(&workspace.root);
eprintln!(" files before: {}", snapshot_before.files.len());
eprintln!(" Phase 2: sync from subdir (mimics `cd src/cli && br sync`)");
let subdir = workspace.root.join("src");
if !subdir.exists() {
fs::create_dir_all(&subdir).expect("create src/");
}
let br_path = std::env::var("CARGO_BIN_EXE_br")
.or_else(|_| std::env::var("BR_BIN").map(|b| b.trim().to_string()))
.unwrap_or_else(|_| "br".to_string());
let output = std::process::Command::new(&br_path)
.args(["sync", "--flush-only"])
.current_dir(&subdir)
.env("RUST_LOG", "info")
.env("RCH_DISABLED", "1")
.output()
.expect("spawn br sync from subdir");
eprintln!(
" sync exit: {} stderr_len: {}",
output.status,
output.stderr.len()
);
assert!(
output.status.success() || output.status.code() == Some(2),
"sync from subdir should either succeed or surface a clear workspace-discovery error; got status {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr)
);
let snapshot_after = FileTreeSnapshot::new(&workspace.root);
let diff = snapshot_before.diff(&snapshot_after);
let (violations, allowed) = diff.check_allowed_changes();
eprintln!(
" diff: {} allowed, {} violations",
allowed.len(),
violations.len()
);
assert!(
violations.is_empty(),
"PC-1: sync from subdir touched files outside the nearest .beads/: {:?}",
violations
.iter()
.map(|c| c.format_detail())
.collect::<Vec<_>>()
);
eprintln!(" [PASS] sync from subdirectory only touched nearest .beads/");
}
#[test]
fn integration_sync_with_external_jsonl_path_touches_only_target_and_beads() {
use common::cli::run_br_with_env;
let workspace = BrWorkspace::new();
create_realistic_project(&workspace);
eprintln!(
"[yyxo TEST] integration_sync_with_external_jsonl_path_touches_only_target_and_beads"
);
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let _ = run_br(
&workspace,
[
"create",
"External JSONL test",
"-t",
"task",
"--no-auto-flush",
],
"create_for_external",
);
let external_dir = workspace.temp_dir.path().join("ext-jsonl-store");
fs::create_dir_all(&external_dir).expect("create external dir");
let external_jsonl = external_dir.join("custom-issues.jsonl");
eprintln!(" external target: {}", external_jsonl.display());
let snapshot_before = FileTreeSnapshot::new(workspace.temp_dir.path());
eprintln!(" files before sync: {}", snapshot_before.files.len());
let env_vars = vec![("BEADS_JSONL", external_jsonl.to_str().unwrap().to_string())];
let sync = run_br_with_env(
&workspace,
["sync", "--flush-only", "--allow-external-jsonl", "--force"],
env_vars,
"sync_with_external",
);
eprintln!(
" sync exit: {} stderr_len: {}",
sync.status,
sync.stderr.len()
);
let snapshot_after = FileTreeSnapshot::new(workspace.temp_dir.path());
let diff = snapshot_after.files.iter().filter(|(path, _)| {
if path.starts_with(".beads/") || path.contains(".beads\\") {
return false;
}
if path.contains("ext-jsonl-store/") || path.contains("ext-jsonl-store\\") {
return false;
}
if path.starts_with("logs") || path.starts_with("logs/") {
return false;
}
!snapshot_before.files.contains_key(path.as_str())
&& !path.starts_with("src/")
&& !path.starts_with("tests/")
&& !path.starts_with("docs/")
&& path.as_str() != "Cargo.toml"
&& path.as_str() != "README.md"
});
let unexpected: Vec<_> = diff.collect();
if !unexpected.is_empty() {
eprintln!(" UNEXPECTED FILES TOUCHED OUTSIDE .beads/ AND EXTERNAL TARGET:");
for (p, _) in &unexpected {
eprintln!(" {p}");
}
}
assert!(
unexpected.is_empty(),
"PC-1 violation: sync with BEADS_JSONL touched unexpected files outside .beads/ and the external target"
);
if sync.status.success() {
assert!(
external_jsonl.exists(),
"external JSONL should exist after a successful sync"
);
eprintln!(" [PASS] external JSONL created; no third-party files touched");
} else {
let combined = format!("{}{}", sync.stdout, sync.stderr);
assert!(
combined.contains("external")
|| combined.contains("outside")
|| combined.contains("allow"),
"sync rejection must mention external/outside/allow context; got:\n{combined}"
);
eprintln!(" [PASS] sync clearly rejected external path with operator-readable error");
}
}
fn collect_all_dotgit_paths(root: &Path) -> Vec<PathBuf> {
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let name = entry.file_name();
if name == ".git" {
out.push(entry.path());
continue; }
if name == "logs" {
continue;
}
let path = entry.path();
if path.is_dir() {
walk(&path, out);
}
}
}
let mut out = Vec::new();
walk(root, &mut out);
out
}