From 2a79616f8b13a367351dbfce32b197f92fbde63e Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 11 Jun 2026 16:03:00 -0600 Subject: [PATCH] feat: Added the ability to auto-bootstrap workspace memory when in git repos --- src/config/memory.rs | 220 ++++++++++++++++++++++++++++++++++++++++- src/config/mod.rs | 2 + src/function/memory.rs | 52 ++++++++-- src/main.rs | 2 +- 4 files changed, 264 insertions(+), 12 deletions(-) diff --git a/src/config/memory.rs b/src/config/memory.rs index 3620ef7..0a30da9 100644 --- a/src/config/memory.rs +++ b/src/config/memory.rs @@ -6,8 +6,8 @@ use log::warn; use serde::{Deserialize, Serialize}; use crate::config::{ - MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME, WORKSPACE_MEMORY_DIR_NAME, WORKSPACE_MEMORY_FILE_NAME, - paths, + GIT_DIR_NAME, GITIGNORE_FILE_NAME, MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME, + WORKSPACE_MEMORY_DIR_NAME, WORKSPACE_MEMORY_FILE_NAME, paths, }; pub const DEFAULT_MEMORY_CAP_WITH_TOOLS: usize = 6_000; @@ -46,6 +46,70 @@ pub fn discover_workspace_memory(start: &Path) -> Option { None } +pub fn find_git_root(start: &Path) -> Option { + for dir in start.ancestors() { + if dir.join(GIT_DIR_NAME).exists() { + return Some(dir.to_path_buf()); + } + } + + None +} + +pub fn bootstrap_workspace_memory(git_root: &Path) -> Result { + let mem_dir = paths::workspace_memory_dir_for(git_root); + fs::create_dir_all(&mem_dir) + .with_context(|| format!("create memory dir {}", mem_dir.display()))?; + + let index_path = mem_dir.join(MEMORY_INDEX_FILE_NAME); + if !index_path.exists() { + fs::write(&index_path, "# Workspace Memory Index\n\n") + .with_context(|| format!("write {}", index_path.display()))?; + } + + let gitignore_appended = append_gitignore_entry(git_root)?; + let suffix = if gitignore_appended { + " (appended .coyote/memory/ to .gitignore)" + } else { + "" + }; + warn!( + "auto-bootstrapped workspace memory at {}{}", + mem_dir.display(), + suffix + ); + + Ok(mem_dir) +} + +fn append_gitignore_entry(git_root: &Path) -> Result { + let gitignore = git_root.join(GITIGNORE_FILE_NAME); + let entry = format!("{WORKSPACE_MEMORY_DIR_NAME}/{MEMORY_DIR_NAME}/"); + let entry_no_slash = format!("{WORKSPACE_MEMORY_DIR_NAME}/{MEMORY_DIR_NAME}"); + + let existing = fs::read_to_string(&gitignore).unwrap_or_default(); + let already_present = existing.lines().any(|line| { + let trimmed = line.trim(); + trimmed == entry || trimmed == entry_no_slash + }); + + if already_present { + return Ok(false); + } + + let new_content = if existing.is_empty() { + format!("{entry}\n") + } else if existing.ends_with('\n') { + format!("{existing}{entry}\n") + } else { + format!("{existing}\n{entry}\n") + }; + + fs::write(&gitignore, new_content).with_context(|| format!("write {}", gitignore.display()))?; + + Ok(true) +} + #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct MemoryFrontmatter { #[serde(default)] @@ -514,4 +578,156 @@ mod tests { let _ = fs::remove_dir_all(&root); } + + #[test] + fn find_git_root_returns_dir_containing_git_dir() { + let root = temp_root("git_root"); + let repo = root.join("repo"); + fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap(); + + assert_eq!(find_git_root(&repo), Some(repo.clone())); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn find_git_root_walks_up_from_nested_dir() { + let root = temp_root("git_root_walk"); + let repo = root.join("repo"); + fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap(); + let nested = repo.join("a").join("b").join("c"); + fs::create_dir_all(&nested).unwrap(); + + assert_eq!(find_git_root(&nested), Some(repo)); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn find_git_root_treats_git_file_as_repo_marker() { + let root = temp_root("git_root_worktree"); + let worktree = root.join("worktree"); + fs::create_dir_all(&worktree).unwrap(); + fs::write( + worktree.join(GIT_DIR_NAME), + "gitdir: /elsewhere/.git/worktrees/wt\n", + ) + .unwrap(); + + assert_eq!(find_git_root(&worktree), Some(worktree)); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn find_git_root_returns_none_when_no_git() { + let root = temp_root("git_root_missing"); + let bare = root.join("bare"); + fs::create_dir_all(&bare).unwrap(); + + assert_eq!(find_git_root(&bare), None); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn bootstrap_creates_structured_layout_and_index() { + let root = temp_root("bootstrap_layout"); + let repo = root.join("repo"); + fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap(); + + let mem_dir = bootstrap_workspace_memory(&repo).unwrap(); + + assert_eq!(mem_dir, paths::workspace_memory_dir_for(&repo)); + assert!(mem_dir.is_dir()); + let index = mem_dir.join(MEMORY_INDEX_FILE_NAME); + assert!(index.exists()); + let body = fs::read_to_string(&index).unwrap(); + assert!(body.starts_with("# Workspace Memory Index")); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn bootstrap_creates_gitignore_when_absent() { + let root = temp_root("bootstrap_gi_new"); + let repo = root.join("repo"); + fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap(); + + bootstrap_workspace_memory(&repo).unwrap(); + + let gi = repo.join(GITIGNORE_FILE_NAME); + assert!(gi.exists()); + let body = fs::read_to_string(&gi).unwrap(); + assert!(body.contains(".coyote/memory/")); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn bootstrap_appends_to_existing_gitignore_without_trailing_newline() { + let root = temp_root("bootstrap_gi_append"); + let repo = root.join("repo"); + fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap(); + fs::write(repo.join(GITIGNORE_FILE_NAME), "target/").unwrap(); + + bootstrap_workspace_memory(&repo).unwrap(); + + let body = fs::read_to_string(repo.join(GITIGNORE_FILE_NAME)).unwrap(); + assert!(body.contains("target/")); + assert!(body.contains(".coyote/memory/")); + assert!(body.ends_with('\n')); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn bootstrap_is_idempotent_on_gitignore_entry() { + let root = temp_root("bootstrap_gi_idempotent"); + let repo = root.join("repo"); + fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap(); + let original = "target/\n.coyote/memory/\n"; + fs::write(repo.join(GITIGNORE_FILE_NAME), original).unwrap(); + + bootstrap_workspace_memory(&repo).unwrap(); + + let body = fs::read_to_string(repo.join(GITIGNORE_FILE_NAME)).unwrap(); + assert_eq!(body, original, "gitignore must be untouched"); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn bootstrap_treats_entry_without_trailing_slash_as_present() { + let root = temp_root("bootstrap_gi_no_slash"); + let repo = root.join("repo"); + fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap(); + let original = ".coyote/memory\n"; + fs::write(repo.join(GITIGNORE_FILE_NAME), original).unwrap(); + + bootstrap_workspace_memory(&repo).unwrap(); + + let body = fs::read_to_string(repo.join(GITIGNORE_FILE_NAME)).unwrap(); + assert_eq!(body, original); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn bootstrap_does_not_clobber_existing_index() { + let root = temp_root("bootstrap_existing_index"); + let repo = root.join("repo"); + fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap(); + let mem_dir = paths::workspace_memory_dir_for(&repo); + fs::create_dir_all(&mem_dir).unwrap(); + let preserved = "# Custom Index\n\n- [[foo]]: keep me\n"; + fs::write(mem_dir.join(MEMORY_INDEX_FILE_NAME), preserved).unwrap(); + + bootstrap_workspace_memory(&repo).unwrap(); + + let body = fs::read_to_string(mem_dir.join(MEMORY_INDEX_FILE_NAME)).unwrap(); + assert_eq!(body, preserved); + + let _ = fs::remove_dir_all(&root); + } } diff --git a/src/config/mod.rs b/src/config/mod.rs index ebe4258..884327e 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -143,6 +143,8 @@ const MEMORY_DIR_NAME: &str = "memory"; const MEMORY_INDEX_FILE_NAME: &str = "MEMORY.md"; const WORKSPACE_MEMORY_FILE_NAME: &str = "COYOTE.md"; const WORKSPACE_MEMORY_DIR_NAME: &str = ".coyote"; +const GIT_DIR_NAME: &str = ".git"; +const GITIGNORE_FILE_NAME: &str = ".gitignore"; const DEFAULT_VISIBLE_TOOLS: [&str; 18] = [ "execute_command.sh", "execute_py_code.py", diff --git a/src/function/memory.rs b/src/function/memory.rs index de0f69d..4a95058 100644 --- a/src/function/memory.rs +++ b/src/function/memory.rs @@ -8,7 +8,10 @@ use serde_json::{Value, json}; use super::{FunctionDeclaration, JsonSchema}; use crate::config::RequestContext; -use crate::config::memory::{MemoryFile, MemoryFrontmatter, MemoryStore, WorkspaceMemory}; +use crate::config::memory::{ + MemoryFile, MemoryFrontmatter, MemoryStore, WorkspaceMemory, bootstrap_workspace_memory, + find_git_root, +}; use crate::config::paths; pub const MEMORY_FUNCTION_PREFIX: &str = "memory__"; @@ -185,7 +188,7 @@ pub fn handle_memory_tool(ctx: &mut RequestContext, cmd_name: &str, args: &Value let target_dir = match scope.as_str() { "global" => paths::global_memory_dir(), - "workspace" => workspace_write_dir(&store)?, + "workspace" => workspace_write_dir(&store, &cwd)?, other => bail!("unknown scope '{}': use 'global' or 'workspace'", other), }; let file = MemoryFile { @@ -254,13 +257,19 @@ fn find_file(store: &MemoryStore, name: &str) -> Result> { .find(|f| f.frontmatter.name == name)) } -fn workspace_write_dir(store: &MemoryStore) -> Result { +fn workspace_write_dir(store: &MemoryStore, cwd: &Path) -> Result { match &store.workspace { Some(WorkspaceMemory::Structured { dir, .. }) => Ok(dir.clone()), Some(WorkspaceMemory::Lite { workspace_root, .. }) => { Ok(paths::workspace_memory_dir_for(workspace_root)) } - None => bail!("no workspace memory discoverable; cannot write workspace-scoped memory"), + None => match find_git_root(cwd) { + Some(git_root) => bootstrap_workspace_memory(&git_root), + None => bail!( + "no workspace memory discoverable and not inside a git repository for auto-bootstrap. \ + If you want workspace memory, run `coyote --init-memory workspace`." + ), + }, } } @@ -425,7 +434,7 @@ mod tests { workspace: discover_workspace_memory(&workspace), }; - let dir = workspace_write_dir(&store).unwrap(); + let dir = workspace_write_dir(&store, &workspace).unwrap(); assert_eq!(dir, structured); let _ = fs::remove_dir_all(&root); @@ -443,14 +452,14 @@ mod tests { workspace: discover_workspace_memory(&workspace), }; - let dir = workspace_write_dir(&store).unwrap(); + let dir = workspace_write_dir(&store, &workspace).unwrap(); assert_eq!(dir, workspace.join(".coyote").join("memory")); let _ = fs::remove_dir_all(&root); } #[test] - fn workspace_write_dir_errors_when_no_workspace() { + fn workspace_write_dir_errors_when_no_workspace_and_no_git() { let root = temp_root("ws_none"); let bare = root.join("nowhere"); fs::create_dir_all(&bare).unwrap(); @@ -460,8 +469,33 @@ mod tests { workspace: discover_workspace_memory(&bare), }; - let err = workspace_write_dir(&store).unwrap_err(); - assert!(err.to_string().contains("no workspace memory discoverable")); + let err = workspace_write_dir(&store, &bare).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("no workspace memory discoverable")); + assert!(msg.contains("coyote --init-memory workspace")); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn workspace_write_dir_auto_bootstraps_inside_git_repo() { + let root = temp_root("ws_bootstrap"); + let repo = root.join("repo"); + fs::create_dir_all(repo.join(".git")).unwrap(); + let nested = repo.join("src").join("deep"); + fs::create_dir_all(&nested).unwrap(); + + let store = MemoryStore { + global_dir: root.join("g"), + workspace: discover_workspace_memory(&nested), + }; + assert!(store.workspace.is_none()); + + let dir = workspace_write_dir(&store, &nested).unwrap(); + assert_eq!(dir, repo.join(".coyote").join("memory")); + assert!(dir.join("MEMORY.md").exists()); + let gi = fs::read_to_string(repo.join(".gitignore")).unwrap(); + assert!(gi.contains(".coyote/memory/")); let _ = fs::remove_dir_all(&root); } diff --git a/src/main.rs b/src/main.rs index 4c8f7bd..293a260 100644 --- a/src/main.rs +++ b/src/main.rs @@ -309,7 +309,7 @@ async fn run( "# Global Memory\n\n\n\n", ), MemoryScope::Workspace => ( - std::env::current_dir()?.join("COYOTE.md"), + env::current_dir()?.join("COYOTE.md"), "# Workspace Memory\n\n\n", ), };