Git native project detection

This commit is contained in:
K. Hodges 2026-06-09 03:54:32 -07:00
parent e3ee5161a6
commit 94b283515c
10 changed files with 359 additions and 7 deletions

2
Cargo.lock generated
View File

@ -100,7 +100,7 @@ dependencies = [
[[package]]
name = "exoshell"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"async-trait",
"futures-util",

View File

@ -1,6 +1,6 @@
[package]
name = "exoshell"
version = "0.4.0"
version = "0.5.0"
edition = "2024"
license = "GPL-3.0-or-later"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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<String, AppError> {
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 <id> inspect a context entry
/context enable|disable <id> 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<Stance>,
pub transcript_enabled: Option<bool>,
pub transcript_directory: Option<PathBuf>,
pub project_root: Option<PathBuf>,
pub context_notes: Vec<String>,
pub context_files: Vec<PathBuf>,
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 <path>] [--shell powershell|posix] [--stance operator|audit|teach|quiet] [--context-note <text>] [--context-file <path>] [--no-transcript] [--transcript-dir <path>] [--no-color]\n\nStarts the Exoshell interactive model chat. Exoshell suggests commands; it does not execute them."
"Usage: exoshell [--config <path>] [--shell powershell|posix] [--stance operator|audit|teach|quiet] [--project-root <path>] [--context-note <text>] [--context-file <path>] [--no-transcript] [--transcript-dir <path>] [--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,
},

View File

@ -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<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TranscriptConfig {
pub directory: PathBuf,
@ -71,6 +77,7 @@ impl ContextConfig {
struct RawConfig {
provider: Option<RawProviderConfig>,
router: Option<RawModelRouterConfig>,
project: Option<RawProjectConfig>,
shell: Option<RawShellConfig>,
interaction: Option<RawInteractionConfig>,
commands: Option<RawCommandConfig>,
@ -102,6 +109,11 @@ struct RawModelRouterRole {
description: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
struct RawProjectConfig {
root: Option<PathBuf>,
}
#[derive(Debug, Deserialize, Default)]
struct RawShellConfig {
family: Option<String>,
@ -148,6 +160,7 @@ impl Config {
fn from_raw(raw: RawConfig) -> Result<Self, ConfigError> {
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]

View File

@ -4,6 +4,7 @@ mod config;
pub mod context;
mod formatting;
mod keybindings;
mod project;
mod prompts;
mod providers;
mod repl;

239
src/project.rs Normal file
View File

@ -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<Option<ProjectInfo>, 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<Option<(PathBuf, PathBuf)>, 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<PathBuf, ProjectError> {
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<ProjectHead, ProjectError> {
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");
}
}

View File

@ -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 ")