use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::collect::run::{Env, FailureKind, Runner};
use crate::config::{Config, Project, Scope};
pub fn from_the_current_directory(
runner: &dyn Runner,
cwd: &Path,
name_from_the_environment: Option<&str>,
) -> anyhow::Result<Config> {
let tracker = match runner.run("bd", &["where", "--json"], Some(cwd), &Env::new()) {
Ok(said) => said,
Err(failure) => {
if matches!(
failure.kind,
FailureKind::NotInstalled
| FailureKind::Unstartable
| FailureKind::InstalledUnstartable
) {
return Err(failure.into());
}
anyhow::bail!("{} is not in anything beads tracks", cwd.display());
}
};
let toplevel = git(runner, cwd, &["rev-parse", "--show-toplevel"]);
let git_never_ran = matches!(toplevel, Git::NeverRan);
let repository = toplevel.line().map(PathBuf::from);
let worktrees = match &repository {
Some(_) => worktrees_of(runner, cwd),
None => Vec::new(),
};
let root = repository
.or_else(|| the_tracked_tree_around(&tracker, cwd))
.unwrap_or_else(|| cwd.to_path_buf());
let named_in_the_environment = name_from_the_environment.filter(|name| !name.is_empty());
let name = named_in_the_environment
.map(str::to_string)
.or_else(|| {
git(runner, cwd, &["remote", "get-url", "origin"])
.line()
.map(|url| repository_name(&url))
})
.unwrap_or_else(|| directory_name(&root));
Ok(Config {
named_without_git: git_never_ran && named_in_the_environment.is_none(),
..Config::naming(vec![Project {
name,
path: root,
environment_command: None,
credential_command: None,
poll: true,
worktrees,
}])
})
}
pub fn scoped_to_the_directory(config: Config, runner: &dyn Runner, cwd: &Path) -> Config {
let config = config.scoped_to_the_project_holding(cwd);
if matches!(config.scope, Scope::Directory { .. }) {
return config;
}
let counterparts = the_same_place_in_each(&worktrees_of(runner, cwd), cwd);
config.scoped_to_the_project_holding_any_of(&counterparts)
}
pub fn with_the_working_trees_git_lists(mut config: Config, runner: &dyn Runner) -> Config {
let scope = config.scope.clone();
for project in config.projects.iter_mut().filter(|p| scope.reads(&p.name)) {
let listed = worktrees_of(runner, &project.path);
project.worktrees = the_same_place_in_each(&listed, &project.path);
}
config
}
fn the_same_place_in_each(working_trees: &[PathBuf], path: &Path) -> Vec<PathBuf> {
let Some(within) = working_trees
.iter()
.filter_map(|tree| path.strip_prefix(tree).ok())
.min_by_key(|within| within.components().count())
else {
return Vec::new();
};
working_trees.iter().map(|tree| tree.join(within)).collect()
}
fn worktrees_of(runner: &dyn Runner, cwd: &Path) -> Vec<PathBuf> {
let Some(listed) = git(runner, cwd, &["worktree", "list", "--porcelain"]).line() else {
return Vec::new();
};
listed
.lines()
.filter_map(|line| line.strip_prefix("worktree "))
.map(PathBuf::from)
.collect()
}
#[derive(Deserialize)]
struct Workspace {
path: PathBuf,
}
const WORKSPACE: &str = ".beads";
fn the_tracked_tree_around(said: &str, cwd: &Path) -> Option<PathBuf> {
let workspace: Workspace = serde_json::from_str(said).ok()?;
if workspace.path.file_name() != Some(OsStr::new(WORKSPACE)) {
return None;
}
let tree = workspace.path.parent()?;
cwd.starts_with(tree).then(|| tree.to_path_buf())
}
enum Git {
Said(String),
SaidNothing,
NeverRan,
}
impl Git {
fn line(self) -> Option<String> {
match self {
Git::Said(line) => Some(line),
Git::SaidNothing | Git::NeverRan => None,
}
}
}
fn git(runner: &dyn Runner, cwd: &Path, args: &[&str]) -> Git {
let said = match runner.run("git", args, Some(cwd), &Env::new()) {
Ok(said) => said,
Err(failure)
if matches!(
failure.kind,
FailureKind::NotInstalled
| FailureKind::Unstartable
| FailureKind::InstalledUnstartable
) =>
{
return Git::NeverRan
}
Err(_) => return Git::SaidNothing,
};
match said.trim() {
"" => Git::SaidNothing,
line => Git::Said(line.to_string()),
}
}
fn repository_name(url: &str) -> String {
let named = url.trim_end_matches('/');
let named = named.rsplit(['/', ':']).next().unwrap_or(named);
named.trim_end_matches(".git").to_string()
}
fn directory_name(path: &Path) -> String {
path.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::collect::run::testing::FakeRunner;
use crate::collect::run::{RealRunner, RunFailure};
use crate::collect::worktree::testing::{a_scratch_directory, git_in};
use crate::config::{Roots, Tui};
fn a_tracked_repository() -> FakeRunner {
FakeRunner::default()
.with("bd where --json", r#"{"path":"/srv/work/orbital/.beads"}"#)
.with("git rev-parse --show-toplevel", "/srv/work/orbital\n")
.with(
"git remote get-url origin",
"git@github.com:pilot/ground-station.git\n",
)
.with("git worktree list --porcelain", ONE_CHECKOUT)
}
const ONE_CHECKOUT: &str = "\
worktree /srv/work/orbital
HEAD 4d3c1f0e9b8a7c6d5e4f3a2b1c0d9e8f7a6b5c4d
branch refs/heads/main
";
const A_WORKTREE_PER_SEAT: &str = "\
worktree /srv/work/orbital
HEAD 4d3c1f0e9b8a7c6d5e4f3a2b1c0d9e8f7a6b5c4d
branch refs/heads/main
worktree /tmp/seat-a/wt
HEAD 1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b
detached
worktree /tmp/seat-b/wt
HEAD 9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c
detached
";
fn no_such_repository() -> RunFailure {
RunFailure {
kind: FailureKind::Unavailable,
program: "git".to_string(),
detail: "git exited 128 for a reason bdi cannot place".to_string(),
unreadable: None,
}
}
#[test]
fn the_repository_the_directory_sits_in_becomes_the_one_project() {
let cfg = from_the_current_directory(
&a_tracked_repository(),
Path::new("/srv/work/orbital/src"),
None,
)
.expect("the repository is a project");
assert_eq!(
cfg.projects,
vec![Project {
name: "ground-station".to_string(),
path: PathBuf::from("/srv/work/orbital"),
environment_command: None,
credential_command: None,
poll: true,
worktrees: vec![PathBuf::from("/srv/work/orbital")],
}]
);
}
#[test]
fn every_worktree_of_the_repository_belongs_to_the_project() {
let runner =
a_tracked_repository().with("git worktree list --porcelain", A_WORKTREE_PER_SEAT);
let cfg = from_the_current_directory(&runner, Path::new("/tmp/seat-a/wt"), None)
.expect("the repository is a project");
assert_eq!(
cfg.projects[0].worktrees,
vec![
PathBuf::from("/srv/work/orbital"),
PathBuf::from("/tmp/seat-a/wt"),
PathBuf::from("/tmp/seat-b/wt"),
]
);
}
#[test]
fn a_repository_git_lists_no_worktrees_for_still_holds_its_own_path() {
let runner = FakeRunner::default()
.with("bd where --json", r#"{"path":"/srv/work/orbital/.beads"}"#)
.with("git rev-parse --show-toplevel", "/srv/work/orbital\n")
.with(
"git remote get-url origin",
"git@github.com:pilot/ground-station.git\n",
)
.failing("git worktree list --porcelain", no_such_repository());
let cfg = from_the_current_directory(&runner, Path::new("/srv/work/orbital"), None)
.expect("the repository is a project");
assert!(cfg.projects[0].worktrees.is_empty());
assert!(
cfg.projects[0]
.holds(Path::new("/srv/work/orbital/src"))
.is_some(),
"a project git listed no worktrees for holds nothing at all"
);
}
#[test]
fn only_the_worktree_lines_of_the_listing_are_directories() {
let runner =
a_tracked_repository().with("git worktree list --porcelain", A_WORKTREE_PER_SEAT);
let cfg = from_the_current_directory(&runner, Path::new("/tmp/seat-a/wt"), None)
.expect("the repository is a project");
assert!(
cfg.projects[0]
.worktrees
.iter()
.all(|w| w.starts_with("/srv") || w.starts_with("/tmp")),
"a HEAD or a branch was read as a directory: {:?}",
cfg.projects[0].worktrees
);
}
const ONE_CONFIGURED_PROJECT: &str = r#"
[[projects]]
name = "orbital"
path = "/srv/work/orbital"
"#;
const A_PROJECT_IN_A_SUBDIRECTORY: &str = r#"
[[projects]]
name = "dish"
path = "/srv/work/orbital/crates/dish"
"#;
const TWO_CONFIGURED_PROJECTS: &str = r#"
[[projects]]
name = "orbital"
path = "/srv/work/orbital"
credential_command = "secret-tool lookup tracker orbital"
[[projects]]
name = "harbour"
path = "/srv/work/harbour"
credential_command = "secret-tool lookup tracker harbour"
"#;
#[test]
fn a_linked_worktree_of_a_project_inside_the_repository_is_that_project() {
let runner =
FakeRunner::default().with("git worktree list --porcelain", A_WORKTREE_PER_SEAT);
let cfg = scoped_to_the_directory(
Config::from_toml(A_PROJECT_IN_A_SUBDIRECTORY).expect("the config parses"),
&runner,
Path::new("/tmp/seat-a/wt/crates/dish/src"),
);
assert_eq!(
cfg.scope,
Scope::Directory {
project: "dish".to_string(),
widened: Vec::new(),
}
);
assert_eq!(
runner.call("git worktree list --porcelain").cwd,
Some(PathBuf::from("/tmp/seat-a/wt/crates/dish/src")),
"git is asked once, from the directory bdi was started in"
);
}
#[test]
fn a_directory_a_project_holds_is_read_without_asking_git() {
let runner = FakeRunner::default();
let cfg = scoped_to_the_directory(
Config::from_toml(TWO_CONFIGURED_PROJECTS).expect("the config parses"),
&runner,
Path::new("/srv/work/harbour/src"),
);
assert_eq!(
cfg.scope,
Scope::Directory {
project: "harbour".to_string(),
widened: Vec::new(),
}
);
assert!(runner.calls().is_empty());
}
#[test]
fn a_project_configured_in_one_linked_worktree_holds_the_same_place_in_another() {
let runner =
FakeRunner::default().with("git worktree list --porcelain", A_WORKTREE_PER_SEAT);
let cfg = scoped_to_the_directory(
Config::from_toml(
r#"
[[projects]]
name = "dish"
path = "/tmp/seat-b/wt/crates/dish"
"#,
)
.expect("the config parses"),
&runner,
Path::new("/tmp/seat-a/wt/crates/dish/src"),
);
assert_eq!(
cfg.scope,
Scope::Directory {
project: "dish".to_string(),
widened: Vec::new(),
}
);
}
#[test]
fn a_working_tree_of_no_configured_project_leaves_every_project_read() {
let runner =
FakeRunner::default().with("git worktree list --porcelain", A_WORKTREE_PER_SEAT);
let cfg = scoped_to_the_directory(
Config::from_toml(TWO_CONFIGURED_PROJECTS).expect("the config parses"),
&runner,
Path::new("/home/elsewhere/notes"),
);
assert_eq!(cfg.scope, Scope::Everything);
}
#[test]
fn a_configured_project_holds_every_working_tree_git_lists() {
let runner =
FakeRunner::default().with("git worktree list --porcelain", A_WORKTREE_PER_SEAT);
let cfg = with_the_working_trees_git_lists(
Config::from_toml(ONE_CONFIGURED_PROJECT).expect("the config parses"),
&runner,
);
assert_eq!(
cfg.projects[0].worktrees,
vec![
PathBuf::from("/srv/work/orbital"),
PathBuf::from("/tmp/seat-a/wt"),
PathBuf::from("/tmp/seat-b/wt"),
]
);
assert!(
cfg.projects[0]
.holds(Path::new("/tmp/seat-a/wt/src"))
.is_some(),
"a seat in a linked worktree is working in the project"
);
}
#[test]
fn each_configured_project_is_asked_from_its_own_path() {
let runner = FakeRunner::default().with("git worktree list --porcelain", ONE_CHECKOUT);
with_the_working_trees_git_lists(
Config::from_toml(TWO_CONFIGURED_PROJECTS).expect("the config parses"),
&runner,
);
let asked: Vec<Option<PathBuf>> = runner
.calls()
.into_iter()
.filter(|call| call.argv == "git worktree list --porcelain")
.map(|call| call.cwd)
.collect();
assert_eq!(
asked,
vec![
Some(PathBuf::from("/srv/work/orbital")),
Some(PathBuf::from("/srv/work/harbour")),
]
);
}
#[test]
fn a_project_inside_the_repository_is_that_place_in_each_working_tree() {
let runner =
FakeRunner::default().with("git worktree list --porcelain", A_WORKTREE_PER_SEAT);
let cfg = with_the_working_trees_git_lists(
Config::from_toml(A_PROJECT_IN_A_SUBDIRECTORY).expect("the config parses"),
&runner,
);
assert_eq!(
cfg.projects[0].worktrees,
vec![
PathBuf::from("/srv/work/orbital/crates/dish"),
PathBuf::from("/tmp/seat-a/wt/crates/dish"),
PathBuf::from("/tmp/seat-b/wt/crates/dish"),
]
);
assert!(
cfg.projects[0]
.holds(Path::new("/srv/work/orbital/docs"))
.is_none(),
"a project configured as one directory annexed the repository around it"
);
}
#[test]
fn a_configured_path_that_is_no_repository_still_holds_itself() {
let runner =
FakeRunner::default().failing("git worktree list --porcelain", no_such_repository());
let cfg = with_the_working_trees_git_lists(
Config::from_toml(ONE_CONFIGURED_PROJECT).expect("the config parses"),
&runner,
);
assert!(cfg.projects[0].worktrees.is_empty());
assert!(
cfg.projects[0]
.holds(Path::new("/srv/work/orbital/src"))
.is_some(),
"a project in no repository holds nothing at all"
);
}
#[test]
fn a_worktree_lists_the_checkout_it_was_added_from_and_itself() {
let scratch = a_scratch_directory("worktree-listing");
let checkout = scratch.join("checkout");
std::fs::create_dir_all(&checkout).expect("the directory is ours to make");
git_in(&checkout, &["init"]);
git_in(&checkout, &["commit", "--allow-empty", "-m", "root"]);
let linked = scratch.join("seat/wt");
git_in(
&checkout,
&["worktree", "add", "--detach", &linked.display().to_string()],
);
assert_eq!(
worktrees_of(&RealRunner, &linked),
vec![checkout, linked],
"git did not list both working trees the way the parse expects"
);
std::fs::remove_dir_all(&scratch).expect("the directory is ours to remove");
}
fn a_repository_with_a_subdirectory_and_a_linked_worktree(
named: &str,
) -> (PathBuf, PathBuf, PathBuf) {
let scratch = a_scratch_directory(named);
let checkout = scratch.join("checkout");
std::fs::create_dir_all(&checkout).expect("the directory is ours to make");
git_in(&checkout, &["init"]);
git_in(&checkout, &["commit", "--allow-empty", "-m", "root"]);
let root = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(&checkout)
.output()
.expect("git runs");
let root = String::from_utf8_lossy(&root.stdout).trim().to_string();
let inside = checkout.join("crates/dish");
std::fs::create_dir_all(&inside).expect("the directory is ours to make");
std::fs::write(inside.join("Cargo.toml"), "").expect("the file is ours to write");
git_in(&checkout, &["add", "."]);
git_in(&checkout, &["commit", "-m", "a crate"]);
let linked = scratch.join("seat/wt");
git_in(
&checkout,
&[
"worktree",
"add",
"--detach",
&linked.display().to_string(),
&root,
],
);
(scratch, checkout, linked)
}
#[test]
fn a_configured_subdirectory_is_re_rooted_onto_gits_own_listing() {
let (scratch, checkout, linked) =
a_repository_with_a_subdirectory_and_a_linked_worktree("configured-subdirectory");
let cfg = with_the_working_trees_git_lists(
Config::from_toml(&format!(
"[[projects]]\nname = \"dish\"\npath = \"{}\"\n",
checkout.join("crates/dish").display()
))
.expect("the config parses"),
&RealRunner,
);
assert_eq!(
cfg.projects[0].worktrees,
vec![checkout.join("crates/dish"), linked.join("crates/dish")]
);
std::fs::remove_dir_all(&scratch).expect("the directory is ours to remove");
}
#[test]
fn a_place_that_is_not_there_on_this_branch_is_still_the_projects() {
let (scratch, checkout, linked) =
a_repository_with_a_subdirectory_and_a_linked_worktree("place-not-there");
assert!(
!linked.join("crates/dish").exists(),
"the worktree was cut from before the directory, so it should not be there"
);
let cfg = with_the_working_trees_git_lists(
Config::from_toml(&format!(
"[[projects]]\nname = \"dish\"\npath = \"{}\"\n",
checkout.join("crates/dish").display()
))
.expect("the config parses"),
&RealRunner,
);
assert!(
cfg.projects[0]
.holds(&linked.join("crates/dish/src"))
.is_some(),
"a seat working there was dropped because the directory is not checked out"
);
std::fs::remove_dir_all(&scratch).expect("the directory is ours to remove");
}
#[test]
fn a_path_in_none_of_the_working_trees_git_listed_holds_only_itself() {
let scratch = a_scratch_directory("symlinked-path");
let checkout = scratch.join("checkout");
std::fs::create_dir_all(&checkout).expect("the directory is ours to make");
git_in(&checkout, &["init"]);
git_in(&checkout, &["commit", "--allow-empty", "-m", "root"]);
let spelled = scratch.join("orbital");
std::os::unix::fs::symlink(&checkout, &spelled).expect("the link is ours to make");
let cfg = with_the_working_trees_git_lists(
Config::from_toml(&format!(
"[[projects]]\nname = \"orbital\"\npath = \"{}\"\n",
spelled.display()
))
.expect("the config parses"),
&RealRunner,
);
assert!(
cfg.projects[0].worktrees.is_empty(),
"a listing holding none of the project's paths was taken for it: {:?}",
cfg.projects[0].worktrees
);
assert!(
cfg.projects[0].holds(&spelled.join("src")).is_some(),
"the project stopped holding the path it was configured with"
);
std::fs::remove_dir_all(&scratch).expect("the directory is ours to remove");
}
#[test]
fn the_worktrees_are_listed_from_where_bdi_was_run() {
let runner = a_tracked_repository();
from_the_current_directory(&runner, Path::new("/srv/work/orbital/src"), None)
.expect("the repository is a project");
assert_eq!(
runner.call("git worktree list --porcelain").cwd,
Some(PathBuf::from("/srv/work/orbital/src"))
);
}
#[test]
fn a_synthesised_project_gets_every_other_default() {
let cfg = from_the_current_directory(
&a_tracked_repository(),
Path::new("/srv/work/orbital"),
None,
)
.expect("the repository is a project");
assert_eq!(cfg.roots, Roots::default());
assert!(cfg.badges.is_empty());
assert_eq!(cfg.anomalies.stale_claim_days, 30);
assert_eq!(cfg.join.pane_key, "agent_pane");
assert_eq!(cfg.tui, Tui::default());
}
#[test]
fn the_environment_names_the_project_ahead_of_git() {
let cfg = from_the_current_directory(
&a_tracked_repository(),
Path::new("/srv/work/orbital"),
Some("atlas"),
)
.expect("the repository is a project");
assert_eq!(cfg.projects[0].name, "atlas");
}
#[test]
fn an_empty_name_in_the_environment_is_no_name_at_all() {
let cfg = from_the_current_directory(
&a_tracked_repository(),
Path::new("/srv/work/orbital"),
Some(""),
)
.expect("the repository is a project");
assert_eq!(cfg.projects[0].name, "ground-station");
}
#[test]
fn a_repository_with_no_remote_is_named_by_its_directory() {
let runner = FakeRunner::default()
.with("bd where --json", r#"{"path":"/srv/work/orbital/.beads"}"#)
.with("git rev-parse --show-toplevel", "/srv/work/orbital\n")
.with("git worktree list --porcelain", ONE_CHECKOUT)
.failing("git remote get-url origin", no_such_repository());
let cfg = from_the_current_directory(&runner, Path::new("/srv/work/orbital"), None)
.expect("the repository is a project");
assert_eq!(cfg.projects[0].name, "orbital");
}
#[test]
fn every_spelling_of_a_remote_names_the_same_project() {
for url in [
"git@github.com:pilot/ground-station.git",
"https://github.com/pilot/ground-station.git",
"https://github.com/pilot/ground-station",
"ssh://git@host/~pilot/ground-station.git/",
"/srv/git/ground-station.git",
] {
let runner = FakeRunner::default()
.with("bd where --json", r#"{"path":"/srv/work/orbital/.beads"}"#)
.with("git rev-parse --show-toplevel", "/srv/work/orbital\n")
.with("git worktree list --porcelain", ONE_CHECKOUT)
.with("git remote get-url origin", &format!("{url}\n"));
let cfg = from_the_current_directory(&runner, Path::new("/srv/work/orbital"), None)
.expect("the repository is a project");
assert_eq!(cfg.projects[0].name, "ground-station", "from {url}");
}
}
fn no_git() -> RunFailure {
RunFailure::not_installed("git", "No such file or directory (os error 2)")
}
const THE_TRACKER_AT_ORBITAL: &str = r#"{
"database_path": "/srv/work/orbital/.beads/dolt",
"path": "/srv/work/orbital/.beads",
"schema_version": 1
}"#;
fn a_tracked_tree_with_no_git() -> FakeRunner {
FakeRunner::default()
.with("bd where --json", THE_TRACKER_AT_ORBITAL)
.failing("git rev-parse --show-toplevel", no_git())
.failing("git remote get-url origin", no_git())
}
#[test]
fn a_project_no_git_can_root_is_rooted_at_the_tracker_above_the_reader() {
let cfg = from_the_current_directory(
&a_tracked_tree_with_no_git(),
Path::new("/srv/work/orbital/src"),
None,
)
.expect("the tree bd tracks is a project");
assert_eq!(cfg.projects[0].path, PathBuf::from("/srv/work/orbital"));
}
#[test]
fn a_project_rooted_at_its_tracker_holds_the_seats_working_elsewhere_in_it() {
let cfg = from_the_current_directory(
&a_tracked_tree_with_no_git(),
Path::new("/srv/work/orbital/src"),
None,
)
.expect("the tree bd tracks is a project");
assert!(
cfg.projects[0]
.holds(Path::new("/srv/work/orbital/docs"))
.is_some(),
"a seat working elsewhere in the tracked tree is in no project"
);
}
#[test]
fn a_project_no_git_can_name_is_named_after_the_tracker_not_the_reader() {
for standing_in in [
"/srv/work/orbital",
"/srv/work/orbital/src",
"/srv/work/orbital/crates/dish",
] {
let cfg = from_the_current_directory(
&a_tracked_tree_with_no_git(),
Path::new(standing_in),
None,
)
.expect("the tree bd tracks is a project");
assert_eq!(cfg.projects[0].name, "orbital", "standing in {standing_in}");
}
}
#[test]
fn a_machine_with_no_git_says_the_name_it_gave_the_project_is_a_guess() {
let discovered = from_the_current_directory(
&a_tracked_tree_with_no_git(),
Path::new("/srv/work/orbital/src"),
None,
)
.expect("the tree bd tracks is a project");
assert!(discovered.named_without_git);
}
#[test]
fn a_repository_with_no_origin_is_named_after_its_directory_and_says_nothing() {
let runner = FakeRunner::default()
.with("bd where --json", THE_TRACKER_AT_ORBITAL)
.with("git rev-parse --show-toplevel", "/srv/work/orbital\n")
.with("git worktree list --porcelain", ONE_CHECKOUT)
.failing("git remote get-url origin", no_such_repository());
let discovered = from_the_current_directory(&runner, Path::new("/srv/work/orbital"), None)
.expect("the repository is a project");
assert_eq!(discovered.projects[0].name, "orbital");
assert!(!discovered.named_without_git);
}
#[test]
fn a_directory_in_no_repository_is_named_after_itself_and_says_nothing() {
let runner = FakeRunner::default()
.with("bd where --json", r#"{"path":"/srv/beads/.beads"}"#)
.failing("git rev-parse --show-toplevel", no_such_repository())
.failing("git remote get-url origin", no_such_repository());
let discovered = from_the_current_directory(&runner, Path::new("/srv/loose"), None)
.expect("the directory is a project");
assert_eq!(discovered.projects[0].name, "loose");
assert!(!discovered.named_without_git);
}
#[test]
fn a_project_the_environment_names_is_no_guess_on_a_machine_with_no_git() {
let discovered = from_the_current_directory(
&a_tracked_tree_with_no_git(),
Path::new("/srv/work/orbital/src"),
Some("atlas"),
)
.expect("the tree bd tracks is a project");
assert_eq!(discovered.projects[0].name, "atlas");
assert!(!discovered.named_without_git);
}
#[test]
fn an_empty_name_in_the_environment_leaves_the_name_a_guess() {
let discovered = from_the_current_directory(
&a_tracked_tree_with_no_git(),
Path::new("/srv/work/orbital/src"),
Some(""),
)
.expect("the tree bd tracks is a project");
assert_eq!(discovered.projects[0].name, "orbital");
assert!(discovered.named_without_git);
}
#[test]
fn a_tracker_the_reader_redirected_upwards_roots_them_at_the_tree_it_belongs_to() {
let runner = FakeRunner::default()
.with("bd where --json", r#"{"path":"/srv/project/.beads"}"#)
.failing("git rev-parse --show-toplevel", no_git())
.failing("git remote get-url origin", no_git());
let cfg = from_the_current_directory(&runner, Path::new("/srv/project/sub"), None)
.expect("the tree bd tracks is a project");
assert_eq!(cfg.projects[0].path, PathBuf::from("/srv/project"));
assert_eq!(cfg.projects[0].name, "project");
assert!(
cfg.projects[0]
.holds(Path::new("/srv/project/other"))
.is_some(),
"a seat working on the same tracker elsewhere in the tree is in no project"
);
}
#[test]
fn a_tracker_beside_the_reader_rather_than_above_them_is_no_root() {
let runner = FakeRunner::default()
.with("bd where --json", r#"{"path":"/srv/shared-beads"}"#)
.failing("git rev-parse --show-toplevel", no_git())
.failing("git remote get-url origin", no_git());
let cfg = from_the_current_directory(&runner, Path::new("/srv/project"), None)
.expect("the directory is a project");
assert_eq!(cfg.projects[0].path, PathBuf::from("/srv/project"));
assert_eq!(cfg.projects[0].name, "project");
}
#[test]
fn a_tracker_far_above_the_reader_is_still_the_root() {
let runner = FakeRunner::default()
.with("bd where --json", r#"{"path":"/home/pilot/.beads"}"#)
.failing("git rev-parse --show-toplevel", no_git())
.failing("git remote get-url origin", no_git());
let cfg = from_the_current_directory(&runner, Path::new("/home/pilot/work/orbital"), None)
.expect("the tree bd tracks is a project");
assert_eq!(cfg.projects[0].path, PathBuf::from("/home/pilot"));
}
#[test]
fn a_repository_git_names_is_the_root_whatever_the_tracker_sits_beside() {
let runner = a_tracked_repository().with(
"bd where --json",
r#"{"path":"/srv/work/orbital/crates/dish/.beads"}"#,
);
let cfg = from_the_current_directory(&runner, Path::new("/srv/work/orbital/src"), None)
.expect("the repository is a project");
assert_eq!(cfg.projects[0].path, PathBuf::from("/srv/work/orbital"));
assert_eq!(cfg.projects[0].name, "ground-station");
}
#[test]
fn a_tracker_outside_any_repository_is_read_from_where_bdi_was_run() {
let runner = FakeRunner::default()
.with("bd where --json", r#"{"path":"/srv/beads/.beads"}"#)
.failing("git rev-parse --show-toplevel", no_such_repository())
.failing("git remote get-url origin", no_such_repository());
let cfg = from_the_current_directory(&runner, Path::new("/srv/loose"), None)
.expect("the directory is a project");
assert_eq!(
cfg.projects,
vec![Project {
name: "loose".to_string(),
path: PathBuf::from("/srv/loose"),
environment_command: None,
credential_command: None,
poll: true,
worktrees: Vec::new(),
}]
);
}
#[test]
fn a_directory_beads_does_not_track_is_reported() {
let runner = FakeRunner::default().failing(
"bd where --json",
RunFailure {
kind: FailureKind::Unavailable,
program: "bd".to_string(),
detail: "bd exited 1 for a reason bdi cannot place".to_string(),
unreadable: None,
},
);
let err = from_the_current_directory(&runner, Path::new("/srv/loose"), None)
.unwrap_err()
.to_string();
assert!(err.contains("/srv/loose"), "got: {err}");
assert!(err.contains("beads"), "got: {err}");
}
#[test]
fn a_bd_that_cannot_run_says_so_rather_than_blaming_the_directory() {
let runner = FakeRunner::default().failing(
"bd where --json",
RunFailure::not_installed("bd", "No such file or directory (os error 2)"),
);
let err = from_the_current_directory(&runner, Path::new("/srv/loose"), None)
.unwrap_err()
.to_string();
assert!(err.contains("bd is not installed"), "got: {err}");
assert!(!err.contains("/srv/loose"), "got: {err}");
}
#[test]
fn the_tracker_is_probed_where_bdi_was_run() {
let runner = a_tracked_repository();
from_the_current_directory(&runner, Path::new("/srv/work/orbital/src"), None)
.expect("the repository is a project");
assert_eq!(
runner.call("bd where --json").cwd,
Some(PathBuf::from("/srv/work/orbital/src"))
);
}
}