use crate::prelude::*;
use std::path::PathBuf;
pub fn find_project_root(start_dir: &Path) -> Option<PathBuf> {
let mut dir = start_dir;
loop {
if dir.join(".wsb").join("state.json").exists() {
return Some(dir.to_path_buf());
}
dir = dir.parent()?;
}
}
pub fn find_super_root(start_dir: &Path) -> Option<PathBuf> {
let repo = gix::discover(start_dir).ok()?;
let workdir = repo.workdir()?.to_path_buf();
let mut current = workdir;
while let Some(parent) = current.parent() {
if parent == current {
break;
}
if parent.join(".git").exists() {
if let Ok(parent_repo) = gix::discover(parent) {
if let Some(parent_workdir) = parent_repo.workdir() {
let parent_workdir = parent_workdir.to_path_buf();
if current.starts_with(&parent_workdir) {
current = parent_workdir;
continue;
}
}
}
}
break;
}
Some(current)
}
pub fn find_workspace_root(start_dir: &Path) -> Option<PathBuf> {
find_super_root(start_dir).filter(|root| {
root.join(".wsb").join("state.json").is_file()
})
}
pub fn find_super_root_or_project(start_dir: &Path) -> PathBuf {
find_super_root(start_dir)
.or_else(|| find_project_root(start_dir))
.unwrap_or_else(|| start_dir.to_path_buf())
}
pub fn require_project_root(start_dir: &Path) -> Result<PathBuf> {
find_project_root(start_dir).with_context(|| {
format!(
"Not in a wsb workspace (no .wsb/state.json found walking up from {}). \
Run 'wsb init' to initialize one.",
start_dir.display()
)
})
}
pub const DECLARATIONS: [&str; 3] = [
crate::state::stamp_config::FILE,
crate::catalogue::families::FILE,
crate::peer::POLICY_FILE,
];
fn exclusion_lines() -> Vec<String> {
std::iter::once(".wsb/*".to_string())
.chain(DECLARATIONS.iter().map(|file| format!("!{file}")))
.collect()
}
pub fn excludes_state(content: &str) -> bool {
exclusion_lines().iter().all(|ours| content.lines().any(|line| line.trim() == ours))
&& !content.lines().any(hides_the_directory)
}
fn hides_the_directory(line: &str) -> bool {
matches!(line.trim(), ".wsb" | ".wsb/" | "/.wsb" | "/.wsb/")
}
pub fn exclude_state_dir(root: &Path) -> Result<()> {
let exclude = exclude_file(root)?;
if let Some(parent) = exclude.parent() {
std::fs::create_dir_all(parent)?;
}
let existing = match std::fs::read_to_string(&exclude) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(error) => {
return Err(error).with_context(|| format!("cannot read {}", exclude.display()))
}
};
let wanted = exclusion_lines();
let mut lines: Vec<&str> = existing
.lines()
.filter(|line| !hides_the_directory(line) && !wanted.iter().any(|ours| ours == line.trim()))
.collect();
if excludes_state(&existing) {
return Ok(());
}
lines.extend(wanted.iter().map(String::as_str));
let mut content = lines.join("\n");
content.push('\n');
std::fs::write(&exclude, content)
.with_context(|| format!("cannot write {}", exclude.display()))?;
Ok(())
}
fn exclude_file(root: &Path) -> Result<PathBuf> {
let output = crate::git_backend::git_command()
.arg("-C")
.arg(root)
.args(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"])
.output()
.with_context(|| format!("cannot run git in {}", root.display()))?;
if !output.status.success() {
return Err(anyhow::anyhow!(
"{} is not a git repository, so there is nowhere to record the exclusion: {}",
root.display(),
String::from_utf8_lossy(&output.stderr).trim()
));
}
Ok(PathBuf::from(String::from_utf8_lossy(&output.stdout).trim()))
}
#[cfg(test)]
mod exclude_tests {
use super::*;
fn repository() -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("a directory");
let status = crate::git_backend::git_command()
.args(["init", "--quiet"])
.current_dir(dir.path())
.status()
.expect("git runs");
assert!(status.success());
dir
}
fn untracked(root: &Path) -> Vec<String> {
let out = crate::git_backend::git_command()
.args(["status", "--porcelain", "--untracked-files=all"])
.current_dir(root)
.output()
.expect("git runs");
String::from_utf8_lossy(&out.stdout)
.lines()
.map(|line| line[3..].to_string())
.collect()
}
#[test]
fn test20014_state_is_ignored_and_declarations_are_not() {
let dir = repository();
let root = dir.path();
let exclude = root.join(".git").join("info").join("exclude");
std::fs::create_dir_all(exclude.parent().unwrap()).unwrap();
std::fs::write(&exclude, "*.swp\n.wsb/\n").unwrap();
std::fs::create_dir_all(root.join(".wsb").join("disposable")).unwrap();
for file in ["state.json", "wsb.db", "disposable/log.jsonl", "stamp.json", "test.json", "peer.json"] {
std::fs::write(root.join(".wsb").join(file), "{}\n").unwrap();
}
exclude_state_dir(root).unwrap();
let mut seen = untracked(root);
seen.sort();
assert_eq!(seen, vec![".wsb/peer.json", ".wsb/stamp.json", ".wsb/test.json"]);
let written = std::fs::read_to_string(&exclude).unwrap();
assert!(written.lines().any(|line| line == "*.swp"), "another exclusion was lost: {written}");
assert!(!written.lines().any(|line| line == ".wsb/"), "the old line was kept: {written}");
exclude_state_dir(root).unwrap();
assert_eq!(std::fs::read_to_string(&exclude).unwrap(), written, "a second call changed the file");
}
#[test]
fn test20051_every_spelling_that_hides_the_directory_is_replaced() {
for hiding in [".wsb", ".wsb/", "/.wsb", "/.wsb/"] {
let dir = repository();
let root = dir.path();
let exclude = root.join(".git").join("info").join("exclude");
std::fs::create_dir_all(exclude.parent().unwrap()).unwrap();
std::fs::write(&exclude, format!("{hiding}\n")).unwrap();
std::fs::create_dir_all(root.join(".wsb")).unwrap();
std::fs::write(root.join(".wsb/wsb.db"), "db").unwrap();
std::fs::write(root.join(".wsb/stamp.json"), "{}").unwrap();
exclude_state_dir(root).unwrap();
assert_eq!(untracked(root), vec![".wsb/stamp.json"], "`{hiding}` still hides the declarations");
}
}
#[test]
fn test20041_a_worktrees_state_is_ignored() {
let dir = repository();
let root = dir.path();
let run = |dir: &Path, args: &[&str]| {
let output = crate::git_backend::git_command()
.arg("-C")
.arg(dir)
.args(["-c", "user.name=t", "-c", "user.email=t@t"])
.args(args)
.output()
.expect("git runs");
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
};
run(root, &["commit", "--quiet", "--allow-empty", "-m", "one"]);
let worktree = root.join("fix-worktree");
run(root, &["worktree", "add", "--quiet", "-b", "fix", worktree.to_str().unwrap()]);
std::fs::create_dir_all(worktree.join(".wsb")).unwrap();
std::fs::write(worktree.join(".wsb/wsb.db"), "db").unwrap();
std::fs::write(worktree.join(".wsb/stamp.json"), "{}").unwrap();
exclude_state_dir(&worktree).unwrap();
assert_eq!(untracked(&worktree), vec![".wsb/stamp.json"], "the state is hidden, the declaration is not");
assert!(excludes_state(&std::fs::read_to_string(root.join(".git/info/exclude")).unwrap()));
}
}