From 94b283515c936c640f71568d74fc64209d6ba036 Mon Sep 17 00:00:00 2001 From: "K. Hodges" Date: Tue, 9 Jun 2026 03:54:32 -0700 Subject: [PATCH] Git native project detection --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 8 +- docs/quickstart.md | 21 +++- docs/versioning.md | 1 + src/app.rs | 65 +++++++++++- src/config.rs | 26 +++++ src/main.rs | 1 + src/project.rs | 239 +++++++++++++++++++++++++++++++++++++++++++++ src/repl.rs | 1 + 10 files changed, 359 insertions(+), 7 deletions(-) create mode 100644 src/project.rs diff --git a/Cargo.lock b/Cargo.lock index 5bcd77e..9e98201 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -100,7 +100,7 @@ dependencies = [ [[package]] name = "exoshell" -version = "0.4.0" +version = "0.5.0" dependencies = [ "async-trait", "futures-util", diff --git a/Cargo.toml b/Cargo.toml index e2e3b6b..8a5df7d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "exoshell" -version = "0.4.0" +version = "0.5.0" edition = "2024" license = "GPL-3.0-or-later" diff --git a/README.md b/README.md index 20b8b3f..14ffa39 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ The current implementation supports the first shell-adjacent model chat mileston The codebase also contains the Phase 1.5 context engine foundation: context entries, provenance metadata, priority and size estimates, a session context store, provider registry, default manual/file/command-output/stdin/directory-summary providers, context REPL commands, deterministic pruning, budget checks, transcript events, startup context flags, piped stdin import, and prompt-context rendering. -The active milestone is Phase 2. The current implementation adds stance selection, explicit prompt assembly, visible prompt/context budget estimates, command suggestion IDs, simple risky-command warnings, command copy/explain/discard actions, a plain terminal session panel, and Phase 2 help text. The interaction model is documented in [docs/phase2_interaction_model.md](docs/phase2_interaction_model.md). +The active milestone is Phase 3. The current implementation adds stance selection, explicit prompt assembly, visible prompt/context budget estimates, command suggestion IDs, simple risky-command warnings, command copy/explain/discard actions, a plain terminal session panel, Phase 2 help text, configurable model routing, and initial Git-native project detection. The broader roadmap is tracked in [docs/PHASES.md](docs/PHASES.md) and [docs/tasks](docs/tasks). @@ -63,6 +63,12 @@ Select an operating stance: cargo run -- --stance audit ``` +Override the detected project root: + +```sh +cargo run -- --project-root path/to/repo +``` + Configure model routing: ```toml diff --git a/docs/quickstart.md b/docs/quickstart.md index 45bb912..2814582 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -163,6 +163,19 @@ Select an operating stance: cargo run -- --stance audit ``` +Override detected project root: + +```sh +cargo run -- --project-root path/to/repo +``` + +Or configure it: + +```toml +[project] +root = "path/to/repo" +``` + ## First Session At the prompt: @@ -184,6 +197,12 @@ exo> /context exo> /context stats ``` +Inspect detected Git project state: + +```text +exo> /project +``` + Ask a question: ```text @@ -298,7 +317,7 @@ Show the current operating state: exo> /panel ``` -The panel includes stance, shell family, provider/model, transcript state, context entries, and prompt estimates. +The panel includes stance, shell family, provider/model, transcript state, detected Git project, context entries, and prompt estimates. ## Keybinding Fallbacks diff --git a/docs/versioning.md b/docs/versioning.md index 40b641a..941586e 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -103,3 +103,4 @@ Historical codenames should be tracked in docs/versioning.md below * 0.2.0 context-relic * 0.3.0 stance-lantern * 0.4.0 switchboard-relic +* 0.5.0 branch-oracle diff --git a/src/app.rs b/src/app.rs index 75a6fca..6aa29eb 100644 --- a/src/app.rs +++ b/src/app.rs @@ -10,6 +10,7 @@ use crate::context::{ }; use crate::formatting::render_assistant_output_with_policy; use crate::keybindings::render_keybindings; +use crate::project::{ProjectError, detect_project, render_project_status}; use crate::prompts::{Stance, assemble_prompt, render_prompt_estimate}; use crate::providers::{ChatMessage, ChatRequest, ChatResponse, ChatRole, Provider, ProviderError}; use crate::repl::ReplError; @@ -105,6 +106,10 @@ impl App { return Ok(self.render_panel()); } + if trimmed == "/project" { + return self.render_project(); + } + if trimmed == "/help" { return Ok(help_overview().into()); } @@ -339,7 +344,7 @@ impl App { fn render_panel(&self) -> String { format!( - "Exoshell session\nstance: {}\nshell: {}\nprovider: openai-compatible\nmodel: {}\nrouter: {}\ntranscript: {}\n\nContext\n{}\n\nPrompt estimate\n{}", + "Exoshell session\nstance: {}\nshell: {}\nprovider: openai-compatible\nmodel: {}\nrouter: {}\ntranscript: {}\n\n{}\n\nContext\n{}\n\nPrompt estimate\n{}", self.config.interaction.stance, self.config.shell.family, self.config.provider.model, @@ -349,11 +354,31 @@ impl App { } else { "disabled" }, + self.render_project_status_for_panel(), render_context_list(self.context_store.entries()), render_prompt_estimate(self.prompt_budget_estimate()) ) } + fn render_project(&self) -> Result { + let cwd = std::env::current_dir().map_err(|error| ProjectError::Read { + path: PathBuf::from("."), + error: error.to_string(), + })?; + let project = detect_project(&cwd, self.config.project.root.as_deref())?; + Ok(render_project_status(project.as_ref())) + } + + fn render_project_status_for_panel(&self) -> String { + let Ok(cwd) = std::env::current_dir() else { + return "Project\nstatus: unavailable".into(); + }; + match detect_project(&cwd, self.config.project.root.as_deref()) { + Ok(project) => render_project_status(project.as_ref()), + Err(error) => format!("Project\nstatus: {error}"), + } + } + fn render_router_status(&self) -> String { if !self.config.router.enabled { return "disabled".into(); @@ -514,6 +539,8 @@ pub enum AppError { Transcript(#[from] TranscriptError), #[error(transparent)] Context(#[from] ContextError), + #[error(transparent)] + Project(#[from] ProjectError), } fn parse_context_priority_args(input: &str) -> Result<(&str, ContextPriority), ContextError> { @@ -536,6 +563,7 @@ fn parse_context_priority_args(input: &str) -> Result<(&str, ContextPriority), C fn help_overview() -> &'static str { "Commands: /context list attached context +/project show detected Git project and branch /context stats show context and prompt budget estimates /context show inspect a context entry /context enable|disable control model inclusion @@ -562,6 +590,9 @@ fn help_topic(topic: &str) -> &'static str { "context" => { "Context is explicit and session-scoped. Use /add-note, /add-file, /add-dir, or /add-output to attach material. Use /context stats before requests to inspect attached context size and prompt estimates." } + "project" => { + "Project detection walks upward from the current directory or configured project.root to find a Git repository. Use /project or /panel to inspect the detected root and branch." + } "stance" => { "Stances change the compact prompt fragment used for the next request: operator is concise and action-oriented, audit focuses on risks, teach explains more, and quiet minimizes prose while keeping safety warnings." } @@ -571,7 +602,9 @@ fn help_topic(topic: &str) -> &'static str { "keys" => { "The current line REPL does not install advanced terminal keybindings. Use /keys to see the predictable slash-command fallbacks for copy, explain, discard, context, and stance actions." } - _ => "Unknown help topic. Try /help context, /help stance, /help commands, or /help keys.", + _ => { + "Unknown help topic. Try /help context, /help project, /help stance, /help commands, or /help keys." + } } } @@ -582,6 +615,7 @@ pub struct CliOptions { pub stance: Option, pub transcript_enabled: Option, pub transcript_directory: Option, + pub project_root: Option, pub context_notes: Vec, pub context_files: Vec, pub no_color: bool, @@ -635,6 +669,12 @@ impl CliOptions { options.transcript_directory = Some(PathBuf::from(value)); options.transcript_enabled = Some(true); } + "--project-root" => { + let value = args.next().ok_or_else(|| { + crate::config::ConfigError::Invalid("--project-root requires a path".into()) + })?; + options.project_root = Some(PathBuf::from(value)); + } "--context-note" => { let value = args.next().ok_or_else(|| { crate::config::ConfigError::Invalid("--context-note requires text".into()) @@ -661,7 +701,7 @@ impl CliOptions { } pub fn help() -> &'static str { - "Usage: exoshell [--config ] [--shell powershell|posix] [--stance operator|audit|teach|quiet] [--context-note ] [--context-file ] [--no-transcript] [--transcript-dir ] [--no-color]\n\nStarts the Exoshell interactive model chat. Exoshell suggests commands; it does not execute them." + "Usage: exoshell [--config ] [--shell powershell|posix] [--stance operator|audit|teach|quiet] [--project-root ] [--context-note ] [--context-file ] [--no-transcript] [--transcript-dir ] [--no-color]\n\nStarts the Exoshell interactive model chat. Exoshell suggests commands; it does not execute them." } } @@ -698,6 +738,8 @@ mod tests { "--no-transcript".to_string(), "--transcript-dir".to_string(), "out".to_string(), + "--project-root".to_string(), + "repo".to_string(), "--context-note".to_string(), "note".to_string(), "--context-file".to_string(), @@ -710,6 +752,7 @@ mod tests { assert_eq!(options.stance, Some(Stance::Audit)); assert_eq!(options.transcript_enabled, Some(true)); assert_eq!(options.transcript_directory, Some(PathBuf::from("out"))); + assert_eq!(options.project_root, Some(PathBuf::from("repo"))); assert_eq!(options.context_notes, vec!["note".to_string()]); assert_eq!(options.context_files, vec![PathBuf::from("Cargo.toml")]); assert!(options.no_color); @@ -891,6 +934,21 @@ mod tests { .expect("panel") .contains("stance: operator") ); + assert!( + app.handle_command("/panel") + .expect("panel") + .contains("Project") + ); + assert!( + app.handle_command("/project") + .expect("project") + .contains("Project") + ); + assert!( + app.handle_command("/help project") + .expect("help project") + .contains("Git repository") + ); assert!( app.handle_command("/help commands") .expect("help") @@ -1007,6 +1065,7 @@ mod tests { request_timeout_seconds: 120, }, router: crate::providers::router::ModelRouterConfig::default(), + project: crate::config::ProjectConfig::default(), shell: ShellConfig { family: ShellFamily::PowerShell, }, diff --git a/src/config.rs b/src/config.rs index 3ee8876..becbd64 100644 --- a/src/config.rs +++ b/src/config.rs @@ -15,6 +15,7 @@ use crate::shell::ShellFamily; pub struct Config { pub provider: ProviderConfig, pub router: ModelRouterConfig, + pub project: ProjectConfig, pub shell: ShellConfig, pub interaction: InteractionConfig, pub commands: CommandConfig, @@ -46,6 +47,11 @@ pub struct CommandConfig { pub risk: CommandRiskPolicy, } +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ProjectConfig { + pub root: Option, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct TranscriptConfig { pub directory: PathBuf, @@ -71,6 +77,7 @@ impl ContextConfig { struct RawConfig { provider: Option, router: Option, + project: Option, shell: Option, interaction: Option, commands: Option, @@ -102,6 +109,11 @@ struct RawModelRouterRole { description: Option, } +#[derive(Debug, Deserialize, Default)] +struct RawProjectConfig { + root: Option, +} + #[derive(Debug, Deserialize, Default)] struct RawShellConfig { family: Option, @@ -148,6 +160,7 @@ impl Config { fn from_raw(raw: RawConfig) -> Result { let provider = raw.provider.unwrap_or_default(); let router = raw.router.unwrap_or_default(); + let project = raw.project.unwrap_or_default(); let shell = raw.shell.unwrap_or_default(); let interaction = raw.interaction.unwrap_or_default(); let commands = raw.commands.unwrap_or_default(); @@ -183,6 +196,7 @@ impl Config { request_timeout_seconds: provider.request_timeout_seconds.unwrap_or(120), }, router, + project: ProjectConfig { root: project.root }, shell: ShellConfig { family }, interaction: InteractionConfig { stance }, commands: CommandConfig { risk }, @@ -214,6 +228,10 @@ impl Config { self.transcript.directory = transcript_directory.clone(); } + if let Some(project_root) = &options.project_root { + self.project.root = Some(project_root.clone()); + } + Ok(()) } } @@ -462,6 +480,7 @@ mod tests { shell: Some(RawShellConfig { family: Some("cmd".into()), }), + project: None, router: None, interaction: None, commands: None, @@ -500,6 +519,9 @@ name = "heavy" model = "coder-g4-26b" description = "deep technical work" +[project] +root = "." + [shell] family = "posix" @@ -535,6 +557,7 @@ max_estimated_tokens = 3000 assert_eq!(config.router.fallback_role, "instant"); assert_eq!(config.router.roles.len(), 2); assert_eq!(config.router.roles[1].model, "coder-g4-26b"); + assert_eq!(config.project.root, Some(PathBuf::from("."))); assert_eq!(config.shell.family, ShellFamily::Posix); assert_eq!(config.interaction.stance, Stance::Audit); assert!(config.commands.risk.include_defaults); @@ -567,6 +590,7 @@ max_estimated_tokens = 3000 assert_eq!(config.context.max_characters, None); assert_eq!(config.context.max_estimated_tokens, None); assert_eq!(config.provider.request_timeout_seconds, 120); + assert_eq!(config.project.root, None); assert!(config.commands.risk.include_defaults); assert!(config.commands.risk.rules.is_empty()); assert!(!config.router.enabled); @@ -643,6 +667,7 @@ include_defaults = false stance: Some(Stance::Teach), transcript_enabled: Some(false), transcript_directory: Some(tempdir.clone()), + project_root: Some(PathBuf::from("repo-root")), ..CliOptions::default() }; @@ -652,6 +677,7 @@ include_defaults = false assert_eq!(config.interaction.stance, Stance::Teach); assert!(!config.transcript.enabled); assert_eq!(config.transcript.directory, tempdir); + assert_eq!(config.project.root, Some(PathBuf::from("repo-root"))); } #[test] diff --git a/src/main.rs b/src/main.rs index b8f0b58..97060ea 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod config; pub mod context; mod formatting; mod keybindings; +mod project; mod prompts; mod providers; mod repl; diff --git a/src/project.rs b/src/project.rs new file mode 100644 index 0000000..38d741f --- /dev/null +++ b/src/project.rs @@ -0,0 +1,239 @@ +use std::fmt; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProjectInfo { + pub root: PathBuf, + pub git_dir: PathBuf, + pub head: ProjectHead, +} + +impl ProjectInfo { + pub fn branch_label(&self) -> String { + self.head.to_string() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProjectHead { + Branch(String), + Detached(String), + Unknown, +} + +impl fmt::Display for ProjectHead { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Branch(branch) => formatter.write_str(branch), + Self::Detached(commit) => write!(formatter, "detached at {commit}"), + Self::Unknown => formatter.write_str("unknown"), + } + } +} + +pub fn detect_project( + start: &Path, + root_override: Option<&Path>, +) -> Result, ProjectError> { + let search_start = resolve_start(start, root_override); + let Some((root, git_dir)) = find_git_root(&search_start)? else { + return Ok(None); + }; + let head = read_project_head(&git_dir)?; + + Ok(Some(ProjectInfo { + root, + git_dir, + head, + })) +} + +pub fn render_project_status(project: Option<&ProjectInfo>) -> String { + let Some(project) = project else { + return "Project\nstatus: not detected".into(); + }; + + format!( + "Project\nroot: {}\nbranch: {}\ngit_dir: {}", + project.root.display(), + project.branch_label(), + project.git_dir.display() + ) +} + +fn resolve_start(start: &Path, root_override: Option<&Path>) -> PathBuf { + match root_override { + Some(root) if root.is_absolute() => root.to_path_buf(), + Some(root) => start.join(root), + None => start.to_path_buf(), + } +} + +fn find_git_root(start: &Path) -> Result, ProjectError> { + let mut current = if start.is_file() { + start.parent().unwrap_or(start).to_path_buf() + } else { + start.to_path_buf() + }; + + loop { + let git_marker = current.join(".git"); + if git_marker.exists() { + let git_dir = resolve_git_dir(&git_marker)?; + if git_dir.join("HEAD").exists() { + return Ok(Some((current, git_dir))); + } + } + + if !current.pop() { + return Ok(None); + } + } +} + +fn resolve_git_dir(git_marker: &Path) -> Result { + if git_marker.is_dir() { + return Ok(git_marker.to_path_buf()); + } + + let contents = fs::read_to_string(git_marker).map_err(|error| ProjectError::Read { + path: git_marker.to_path_buf(), + error: error.to_string(), + })?; + let git_dir = contents + .trim() + .strip_prefix("gitdir:") + .map(str::trim) + .ok_or_else(|| ProjectError::InvalidGitFile(git_marker.to_path_buf()))?; + let git_dir = PathBuf::from(git_dir); + if git_dir.is_absolute() { + Ok(git_dir) + } else { + Ok(git_marker + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(git_dir)) + } +} + +fn read_project_head(git_dir: &Path) -> Result { + let head_path = git_dir.join("HEAD"); + let contents = fs::read_to_string(&head_path).map_err(|error| ProjectError::Read { + path: head_path, + error: error.to_string(), + })?; + let head = contents.trim(); + if head.is_empty() { + return Ok(ProjectHead::Unknown); + } + + if let Some(reference) = head.strip_prefix("ref:") { + let branch = reference + .trim() + .strip_prefix("refs/heads/") + .unwrap_or_else(|| reference.trim()); + if branch.is_empty() { + Ok(ProjectHead::Unknown) + } else { + Ok(ProjectHead::Branch(branch.to_string())) + } + } else { + Ok(ProjectHead::Detached(short_commit(head))) + } +} + +fn short_commit(value: &str) -> String { + value.chars().take(12).collect() +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum ProjectError { + #[error("failed to read project metadata at {path}: {error}")] + Read { path: PathBuf, error: String }, + #[error("invalid git file at {0}")] + InvalidGitFile(PathBuf), +} + +#[cfg(test)] +mod tests { + use std::fs; + + use super::*; + + #[test] + fn detects_nested_git_repository_root_and_branch() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let outer = tempdir.path().join("outer"); + let nested = outer.join("nested"); + let source = nested.join("src"); + write_head(&outer, "main"); + write_head(&nested, "feature/branch"); + fs::create_dir_all(&source).expect("source dir"); + + let project = detect_project(&source, None) + .expect("project detection") + .expect("project"); + + assert_eq!(project.root, nested); + assert_eq!( + project.head, + ProjectHead::Branch("feature/branch".to_string()) + ); + } + + #[test] + fn override_root_selects_requested_repository() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let outer = tempdir.path().join("outer"); + let nested = outer.join("nested"); + let source = nested.join("src"); + write_head(&outer, "main"); + write_head(&nested, "feature"); + fs::create_dir_all(&source).expect("source dir"); + + let project = detect_project(&source, Some(&outer)) + .expect("project detection") + .expect("project"); + + assert_eq!(project.root, outer); + assert_eq!(project.head, ProjectHead::Branch("main".to_string())); + } + + #[test] + fn detached_head_is_rendered_gracefully() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let repo = tempdir.path().join("repo"); + fs::create_dir_all(repo.join(".git")).expect("git dir"); + fs::write( + repo.join(".git").join("HEAD"), + "d34db33fd34db33fd34db33fd34db33fd34db33f\n", + ) + .expect("head"); + + let project = detect_project(&repo, None) + .expect("project detection") + .expect("project"); + + assert_eq!( + project.head, + ProjectHead::Detached("d34db33fd34d".to_string()) + ); + assert!(render_project_status(Some(&project)).contains("detached at d34db33fd34d")); + } + + #[test] + fn returns_none_without_git_repository() { + let tempdir = tempfile::tempdir().expect("tempdir"); + + let project = detect_project(tempdir.path(), None).expect("project detection"); + + assert_eq!(project, None); + } + + fn write_head(root: &Path, branch: &str) { + let git_dir = root.join(".git"); + fs::create_dir_all(&git_dir).expect("git dir"); + fs::write(git_dir.join("HEAD"), format!("ref: refs/heads/{branch}\n")).expect("head"); + } +} diff --git a/src/repl.rs b/src/repl.rs index f9fef4c..bd93249 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -45,6 +45,7 @@ impl Repl { } if input.starts_with("/context") + || input == "/project" || input.starts_with("/stance") || input.starts_with("/copy ") || input.starts_with("/explain ")