Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .beads/issues.jsonl

Large diffs are not rendered by default.

184 changes: 184 additions & 0 deletions src/extras/dirge_paths.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
//! Per-project `.dirge/` directory resolution.
//!
//! This is the canonical entry point for all per-project storage.
//! Hermes stores everything globally in `~/.hermes/`; dirge stores
//! per-project knowledge in `.dirge/` at the repository root (where
//! `.git/` lives). Each project gets independent memory, skills,
//! and session history.
//!
//! The existing `extras::memory` module stores in `~/.dirge/memories/`
//! (user-global, project-keyed by path hash). That serves a different
//! purpose — user notes that follow you across machines. Phase 2 will
//! wire `ProjectPaths` into a new per-project `MemoryStore` without
//! touching the global memory module.

use std::path::{Path, PathBuf};

/// Walk up from `cwd` until a `.git/` directory is found.
/// Returns `cwd` unchanged if no git root is found (user may
/// be outside a repo — per-project features degrade gracefully).
pub fn find_git_root(cwd: &Path) -> PathBuf {
let mut current = cwd.to_path_buf();
loop {
if current.join(".git").is_dir() {
return current;
}
if !current.pop() {
return cwd.to_path_buf();
}
}
}

/// `DIRGE_PROJECT_ROOT` override. When set and pointing to an
/// existing directory, this is the project root instead of the
/// auto-detected git root. Useful for monorepos where the
/// logical project is a subdirectory.
pub fn project_root_override() -> Option<PathBuf> {
std::env::var("DIRGE_PROJECT_ROOT")
.ok()
.map(PathBuf::from)
.filter(|p| p.is_dir())
}

/// Resolve the active project root: `DIRGE_PROJECT_ROOT` wins if
/// set and valid, otherwise walk up from CWD looking for `.git/`.
pub fn project_root(cwd: &Path) -> PathBuf {
project_root_override().unwrap_or_else(|| find_git_root(cwd))
}

/// Canonical paths into the per-project `.dirge/` tree.
///
/// Construct with `ProjectPaths::new(cwd)`. All subdirectory
/// accessors are lazy — directories are not created until
/// something actually writes into them.
#[derive(Debug, Clone)]
pub struct ProjectPaths {
/// The project root (usually where `.git/` lives).
pub root: PathBuf,
}

impl ProjectPaths {
pub fn new(cwd: &Path) -> Self {
ProjectPaths {
root: project_root(cwd),
}
}

/// Top-level `.dirge/` directory under the project root.
pub fn dirge_dir(&self) -> PathBuf {
self.root.join(".dirge")
}

/// `.dirge/memory/` — declarative memory files (MEMORY.md, PITFALLS.md).
pub fn memory_dir(&self) -> PathBuf {
self.dirge_dir().join("memory")
}

/// `.dirge/skills/` — procedural skill definitions with SKILL.md files.
pub fn skills_dir(&self) -> PathBuf {
self.dirge_dir().join("skills")
}

/// `.dirge/sessions/` — SQLite session database and transcripts.
pub fn sessions_dir(&self) -> PathBuf {
self.dirge_dir().join("sessions")
}

/// `.dirge/sessions/state.db` — the FTS5-backed session database.
pub fn session_db_path(&self) -> PathBuf {
self.sessions_dir().join("state.db")
}

/// `.dirge/memory/<name>` — a specific memory file.
pub fn memory_file(&self, name: &str) -> PathBuf {
self.memory_dir().join(name)
}

/// `.dirge/config.yaml` — optional per-project dirge configuration.
pub fn config_path(&self) -> PathBuf {
self.dirge_dir().join("config.yaml")
}
}

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

/// In the dirge repo itself, `find_git_root` from the current
/// working directory should resolve to the repo root (where
/// `.git/` actually lives).
#[test]
fn find_git_root_in_this_repo() {
let cwd = std::env::current_dir().unwrap();
let root = find_git_root(&cwd);
assert!(
root.join(".git").is_dir(),
"expected {root:?} to contain .git/"
);
}

/// `/tmp` has no `.git/` — should return `/tmp` unchanged.
#[test]
fn find_git_root_falls_back_to_cwd_outside_repo() {
let tmp = std::env::temp_dir();
let root = find_git_root(&tmp);
assert_eq!(root, tmp);
}

/// `DIRGE_PROJECT_ROOT` wins over auto-detection.
#[test]
fn env_override_wins_over_git_detection() {
let tmp = std::env::temp_dir();
unsafe { std::env::set_var("DIRGE_PROJECT_ROOT", tmp.to_str().unwrap()) };
// Even though we're in the dirge repo, the env var wins.
let cwd = std::env::current_dir().unwrap();
let root = project_root(&cwd);
assert_eq!(root, tmp);
unsafe { std::env::remove_var("DIRGE_PROJECT_ROOT") };
}

