From ef0b870890f66e5a4927ee57192424771b92dac1 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 7 Apr 2026 15:15:42 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20b5-git-aware=20=E2=80=94=20batch=205=20?= =?UTF-8?q?wave=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust/crates/runtime/src/conversation.rs | 2 +- rust/crates/runtime/src/git_context.rs | 324 ++++++++++++++++++++++++ rust/crates/runtime/src/lib.rs | 2 + rust/crates/runtime/src/prompt.rs | 14 +- 4 files changed, 338 insertions(+), 4 deletions(-) create mode 100644 rust/crates/runtime/src/git_context.rs diff --git a/rust/crates/runtime/src/conversation.rs b/rust/crates/runtime/src/conversation.rs index b687687..99cce82 100644 --- a/rust/crates/runtime/src/conversation.rs +++ b/rust/crates/runtime/src/conversation.rs @@ -894,7 +894,7 @@ mod tests { current_date: "2026-03-31".to_string(), git_status: None, git_diff: None, - git_recent_commits: None, + git_context: None, instruction_files: Vec::new(), }) .with_os("linux", "6.8") diff --git a/rust/crates/runtime/src/git_context.rs b/rust/crates/runtime/src/git_context.rs new file mode 100644 index 0000000..5703ebe --- /dev/null +++ b/rust/crates/runtime/src/git_context.rs @@ -0,0 +1,324 @@ +use std::path::Path; +use std::process::Command; + +/// A single git commit entry from the log. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitCommitEntry { + pub hash: String, + pub subject: String, +} + +/// Git-aware context gathered at startup for injection into the system prompt. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitContext { + pub branch: Option, + pub recent_commits: Vec, + pub staged_files: Vec, +} + +const MAX_RECENT_COMMITS: usize = 5; + +impl GitContext { + /// Detect the git context from the given working directory. + /// + /// Returns `None` when the directory is not inside a git repository. + #[must_use] + pub fn detect(cwd: &Path) -> Option { + // Quick gate: is this a git repo at all? + let rev_parse = Command::new("git") + .args(["rev-parse", "--is-inside-work-tree"]) + .current_dir(cwd) + .output() + .ok()?; + if !rev_parse.status.success() { + return None; + } + + Some(Self { + branch: read_branch(cwd), + recent_commits: read_recent_commits(cwd), + staged_files: read_staged_files(cwd), + }) + } + + /// Render a human-readable summary suitable for system-prompt injection. + #[must_use] + pub fn render(&self) -> String { + let mut lines = Vec::new(); + + if let Some(branch) = &self.branch { + lines.push(format!("Git branch: {branch}")); + } + + if !self.recent_commits.is_empty() { + lines.push(String::new()); + lines.push("Recent commits:".to_string()); + for entry in &self.recent_commits { + lines.push(format!(" {} {}", entry.hash, entry.subject)); + } + } + + if !self.staged_files.is_empty() { + lines.push(String::new()); + lines.push("Staged files:".to_string()); + for file in &self.staged_files { + lines.push(format!(" {file}")); + } + } + + lines.join("\n") + } +} + +fn read_branch(cwd: &Path) -> Option { + let output = Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(cwd) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let branch = String::from_utf8(output.stdout).ok()?; + let trimmed = branch.trim(); + if trimmed.is_empty() || trimmed == "HEAD" { + None + } else { + Some(trimmed.to_string()) + } +} + +fn read_recent_commits(cwd: &Path) -> Vec { + let output = Command::new("git") + .args([ + "--no-optional-locks", + "log", + "--oneline", + "-n", + &MAX_RECENT_COMMITS.to_string(), + "--no-decorate", + ]) + .current_dir(cwd) + .output() + .ok(); + let Some(output) = output else { + return Vec::new(); + }; + if !output.status.success() { + return Vec::new(); + } + let stdout = String::from_utf8(output.stdout).unwrap_or_default(); + stdout + .lines() + .filter_map(|line| { + let line = line.trim(); + if line.is_empty() { + return None; + } + let (hash, subject) = line.split_once(' ')?; + Some(GitCommitEntry { + hash: hash.to_string(), + subject: subject.to_string(), + }) + }) + .collect() +} + +fn read_staged_files(cwd: &Path) -> Vec { + let output = Command::new("git") + .args(["--no-optional-locks", "diff", "--cached", "--name-only"]) + .current_dir(cwd) + .output() + .ok(); + let Some(output) = output else { + return Vec::new(); + }; + if !output.status.success() { + return Vec::new(); + } + let stdout = String::from_utf8(output.stdout).unwrap_or_default(); + stdout + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| line.trim().to_string()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::{GitCommitEntry, GitContext}; + use std::fs; + use std::process::Command; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_dir(label: &str) -> std::path::PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time should be after epoch") + .as_nanos(); + std::env::temp_dir().join(format!("runtime-git-context-{label}-{nanos}")) + } + + fn env_lock() -> std::sync::MutexGuard<'static, ()> { + crate::test_env_lock() + } + + fn ensure_valid_cwd() { + if std::env::current_dir().is_err() { + std::env::set_current_dir(env!("CARGO_MANIFEST_DIR")) + .expect("test cwd should be recoverable"); + } + } + + #[test] + fn returns_none_for_non_git_directory() { + // given + let _guard = env_lock(); + ensure_valid_cwd(); + let root = temp_dir("non-git"); + fs::create_dir_all(&root).expect("create dir"); + + // when + let context = GitContext::detect(&root); + + // then + assert!(context.is_none()); + fs::remove_dir_all(root).expect("cleanup"); + } + + #[test] + fn detects_branch_name_and_commits() { + // given + let _guard = env_lock(); + ensure_valid_cwd(); + let root = temp_dir("branch-commits"); + fs::create_dir_all(&root).expect("create dir"); + git(&root, &["init", "--quiet", "--initial-branch=main"]); + git(&root, &["config", "user.email", "tests@example.com"]); + git(&root, &["config", "user.name", "Git Context Tests"]); + fs::write(root.join("a.txt"), "a\n").expect("write a"); + git(&root, &["add", "a.txt"]); + git(&root, &["commit", "-m", "first commit", "--quiet"]); + fs::write(root.join("b.txt"), "b\n").expect("write b"); + git(&root, &["add", "b.txt"]); + git(&root, &["commit", "-m", "second commit", "--quiet"]); + + // when + let context = GitContext::detect(&root).expect("should detect git repo"); + + // then + assert_eq!(context.branch.as_deref(), Some("main")); + assert_eq!(context.recent_commits.len(), 2); + assert_eq!(context.recent_commits[0].subject, "second commit"); + assert_eq!(context.recent_commits[1].subject, "first commit"); + assert!(context.staged_files.is_empty()); + fs::remove_dir_all(root).expect("cleanup"); + } + + #[test] + fn detects_staged_files() { + // given + let _guard = env_lock(); + ensure_valid_cwd(); + let root = temp_dir("staged"); + fs::create_dir_all(&root).expect("create dir"); + git(&root, &["init", "--quiet", "--initial-branch=main"]); + git(&root, &["config", "user.email", "tests@example.com"]); + git(&root, &["config", "user.name", "Git Context Tests"]); + fs::write(root.join("init.txt"), "init\n").expect("write init"); + git(&root, &["add", "init.txt"]); + git(&root, &["commit", "-m", "initial", "--quiet"]); + fs::write(root.join("staged.txt"), "staged\n").expect("write staged"); + git(&root, &["add", "staged.txt"]); + + // when + let context = GitContext::detect(&root).expect("should detect git repo"); + + // then + assert_eq!(context.staged_files, vec!["staged.txt"]); + fs::remove_dir_all(root).expect("cleanup"); + } + + #[test] + fn render_formats_all_sections() { + // given + let context = GitContext { + branch: Some("feat/test".to_string()), + recent_commits: vec![ + GitCommitEntry { + hash: "abc1234".to_string(), + subject: "add feature".to_string(), + }, + GitCommitEntry { + hash: "def5678".to_string(), + subject: "fix bug".to_string(), + }, + ], + staged_files: vec!["src/main.rs".to_string()], + }; + + // when + let rendered = context.render(); + + // then + assert!(rendered.contains("Git branch: feat/test")); + assert!(rendered.contains("abc1234 add feature")); + assert!(rendered.contains("def5678 fix bug")); + assert!(rendered.contains("src/main.rs")); + } + + #[test] + fn render_omits_empty_sections() { + // given + let context = GitContext { + branch: Some("main".to_string()), + recent_commits: Vec::new(), + staged_files: Vec::new(), + }; + + // when + let rendered = context.render(); + + // then + assert!(rendered.contains("Git branch: main")); + assert!(!rendered.contains("Recent commits:")); + assert!(!rendered.contains("Staged files:")); + } + + #[test] + fn limits_to_five_recent_commits() { + // given + let _guard = env_lock(); + ensure_valid_cwd(); + let root = temp_dir("five-commits"); + fs::create_dir_all(&root).expect("create dir"); + git(&root, &["init", "--quiet", "--initial-branch=main"]); + git(&root, &["config", "user.email", "tests@example.com"]); + git(&root, &["config", "user.name", "Git Context Tests"]); + for i in 1..=8 { + let name = format!("file{i}.txt"); + fs::write(root.join(&name), format!("{i}\n")).expect("write file"); + git(&root, &["add", &name]); + git(&root, &["commit", "-m", &format!("commit {i}"), "--quiet"]); + } + + // when + let context = GitContext::detect(&root).expect("should detect git repo"); + + // then + assert_eq!(context.recent_commits.len(), 5); + assert_eq!(context.recent_commits[0].subject, "commit 8"); + assert_eq!(context.recent_commits[4].subject, "commit 4"); + fs::remove_dir_all(root).expect("cleanup"); + } + + fn git(cwd: &std::path::Path, args: &[&str]) { + let status = Command::new("git") + .args(args) + .current_dir(cwd) + .output() + .unwrap_or_else(|_| panic!("git {args:?} should run")) + .status; + assert!(status.success(), "git {args:?} failed"); + } +} diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index e193039..5678af8 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -13,6 +13,7 @@ mod config; pub mod config_validate; mod conversation; mod file_ops; +mod git_context; pub mod green_contract; mod hooks; mod json; @@ -71,6 +72,7 @@ pub use conversation::{ ConversationRuntime, PromptCacheEvent, RuntimeError, StaticToolExecutor, ToolError, ToolExecutor, TurnSummary, }; +pub use git_context::{GitCommitEntry, GitContext}; pub use file_ops::{ edit_file, glob_search, grep_search, read_file, write_file, EditFileOutput, GlobSearchOutput, GrepSearchInput, GrepSearchOutput, ReadFileOutput, StructuredPatchHunk, TextFilePayload, diff --git a/rust/crates/runtime/src/prompt.rs b/rust/crates/runtime/src/prompt.rs index 85b7e38..3c0bda2 100644 --- a/rust/crates/runtime/src/prompt.rs +++ b/rust/crates/runtime/src/prompt.rs @@ -4,6 +4,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use crate::config::{ConfigError, ConfigLoader, RuntimeConfig}; +use crate::git_context::GitContext; /// Errors raised while assembling the final system prompt. #[derive(Debug)] @@ -56,7 +57,7 @@ pub struct ProjectContext { pub current_date: String, pub git_status: Option, pub git_diff: Option, - pub git_recent_commits: Option, + pub git_context: Option, pub instruction_files: Vec, } @@ -72,7 +73,7 @@ impl ProjectContext { current_date: current_date.into(), git_status: None, git_diff: None, - git_recent_commits: None, + git_context: None, instruction_files, }) } @@ -84,7 +85,7 @@ impl ProjectContext { let mut context = Self::discover(cwd, current_date)?; context.git_status = read_git_status(&context.cwd); context.git_diff = read_git_diff(&context.cwd); - context.git_recent_commits = read_git_recent_commits(&context.cwd); + context.git_context = GitContext::detect(&context.cwd); Ok(context) } } @@ -337,6 +338,13 @@ fn render_project_context(project_context: &ProjectContext) -> String { lines.push("Git diff snapshot:".to_string()); lines.push(diff.clone()); } + if let Some(git_context) = &project_context.git_context { + let rendered = git_context.render(); + if !rendered.is_empty() { + lines.push(String::new()); + lines.push(rendered); + } + } lines.join("\n") }