mirror of
https://github.com/khodges42/exoshell.git
synced 2026-06-14 18:08:37 +00:00
Git native project detection
This commit is contained in:
parent
e3ee5161a6
commit
94b283515c
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -100,7 +100,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "exoshell"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"futures-util",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "exoshell"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
edition = "2024"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
65
src/app.rs
65
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<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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
239
src/project.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ")
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user