Skip to content
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,13 @@ Remove-Item -Recurse -Force (Join-Path $env:APPDATA "bt") -ErrorAction SilentlyC
| `bt sync` | Synchronize project logs between Braintrust and local NDJSON files |
| `bt self update` | Update bt in-place |

## `bt init`

- Creates local project config at `.bt/config.json`.
- Ensures `.bt/.gitignore` is present with managed rules:
- tracked by default: `.bt/config.json`, `.bt/skills/**`
- ignored by default: other `.bt/*` runtime artifacts (for example runner caches)

## `bt eval`

**File selection:**
Expand Down
196 changes: 196 additions & 0 deletions src/bt_dir.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
use std::path::{Path, PathBuf};

use anyhow::bail;
use anyhow::Result;

use crate::utils::write_text_atomic;

pub const BT_DIR: &str = ".bt";
pub const CONFIG_FILE: &str = "config.json";
pub const GITIGNORE_FILE: &str = ".gitignore";

const MANAGED_START: &str = "# BEGIN bt-managed";
const MANAGED_END: &str = "# END bt-managed";
const MANAGED_BODY: &str = "*\n!config.json\n!.gitignore\n!skills/\n!skills/**\n";

pub fn bt_dir(root: &Path) -> PathBuf {
root.join(BT_DIR)
}

pub fn config_path(root: &Path) -> PathBuf {
bt_dir(root).join(CONFIG_FILE)
}

pub fn gitignore_path(root: &Path) -> PathBuf {
bt_dir(root).join(GITIGNORE_FILE)
}

pub fn cache_dir(root: &Path) -> PathBuf {
bt_dir(root).join("cache")
}

pub fn runners_cache_dir(root: &Path) -> PathBuf {
cache_dir(root)
.join("runners")
.join(env!("CARGO_PKG_VERSION"))
}

pub fn state_dir(root: &Path) -> PathBuf {
bt_dir(root).join("state")
}

pub fn skills_dir(root: &Path) -> PathBuf {
bt_dir(root).join("skills")
}

pub fn skills_docs_dir(root: &Path) -> PathBuf {
skills_dir(root).join("docs")
}

pub fn ensure_repo_layout(root: &Path) -> Result<()> {
let dir = bt_dir(root);
ensure_not_symlink(&dir)?;
std::fs::create_dir_all(&dir)?;
ensure_not_symlink(&dir)?;
ensure_bt_gitignore(root)
}

pub fn ensure_bt_gitignore(root: &Path) -> Result<()> {
let dir = bt_dir(root);
ensure_not_symlink(&dir)?;
let path = gitignore_path(root);
ensure_not_symlink(&path)?;
let existing = match std::fs::read_to_string(&path) {
Ok(contents) => contents,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(err) => return Err(err.into()),
};
let updated = upsert_managed_block(&existing);
if updated != existing {
write_text_atomic(&path, &updated)?;
}
Ok(())
}

fn managed_block() -> String {
format!("{MANAGED_START}\n{MANAGED_BODY}{MANAGED_END}\n")
}

fn upsert_managed_block(existing: &str) -> String {
let managed = managed_block();
let user_tail = strip_managed_block(existing);

if user_tail.is_empty() {
managed
} else {
let mut out = String::with_capacity(managed.len() + user_tail.len() + 2);
out.push_str(&managed);
if !user_tail.starts_with('\n') {
out.push('\n');
}
out.push_str(&user_tail);
if !out.ends_with('\n') {
out.push('\n');
}
out
}
}

