1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
//! Filesystem path resolution helpers for `Store`. Extracted from
//! `store.rs` so that the main module stays focused on the Store API.
use crate::error::{BallError, Result};
use crate::git;
use std::fs;
use std::path::{Path, PathBuf};
/// Task directory resolution. Stealth mode honors an override file at
/// `.balls/local/tasks_dir`; non-stealth mode points at the state
/// worktree's `.balls/tasks/` (the canonical location — main's
/// `.balls/tasks` is a gitignored symlink to the same place).
pub(crate) fn resolve_tasks_dir(root: &Path) -> (PathBuf, bool) {
let override_file = root.join(".balls/local/tasks_dir");
if let Ok(s) = fs::read_to_string(&override_file) {
let p = PathBuf::from(s.trim());
if p.is_absolute() {
return (p, true);
}
}
(root.join(".balls/worktree/.balls/tasks"), false)
}
/// Generate a deterministic external path for stealth tasks.
///
/// The SHA-1 here is load-bearing on-disk state, not an implementation
/// choice (footprint audit bl-32f8). Two independent disqualifiers
/// against the jq+git+ln substitute (`git hash-object`):
/// 1. This path is computed in *stealth / no-git mode* — the mode
/// whose entire point is operating outside a git repo. Deriving
/// the store location by shelling to git would require the very
/// context this mode is built to not need.
/// 2. `git hash-object` returns `sha1("blob <len>\0" + content)`,
/// not `sha1(content)`. The value differs, and this hash *is*
/// the store directory: changing it silently orphans every
/// existing stealth store. It is a persisted on-disk contract.
///
/// Backed by the vendored `crate::hash::sha1_hex` (bl-cb4e), which
/// produces byte-identical output to `hex::encode(sha1::Sha1::digest)`
/// — the RustCrypto stack is no longer in the dependency tree, and
/// existing stealth stores keep resolving to the same directory.
pub(crate) fn stealth_tasks_dir(root: &Path) -> PathBuf {
let root_str = root.to_string_lossy();
let hash = crate::hash::sha1_hex(root_str.as_bytes());
let base = dirs_base(&hash);
PathBuf::from(base).join("tasks")
}
pub(crate) fn dirs_base(hash: &str) -> String {
if let Ok(home) = std::env::var("HOME") {
format!("{}/.local/share/balls/{}", home, &hash[..12])
} else {
format!("/tmp/balls-stealth-{}", &hash[..12])
}
}
/// Gate for `discover_git`: confirm `from` is inside a git repo. A
/// bare hub has no work tree, so `rev-parse --show-toplevel` (used by
/// `git_root`) fails there — but it is still a git repo whose gitdir
/// parent is the main root. Tolerate that; only a genuine non-git dir
/// keeps the `NotARepo` error so `discover` falls back to no-git
/// discovery. (bl-8cf7)
pub(crate) fn require_git_repo(from: &Path) -> Result<()> {
match git::git_root(from) {
Ok(_) => Ok(()),
Err(_) if crate::bare_squash::is_bare_repo(from).unwrap_or(false) => Ok(()),
Err(e) => Err(e),
}
}
pub(crate) fn find_main_root(common_dir: &Path) -> Result<PathBuf> {
let canon = fs::canonicalize(common_dir).unwrap_or_else(|_| common_dir.to_path_buf());
canon
.parent()
.map(Path::to_path_buf)
.ok_or_else(|| BallError::Other("could not find main repo root".to_string()))
}
/// Walk up from `from` looking for `.balls/config.json` to locate
/// the project root when no git repo is available (no-git mode).
pub(crate) fn find_balls_root(from: &Path) -> Result<PathBuf> {
let mut cur = fs::canonicalize(from).unwrap_or_else(|_| from.to_path_buf());
let mut searched = Vec::new();
loop {
searched.push(cur.clone());
if cur.join(".balls/config.json").exists() {
return Ok(cur);
}
if !cur.pop() {
return Err(BallError::no_balls_on_walk(searched));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dirs_base_no_home() {
let saved = std::env::var("HOME").ok();
std::env::remove_var("HOME");
let result = dirs_base("abcdef123456789");
if let Some(h) = saved {
std::env::set_var("HOME", h);
}
assert!(result.starts_with("/tmp/balls-stealth-"));
}
#[test]
fn find_balls_root_reports_the_walked_path() {
let dir = tempfile::TempDir::new().unwrap();
let start = dir.path().join("a/b/c");
fs::create_dir_all(&start).unwrap();
let err = find_balls_root(&start).unwrap_err();
match err {
BallError::NotInitialized(crate::error::NotInitKind::NoBallsOnWalk(searched)) => {
// First entry is the (canonicalized) start dir; the walk
// ends at the filesystem root.
assert!(searched.len() >= 4);
assert!(searched.first().unwrap().ends_with("a/b/c"));
assert_eq!(searched.last().unwrap(), Path::new("/"));
}
other => panic!("expected NoBallsOnWalk, got {other}"),
}
}
}