/// An env var pointing to a non-existent directory is ignored
/// (graceful fallback to git detection).
#[test]
fn env_override_ignores_missing_directory() {
unsafe { std::env::set_var("DIRGE_PROJECT_ROOT", "/nonexistent/dirge/project/root") };
let cwd = std::env::current_dir().unwrap();
let root = project_root(&cwd);
// Should fall through to git detection, not use the bogus path.
assert_ne!(root, PathBuf::from("/nonexistent/dirge/project/root"));
unsafe { std::env::remove_var("DIRGE_PROJECT_ROOT") };
}

/// All subdirectory accessors nest under `.dirge/`.
#[test]
fn subdirs_are_under_dirge_dir() {
let cwd = std::env::current_dir().unwrap();
let paths = ProjectPaths::new(&cwd);
let dirge = paths.dirge_dir();

assert!(paths.memory_dir().starts_with(&dirge));
assert!(paths.skills_dir().starts_with(&dirge));
assert!(paths.sessions_dir().starts_with(&dirge));
assert!(paths.config_path().starts_with(&dirge));
}

/// `session_db_path` points into `sessions/` and ends with `state.db`.
#[test]
fn session_db_is_in_sessions_dir() {
let cwd = std::env::current_dir().unwrap();
let paths = ProjectPaths::new(&cwd);
let db = paths.session_db_path();
assert!(db.starts_with(paths.sessions_dir()));
assert!(db.ends_with("state.db"));
}

/// `memory_file("MEMORY.md")` points to `.dirge/memory/MEMORY.md`.
#[test]
fn memory_file_is_in_memory_dir() {
let cwd = std::env::current_dir().unwrap();
let paths = ProjectPaths::new(&cwd);
let f = paths.memory_file("MEMORY.md");
assert_eq!(f.file_name().unwrap(), "MEMORY.md");
assert!(f.starts_with(paths.memory_dir()));
}
}
1 change: 1 addition & 0 deletions src/extras/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ pub mod mcp;
#[cfg(feature = "acp")]
pub mod acp;

pub mod dirge_paths;
pub mod memory;
45 changes: 25 additions & 20 deletions src/permission/checker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1504,10 +1504,11 @@ mod tests {
CheckResult::Allowed
));
// The edit alias MUST also match — this is what enforce() checks.
assert!(matches!(
checker.check_path("edit", "/probe/src/main.rs"),
CheckResult::Allowed,
),
assert!(
matches!(
checker.check_path("edit", "/probe/src/main.rs"),
CheckResult::Allowed,
),
"edit alias must reflect write session-allowlist entry"
);

Expand All @@ -1518,16 +1519,18 @@ mod tests {
Some(std::path::PathBuf::from("/cwd-off-test-axis")),
);
checker2.add_session_allowlist("edit".to_string(), "/probe/src/**");
assert!(matches!(
checker2.check_path("write", "/probe/src/main.rs"),
CheckResult::Allowed,
),
assert!(
matches!(
checker2.check_path("write", "/probe/src/main.rs"),
CheckResult::Allowed,
),
"write must reflect edit session-allowlist entry"
);
assert!(matches!(
checker2.check_path("apply_patch", "/probe/src/main.rs"),
CheckResult::Allowed,
),
assert!(
matches!(
checker2.check_path("apply_patch", "/probe/src/main.rs"),
CheckResult::Allowed,
),
"apply_patch must reflect edit session-allowlist entry"
);

Expand All @@ -1538,10 +1541,11 @@ mod tests {
Some(std::path::PathBuf::from("/cwd-off-test-axis")),
);
checker3.add_session_allowlist("apply_patch".to_string(), "/probe/src/**");
assert!(matches!(
checker3.check_path("edit", "/probe/src/main.rs"),
CheckResult::Allowed,
),
assert!(
matches!(
checker3.check_path("edit", "/probe/src/main.rs"),
CheckResult::Allowed,
),
"edit must reflect apply_patch session-allowlist entry"
);

Expand All @@ -1552,10 +1556,11 @@ mod tests {
Some(std::path::PathBuf::from("/cwd-off-test-axis")),
);
checker4.load_session_allowlist(&[("write".to_string(), "/probe/src/**".to_string())]);
assert!(matches!(
checker4.check_path("edit", "/probe/src/main.rs"),
CheckResult::Allowed,
),
assert!(
matches!(
checker4.check_path("edit", "/probe/src/main.rs"),
CheckResult::Allowed,
),
"load_session_allowlist must also mirror write→edit"
);

Expand Down
4 changes: 3 additions & 1 deletion src/provider/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -655,7 +655,9 @@ impl AnyAgent {
self,
prompt: String,
history: Vec<Message>,
steering_queue: Option<std::sync::Arc<std::sync::Mutex<std::collections::VecDeque<String>>>>,
steering_queue: Option<
std::sync::Arc<std::sync::Mutex<std::collections::VecDeque<String>>>,
>,
) -> AgentRunner {
use crate::agent::agent_loop::{
LoopSpawnConfig, loop_tool_to_rig_definition, retrying_stream_fn,
Expand Down
1 change: 0 additions & 1 deletion src/ui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ mod tree;
mod tui;
mod wrap;


use compact_str::CompactString;
use crossterm::event;
use crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEventKind};
Expand Down
Loading