fn ensure_not_symlink(path: &Path) -> Result<()> {
match std::fs::symlink_metadata(path) {
Ok(metadata) => {
if metadata.file_type().is_symlink() {
bail!("refusing to use symlink path {}", path.display());
}
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(err.into()),
}
Ok(())
}

fn strip_managed_block(existing: &str) -> String {
let Some(start) = existing.find(MANAGED_START) else {
return existing.to_string();
};
let Some(end_marker) = existing[start..].find(MANAGED_END) else {
return existing.to_string();
};

let mut end = start + end_marker + MANAGED_END.len();
while end < existing.len() && existing.as_bytes()[end] == b'\n' {
end += 1;
}

let mut out = String::new();
out.push_str(&existing[..start]);
out.push_str(&existing[end..]);
out
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn upsert_adds_managed_block_for_empty_file() {
let updated = upsert_managed_block("");
assert!(updated.starts_with(MANAGED_START));
assert!(updated.contains("!config.json"));
assert!(updated.contains("!skills/**"));
}

#[test]
fn upsert_places_managed_block_first_and_preserves_custom_rules() {
let existing =
"custom-before\n\n# BEGIN bt-managed\nold\n# END bt-managed\n\ncustom-after\n";
let updated = upsert_managed_block(existing);
assert!(updated.starts_with("# BEGIN bt-managed\n"));
assert!(updated.contains("custom-before"));
assert!(updated.contains("custom-after"));
assert_eq!(updated.matches(MANAGED_START).count(), 1);
assert_eq!(updated.matches(MANAGED_END).count(), 1);
let end_pos = updated.find(MANAGED_END).unwrap();
let before_pos = updated.find("custom-before").unwrap();
let after_pos = updated.find("custom-after").unwrap();
assert!(before_pos > end_pos);
assert!(after_pos > end_pos);
}

#[test]
fn strip_managed_block_returns_input_when_markers_incomplete() {
let existing = "# BEGIN bt-managed\nno-end";
assert_eq!(strip_managed_block(existing), existing);
}

#[test]
fn upsert_is_idempotent_with_custom_rules() {
let existing =
"custom-before\n\n# BEGIN bt-managed\nold\n# END bt-managed\n\ncustom-after\n\n";
let once = upsert_managed_block(existing);
let twice = upsert_managed_block(&once);
assert_eq!(once, twice);
}

#[cfg(unix)]
#[test]
fn ensure_repo_layout_rejects_symlinked_bt_dir() {
use std::os::unix::fs::symlink;

let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos();
let root = std::env::temp_dir().join(format!("bt-layout-symlink-{unique}"));
let target = std::env::temp_dir().join(format!("bt-layout-symlink-target-{unique}"));
std::fs::create_dir_all(&root).expect("create root");
std::fs::create_dir_all(&target).expect("create target");
symlink(&target, root.join(BT_DIR)).expect("create symlinked .bt");

let err = ensure_repo_layout(&root).expect_err("must reject symlinked .bt");
assert!(err.to_string().contains("symlink"));

let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::remove_dir_all(&target);
}
}
49 changes: 39 additions & 10 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,12 +169,18 @@ pub fn save_global(config: &Config) -> Result<()> {
}

pub fn find_local_config_dir() -> Option<PathBuf> {
let current_dir = std::env::current_dir().ok()?;
find_local_config_dir_from(&current_dir)
}

fn find_local_config_dir_from(start: &Path) -> Option<PathBuf> {
let home = dirs::home_dir();
let mut current_dir = std::env::current_dir().ok()?;
let mut current_dir = start.to_path_buf();

loop {
if current_dir.join(".bt").is_dir() {
return Some(current_dir.join(".bt"));
let bt_dir = current_dir.join(crate::bt_dir::BT_DIR);
if bt_dir.join(crate::bt_dir::CONFIG_FILE).is_file() {
return Some(bt_dir);
}
if current_dir.join(".git").exists() {
return None;
Expand All @@ -189,7 +195,7 @@ pub fn find_local_config_dir() -> Option<PathBuf> {
}

pub fn local_path() -> Option<PathBuf> {
find_local_config_dir().map(|dir| dir.join("config.json"))
find_local_config_dir().map(|dir| dir.join(crate::bt_dir::CONFIG_FILE))
}

pub enum WriteTarget {
Expand Down Expand Up @@ -222,12 +228,10 @@ pub fn resolve_write_path(global: bool, local: bool) -> Result<PathBuf> {
}
}

pub fn save_local(config: &Config, create_dir: bool) -> Result<()> {
let dir = std::env::current_dir()?.join(".bt");
if create_dir && !dir.exists() {
fs::create_dir_all(&dir)?;
}
save_file(&dir.join("config.json"), config)
pub fn save_local(config: &Config) -> Result<()> {
let cwd = std::env::current_dir()?;
crate::bt_dir::ensure_repo_layout(&cwd)?;
save_file(&crate::bt_dir::config_path(&cwd), config)
}

// --- CLI commands ---
Expand Down Expand Up @@ -460,4 +464,29 @@ mod tests {
save_file(&path, &config).unwrap();
assert!(path.exists());
}

#[test]
fn find_local_config_dir_requires_config_file() {
let tmp = TempDir::new().unwrap();
let repo_root = tmp.path();
std::fs::create_dir_all(repo_root.join(".git")).unwrap();
std::fs::create_dir_all(repo_root.join(".bt").join("cache")).unwrap();
std::fs::create_dir_all(repo_root.join("nested")).unwrap();

let discovered = find_local_config_dir_from(&repo_root.join("nested"));
assert_eq!(discovered, None);
}

#[test]
fn find_local_config_dir_detects_config_file_in_parent_repo() {
let tmp = TempDir::new().unwrap();
let repo_root = tmp.path();
std::fs::create_dir_all(repo_root.join(".git")).unwrap();
std::fs::create_dir_all(repo_root.join(".bt")).unwrap();
std::fs::write(repo_root.join(".bt").join("config.json"), "{}\n").unwrap();
std::fs::create_dir_all(repo_root.join("nested").join("pkg")).unwrap();

let discovered = find_local_config_dir_from(&repo_root.join("nested").join("pkg"));
assert_eq!(discovered, Some(repo_root.join(".bt")));
}
}
Loading
Loading