Skip to content

Commit 0d7abd6

Browse files
authored
Merge pull request #135 from dirge-code/phase-0-dirge-infra
Phase 0: Per-project .dirge/ path resolution
2 parents 47f9b9c + 03f36e3 commit 0d7abd6

6 files changed

Lines changed: 231 additions & 22 deletions

File tree

.beads/issues.jsonl

Lines changed: 18 additions & 0 deletions
Large diffs are not rendered by default.

src/extras/dirge_paths.rs

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
//! Per-project `.dirge/` directory resolution.
2+
//!
3+
//! This is the canonical entry point for all per-project storage.
4+
//! Hermes stores everything globally in `~/.hermes/`; dirge stores
5+
//! per-project knowledge in `.dirge/` at the repository root (where
6+
//! `.git/` lives). Each project gets independent memory, skills,
7+
//! and session history.
8+
//!
9+
//! The existing `extras::memory` module stores in `~/.dirge/memories/`
10+
//! (user-global, project-keyed by path hash). That serves a different
11+
//! purpose — user notes that follow you across machines. Phase 2 will
12+
//! wire `ProjectPaths` into a new per-project `MemoryStore` without
13+
//! touching the global memory module.
14+
15+
use std::path::{Path, PathBuf};
16+
17+
/// Walk up from `cwd` until a `.git/` directory is found.
18+
/// Returns `cwd` unchanged if no git root is found (user may
19+
/// be outside a repo — per-project features degrade gracefully).
20+
pub fn find_git_root(cwd: &Path) -> PathBuf {
21+
let mut current = cwd.to_path_buf();
22+
loop {
23+
if current.join(".git").is_dir() {
24+
return current;
25+
}
26+
if !current.pop() {
27+
return cwd.to_path_buf();
28+
}
29+
}
30+
}
31+
32+
/// `DIRGE_PROJECT_ROOT` override. When set and pointing to an
33+
/// existing directory, this is the project root instead of the
34+
/// auto-detected git root. Useful for monorepos where the
35+
/// logical project is a subdirectory.
36+
pub fn project_root_override() -> Option<PathBuf> {
37+
std::env::var("DIRGE_PROJECT_ROOT")
38+
.ok()
39+
.map(PathBuf::from)
40+
.filter(|p| p.is_dir())
41+
}
42+
43+
/// Resolve the active project root: `DIRGE_PROJECT_ROOT` wins if
44+
/// set and valid, otherwise walk up from CWD looking for `.git/`.
45+
pub fn project_root(cwd: &Path) -> PathBuf {
46+
project_root_override().unwrap_or_else(|| find_git_root(cwd))
47+
}
48+
49+
/// Canonical paths into the per-project `.dirge/` tree.
50+
///
51+
/// Construct with `ProjectPaths::new(cwd)`. All subdirectory
52+
/// accessors are lazy — directories are not created until
53+
/// something actually writes into them.
54+
#[derive(Debug, Clone)]
55+
pub struct ProjectPaths {
56+
/// The project root (usually where `.git/` lives).
57+
pub root: PathBuf,
58+
}
59+
60+
impl ProjectPaths {
61+
pub fn new(cwd: &Path) -> Self {
62+
ProjectPaths {
63+
root: project_root(cwd),
64+
}
65+
}
66+
67+
/// Top-level `.dirge/` directory under the project root.
68+
pub fn dirge_dir(&self) -> PathBuf {
69+
self.root.join(".dirge")
70+
}
71+
72+
/// `.dirge/memory/` — declarative memory files (MEMORY.md, PITFALLS.md).
73+
pub fn memory_dir(&self) -> PathBuf {
74+
self.dirge_dir().join("memory")
75+
}
76+
77+
/// `.dirge/skills/` — procedural skill definitions with SKILL.md files.
78+
pub fn skills_dir(&self) -> PathBuf {
79+
self.dirge_dir().join("skills")
80+
}
81+
82+
/// `.dirge/sessions/` — SQLite session database and transcripts.
83+
pub fn sessions_dir(&self) -> PathBuf {
84+
self.dirge_dir().join("sessions")
85+
}
86+
87+
/// `.dirge/sessions/state.db` — the FTS5-backed session database.
88+
pub fn session_db_path(&self) -> PathBuf {
89+
self.sessions_dir().join("state.db")
90+
}
91+
92+
/// `.dirge/memory/<name>` — a specific memory file.
93+
pub fn memory_file(&self, name: &str) -> PathBuf {
94+
self.memory_dir().join(name)
95+
}
96+
97+
/// `.dirge/config.yaml` — optional per-project dirge configuration.
98+
pub fn config_path(&self) -> PathBuf {
99+
self.dirge_dir().join("config.yaml")
100+
}
101+
}
102+
103+
#[cfg(test)]
104+
mod tests {
105+
use super::*;
106+
107+
/// In the dirge repo itself, `find_git_root` from the current
108+
/// working directory should resolve to the repo root (where
109+
/// `.git/` actually lives).
110+
#[test]
111+
fn find_git_root_in_this_repo() {
112+
let cwd = std::env::current_dir().unwrap();
113+
let root = find_git_root(&cwd);
114+
assert!(
115+
root.join(".git").is_dir(),
116+
"expected {root:?} to contain .git/"
117+
);
118+
}
119+
120+
/// `/tmp` has no `.git/` — should return `/tmp` unchanged.
121+
#[test]
122+
fn find_git_root_falls_back_to_cwd_outside_repo() {
123+
let tmp = std::env::temp_dir();
124+
let root = find_git_root(&tmp);
125+
assert_eq!(root, tmp);
126+
}
127+
128+
/// `DIRGE_PROJECT_ROOT` wins over auto-detection.
129+
#[test]
130+
fn env_override_wins_over_git_detection() {
131+
let tmp = std::env::temp_dir();
132+
unsafe { std::env::set_var("DIRGE_PROJECT_ROOT", tmp.to_str().unwrap()) };
133+
// Even though we're in the dirge repo, the env var wins.
134+
let cwd = std::env::current_dir().unwrap();
135+
let root = project_root(&cwd);
136+
assert_eq!(root, tmp);
137+
unsafe { std::env::remove_var("DIRGE_PROJECT_ROOT") };
138+
}
139+
140+
/// An env var pointing to a non-existent directory is ignored
141+
/// (graceful fallback to git detection).
142+
#[test]
143+
fn env_override_ignores_missing_directory() {
144+
unsafe { std::env::set_var("DIRGE_PROJECT_ROOT", "/nonexistent/dirge/project/root") };
145+
let cwd = std::env::current_dir().unwrap();
146+
let root = project_root(&cwd);
147+
// Should fall through to git detection, not use the bogus path.
148+
assert_ne!(root, PathBuf::from("/nonexistent/dirge/project/root"));
149+
unsafe { std::env::remove_var("DIRGE_PROJECT_ROOT") };
150+
}
151+
152+
/// All subdirectory accessors nest under `.dirge/`.
153+
#[test]
154+
fn subdirs_are_under_dirge_dir() {
155+
let cwd = std::env::current_dir().unwrap();
156+
let paths = ProjectPaths::new(&cwd);
157+
let dirge = paths.dirge_dir();
158+
159+
assert!(paths.memory_dir().starts_with(&dirge));
160+
assert!(paths.skills_dir().starts_with(&dirge));
161+
assert!(paths.sessions_dir().starts_with(&dirge));
162+
assert!(paths.config_path().starts_with(&dirge));
163+
}
164+
165+
/// `session_db_path` points into `sessions/` and ends with `state.db`.
166+
#[test]
167+
fn session_db_is_in_sessions_dir() {
168+
let cwd = std::env::current_dir().unwrap();
169+
let paths = ProjectPaths::new(&cwd);
170+
let db = paths.session_db_path();
171+
assert!(db.starts_with(paths.sessions_dir()));
172+
assert!(db.ends_with("state.db"));
173+
}
174+
175+
/// `memory_file("MEMORY.md")` points to `.dirge/memory/MEMORY.md`.
176+
#[test]
177+
fn memory_file_is_in_memory_dir() {
178+
let cwd = std::env::current_dir().unwrap();
179+
let paths = ProjectPaths::new(&cwd);
180+
let f = paths.memory_file("MEMORY.md");
181+
assert_eq!(f.file_name().unwrap(), "MEMORY.md");
182+
assert!(f.starts_with(paths.memory_dir()));
183+
}
184+
}

src/extras/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ pub mod mcp;
1010
#[cfg(feature = "acp")]
1111
pub mod acp;
1212

13+
pub mod dirge_paths;
1314
pub mod memory;

src/permission/checker.rs

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1504,10 +1504,11 @@ mod tests {
15041504
CheckResult::Allowed
15051505
));
15061506
// The edit alias MUST also match — this is what enforce() checks.
1507-
assert!(matches!(
1508-
checker.check_path("edit", "/probe/src/main.rs"),
1509-
CheckResult::Allowed,
1510-
),
1507+
assert!(
1508+
matches!(
1509+
checker.check_path("edit", "/probe/src/main.rs"),
1510+
CheckResult::Allowed,
1511+
),
15111512
"edit alias must reflect write session-allowlist entry"
15121513
);
15131514

@@ -1518,16 +1519,18 @@ mod tests {
15181519
Some(std::path::PathBuf::from("/cwd-off-test-axis")),
15191520
);
15201521
checker2.add_session_allowlist("edit".to_string(), "/probe/src/**");
1521-
assert!(matches!(
1522-
checker2.check_path("write", "/probe/src/main.rs"),
1523-
CheckResult::Allowed,
1524-
),
1522+
assert!(
1523+
matches!(
1524+
checker2.check_path("write", "/probe/src/main.rs"),
1525+
CheckResult::Allowed,
1526+
),
15251527
"write must reflect edit session-allowlist entry"
15261528
);
1527-
assert!(matches!(
1528-
checker2.check_path("apply_patch", "/probe/src/main.rs"),
1529-
CheckResult::Allowed,
1530-
),
1529+
assert!(
1530+
matches!(
1531+
checker2.check_path("apply_patch", "/probe/src/main.rs"),
1532+
CheckResult::Allowed,
1533+
),
15311534
"apply_patch must reflect edit session-allowlist entry"
15321535
);
15331536

@@ -1538,10 +1541,11 @@ mod tests {
15381541
Some(std::path::PathBuf::from("/cwd-off-test-axis")),
15391542
);
15401543
checker3.add_session_allowlist("apply_patch".to_string(), "/probe/src/**");
1541-
assert!(matches!(
1542-
checker3.check_path("edit", "/probe/src/main.rs"),
1543-
CheckResult::Allowed,
1544-
),
1544+
assert!(
1545+
matches!(
1546+
checker3.check_path("edit", "/probe/src/main.rs"),
1547+
CheckResult::Allowed,
1548+
),
15451549
"edit must reflect apply_patch session-allowlist entry"
15461550
);
15471551

@@ -1552,10 +1556,11 @@ mod tests {
15521556
Some(std::path::PathBuf::from("/cwd-off-test-axis")),
15531557
);
15541558
checker4.load_session_allowlist(&[("write".to_string(), "/probe/src/**".to_string())]);
1555-
assert!(matches!(
1556-
checker4.check_path("edit", "/probe/src/main.rs"),
1557-
CheckResult::Allowed,
1558-
),
1559+
assert!(
1560+
matches!(
1561+
checker4.check_path("edit", "/probe/src/main.rs"),
1562+
CheckResult::Allowed,
1563+
),
15591564
"load_session_allowlist must also mirror write→edit"
15601565
);
15611566

src/provider/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -655,7 +655,9 @@ impl AnyAgent {
655655
self,
656656
prompt: String,
657657
history: Vec<Message>,
658-
steering_queue: Option<std::sync::Arc<std::sync::Mutex<std::collections::VecDeque<String>>>>,
658+
steering_queue: Option<
659+
std::sync::Arc<std::sync::Mutex<std::collections::VecDeque<String>>>,
660+
>,
659661
) -> AgentRunner {
660662
use crate::agent::agent_loop::{
661663
LoopSpawnConfig, loop_tool_to_rig_definition, retrying_stream_fn,

src/ui/mod.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ mod tree;
2929
mod tui;
3030
mod wrap;
3131

32-
3332
use compact_str::CompactString;
3433
use crossterm::event;
3534
use crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEventKind};

0 commit comments

Comments
 (0)