feat: git-aware context tools

Adds git-aware context tools for improved repository understanding.
This commit is contained in:
TheArchitectit
2026-05-24 21:24:37 -05:00
committed by GitHub
parent cef45efc16
commit 0975252976
3 changed files with 436 additions and 0 deletions

View File

@@ -1477,6 +1477,10 @@ pub fn validate_slash_command_input(
"theme" => SlashCommand::Theme { name: remainder },
"voice" => SlashCommand::Voice { mode: remainder },
"usage" => SlashCommand::Usage { scope: remainder },
<<<<<<< HEAD
=======
"setup" => SlashCommand::Setup,
>>>>>>> 2f6a225 (fix: make id field optional in OpenAI response parsing)
"rename" => SlashCommand::Rename { name: remainder },
"copy" => SlashCommand::Copy { target: remainder },
"hooks" => SlashCommand::Hooks { args: remainder },

View File

@@ -0,0 +1,157 @@
# Git-Aware Context Tools
Adds five native git tools to claw-code that provide structured, read-only access to repository state. These replace ad-hoc `git` commands via bash with purpose-built tool definitions the model can discover and invoke directly.
## Tools
### GitStatus
Show the working tree status (branch, staged, unstaged, untracked). Equivalent to `git status --short --branch`.
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `short` | boolean | no | `true` | Use `--short --branch` format for concise output |
**Example input:**
```json
{}
```
**Example output:**
```json
{
"output": "## feat/git-aware-tools...upstream/main [ahead 1]\nM rust/crates/tools/src/lib.rs"
}
```
---
### GitDiff
Show changes between commits, the index, and the working tree. Supports staged changes, specific paths, commit ranges, and comparing two commits.
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `staged` | boolean | no | `false` | Show staged changes (`git diff --cached`) |
| `commit` | string | no | — | Commit hash, tag, or branch to diff against |
| `commit2` | string | no | — | Second commit for range diff (`commit...commit2`) |
| `path` | string | no | — | File path to restrict the diff to |
**Example inputs:**
```json
{}
```
```json
{ "staged": true }
```
```json
{ "commit": "HEAD~3", "path": "rust/crates/tools/src/lib.rs" }
```
```json
{ "commit": "main", "commit2": "feat/git-aware-tools" }
```
---
### GitLog
Show commit history. Supports limiting count, filtering by author/date/path, and oneline format.
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `count` | integer | no | `20` | Maximum number of commits to return |
| `oneline` | boolean | no | `false` | Use `--oneline` format (hash + subject only) |
| `author` | string | no | — | Filter commits by author pattern |
| `since` | string | no | — | Filter commits since date (e.g. `"2024-01-01"` or `"2.weeks"`) |
| `until` | string | no | — | Filter commits until date |
| `path` | string | no | — | File or directory path to filter commits by |
**Example inputs:**
```json
{ "count": 5, "oneline": true }
```
```json
{ "author": "alice", "since": "1.week", "path": "src/main.rs" }
```
---
### GitShow
Show a commit, tag, or tree object with its diff. Supports showing a specific file at a commit and stat-only mode.
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `commit` | string | **yes** | — | Commit hash, tag, or branch ref to show |
| `path` | string | no | — | Show only this file at the given commit (`commit:path` syntax) |
| `stat` | boolean | no | `false` | Show diffstat summary instead of full diff |
**Example inputs:**
```json
{ "commit": "HEAD" }
```
```json
{ "commit": "abc1234", "stat": true }
```
```json
{ "commit": "main", "path": "src/lib.rs" }
```
---
### GitBlame
Show what revision and author last modified each line of a file. Supports line range filtering.
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `path` | string | **yes** | — | File path to blame |
| `start_line` | integer | no | — | Start of line range (1-based) |
| `end_line` | integer | no | — | End of line range (1-based) |
**Example inputs:**
```json
{ "path": "src/main.rs" }
```
```json
{ "path": "src/main.rs", "start_line": 100, "end_line": 150 }
```
---
## Architecture
All five tools follow the same pattern:
1. **ToolSpec** — Defines the tool name, description, JSON input schema, and `PermissionMode::ReadOnly`
2. **Input struct** — Derives `Deserialize` with `#[serde(default)]` on optional fields
3. **Run function** — Builds git arguments, calls `git_stdout()`, wraps result in JSON via `to_pretty_json()`
4. **Dispatch** — Matched in `execute_tool_with_enforcer()` like all other tools
The existing `git_stdout(args: &[&str]) -> Option<String>` helper (at `tools/src/lib.rs`) handles running the `git` subprocess and returning trimmed stdout. Git tools simply construct the right arguments and delegate to this helper.
## Why native git tools?
Before this PR, the model had to use the `bash` tool for git operations, which has several drawbacks:
- **No structured output** — Bash returns raw text that the model must parse
- **Over-permissioned** — Bash requires `DangerFullAccess` even for read-only git commands
- **No discoverability** — The model can't search for git-capable tools via `ToolSearch`
- **Inconsistent** — Each invocation may use different flags or formatting
With native git tools:
- All five are `ReadOnly` — safe in restricted permission modes
- Structured JSON output — consistent, parseable results
- Discoverable via `ToolSearch` with keywords like "git", "diff", "blame"
- Model-friendly descriptions explain when to use each tool vs bash
## Testing
```bash
cd rust
cargo build --release
cargo test -p tools
```
The 3 pre-existing test failures (agent_fake_runner, agent_persists_handoff, worker_create_merges_config) are unrelated to this change — they fail due to local settings.json incompatibilities.

View File

@@ -1179,6 +1179,80 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
}),
required_permission: PermissionMode::DangerFullAccess,
},
ToolSpec {
name: "GitStatus",
description: "Show the working tree status (branch, staged, unstaged, untracked). Equivalent to 'git status --short --branch'. Use this instead of running git status via bash to get structured, parseable output.",
input_schema: json!({
"type": "object",
"properties": {
"short": { "type": "boolean" }
},
"additionalProperties": false
}),
required_permission: PermissionMode::ReadOnly,
},
ToolSpec {
name: "GitDiff",
description: "Show changes between commits, the index, and the working tree. Supports staged changes ('git diff --cached'), specific paths, commit ranges, and comparing two commits. Use this instead of running git diff via bash to get structured output.",
input_schema: json!({
"type": "object",
"properties": {
"path": { "type": "string" },
"staged": { "type": "boolean" },
"commit": { "type": "string" },
"commit2": { "type": "string" }
},
"additionalProperties": false
}),
required_permission: PermissionMode::ReadOnly,
},
ToolSpec {
name: "GitLog",
description: "Show commit history. Supports limiting count, filtering by author/date/path, and oneline format. Defaults to the last 20 commits. Use this instead of running git log via bash to get structured output.",
input_schema: json!({
"type": "object",
"properties": {
"path": { "type": "string" },
"count": { "type": "integer", "minimum": 1 },
"oneline": { "type": "boolean" },
"author": { "type": "string" },
"since": { "type": "string" },
"until": { "type": "string" }
},
"additionalProperties": false
}),
required_permission: PermissionMode::ReadOnly,
},
ToolSpec {
name: "GitShow",
description: "Show a commit, tag, or tree object with its diff. Supports showing a specific file at a commit (commit:path) and stat-only mode. Use this instead of running git show via bash to get structured output.",
input_schema: json!({
"type": "object",
"properties": {
"commit": { "type": "string" },
"path": { "type": "string" },
"stat": { "type": "boolean" }
},
"required": ["commit"],
"additionalProperties": false
}),
required_permission: PermissionMode::ReadOnly,
},
ToolSpec {
name: "GitBlame",
description: "Show what revision and author last modified each line of a file. Supports line range filtering (start_line, end_line). Use this instead of running git blame via bash to get structured output.",
input_schema: json!({
"type": "object",
"properties": {
"path": { "type": "string" },
"start_line": { "type": "integer", "minimum": 1 },
"end_line": { "type": "integer", "minimum": 1 }
},
"required": ["path"],
"additionalProperties": false
}),
required_permission: PermissionMode::ReadOnly,
},
]
}
@@ -1309,6 +1383,11 @@ fn execute_tool_with_enforcer(
"TestingPermission" => {
from_value::<TestingPermissionInput>(input).and_then(run_testing_permission)
}
"GitStatus" => from_value::<GitStatusInput>(input).and_then(run_git_status),
"GitDiff" => from_value::<GitDiffInput>(input).and_then(run_git_diff),
"GitLog" => from_value::<GitLogInput>(input).and_then(run_git_log),
"GitShow" => from_value::<GitShowInput>(input).and_then(run_git_show),
"GitBlame" => from_value::<GitBlameInput>(input).and_then(run_git_blame),
_ => Err(format!("unsupported tool: {name}")),
}
}
@@ -1844,6 +1923,123 @@ fn run_testing_permission(input: TestingPermissionInput) -> Result<String, Strin
"message": "Testing permission tool stub"
}))
}
#[allow(clippy::needless_pass_by_value)]
/// Execute `git status --short --branch` and return structured JSON output.
/// Falls back to full `git status` if `short` is explicitly set to false.
fn run_git_status(input: GitStatusInput) -> Result<String, String> {
let mut args: Vec<&str> = vec!["status"];
if input.short.unwrap_or(true) {
args.push("--short");
args.push("--branch");
}
match git_stdout(&args) {
Some(output) => to_pretty_json(json!({
"output": output
})),
None => Err("git status failed. Ensure the current directory is inside a git repository.".to_string()),
}
}
#[allow(clippy::needless_pass_by_value)]
/// Execute `git diff` with optional --cached, commit, and path filters.
/// Returns the diff output wrapped in a JSON object.
fn run_git_diff(input: GitDiffInput) -> Result<String, String> {
let mut args: Vec<String> = vec!["diff".to_string()];
if input.staged.unwrap_or(false) {
args.push("--cached".to_string());
}
if let Some(ref commit) = input.commit {
if let Some(ref commit2) = input.commit2 {
args.push(format!("{commit}...{commit2}"));
} else {
args.push(commit.clone());
}
}
if let Some(ref path) = input.path {
args.push("--".to_string());
args.push(path.clone());
}
let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
match git_stdout(&arg_refs) {
Some(output) => to_pretty_json(json!({
"output": output
})),
None => Err("git diff failed. Ensure the current directory is inside a git repository.".to_string()),
}
}
#[allow(clippy::needless_pass_by_value)]
/// Execute `git log` with count, author, date, and path filters.
/// Defaults to the last 20 commits.
fn run_git_log(input: GitLogInput) -> Result<String, String> {
let mut args: Vec<String> = vec!["log".to_string()];
let count = input.count.unwrap_or(20);
args.push(format!("-n{count}"));
if input.oneline.unwrap_or(false) {
args.push("--oneline".to_string());
}
if let Some(ref author) = input.author {
args.push(format!("--author={author}"));
}
if let Some(ref since) = input.since {
args.push(format!("--since={since}"));
}
if let Some(ref until) = input.until {
args.push(format!("--until={until}"));
}
if let Some(ref path) = input.path {
args.push("--".to_string());
args.push(path.clone());
}
let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
match git_stdout(&arg_refs) {
Some(output) => to_pretty_json(json!({
"output": output
})),
None => Err("git log failed. Ensure the current directory is inside a git repository.".to_string()),
}
}
#[allow(clippy::needless_pass_by_value)]
/// Execute `git show` for a given commit, optionally with --stat or a file path.
/// Uses the `commit:path` syntax when a path is specified.
fn run_git_show(input: GitShowInput) -> Result<String, String> {
let mut args: Vec<String> = vec!["show".to_string()];
if input.stat.unwrap_or(false) {
args.push("--stat".to_string());
}
if let Some(ref path) = input.path {
args.push(format!("{}:{}", input.commit, path));
} else {
args.push(input.commit.clone());
}
let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
match git_stdout(&arg_refs) {
Some(output) => to_pretty_json(json!({
"output": output
})),
None => Err(format!("git show {} failed. Ensure the commit exists.", input.commit)),
}
}
#[allow(clippy::needless_pass_by_value)]
/// Execute `git blame` on a file, optionally restricted to a line range.
fn run_git_blame(input: GitBlameInput) -> Result<String, String> {
let mut args: Vec<String> = vec!["blame".to_string()];
if let (Some(start), Some(end)) = (input.start_line, input.end_line) {
args.push(format!("-L{start},{end}"));
}
args.push(input.path.clone());
let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
match git_stdout(&arg_refs) {
Some(output) => to_pretty_json(json!({
"output": output
})),
None => Err(format!("git blame {} failed. Ensure the file exists and the directory is inside a git repository.", input.path)),
}
}
fn from_value<T: for<'de> Deserialize<'de>>(input: &Value) -> Result<T, String> {
serde_json::from_value(input.clone()).map_err(|error| error.to_string())
}
@@ -2696,6 +2892,85 @@ struct TestingPermissionInput {
action: String,
}
/// Input for the GitStatus tool: shows working tree status.
/// Defaults to --short --branch mode for concise, parseable output.
#[derive(Debug, Deserialize)]
struct GitStatusInput {
#[serde(default)]
/// If true, use --short --branch format. Defaults to true.
short: Option<bool>,
}
/// Input for the GitDiff tool: shows changes between commits, index, and working tree.
/// All fields are optional - calling with no options is equivalent to `git diff`.
#[derive(Debug, Deserialize)]
struct GitDiffInput {
#[serde(default)]
/// File path to diff. Prepends `--` before the path.
path: Option<String>,
#[serde(default)]
/// If true, show staged changes (`git diff --cached`).
staged: Option<bool>,
#[serde(default)]
/// A commit hash, tag, or branch to diff against.
commit: Option<String>,
#[serde(default)]
/// A second commit for range diffs (commit...commit2).
commit2: Option<String>,
}
/// Input for the GitLog tool: shows commit history.
/// Defaults to the last 20 commits in full format.
#[derive(Debug, Deserialize)]
struct GitLogInput {
#[serde(default)]
/// File or directory path to filter commits by.
path: Option<String>,
#[serde(default)]
/// Maximum number of commits to return. Defaults to 20.
count: Option<usize>,
#[serde(default)]
/// If true, use --oneline format (hash + subject only).
oneline: Option<bool>,
#[serde(default)]
/// Filter commits by author pattern.
author: Option<String>,
#[serde(default)]
/// Filter commits since date (e.g. "2024-01-01" or "2.weeks").
since: Option<String>,
#[serde(default)]
/// Filter commits until date.
until: Option<String>,
}
/// Input for the GitShow tool: shows a commit, tag, or tree object.
#[derive(Debug, Deserialize)]
struct GitShowInput {
/// Commit hash, tag, or branch ref to show. Required.
commit: String,
#[serde(default)]
/// If set, show only this file at the given commit (commit:path syntax).
path: Option<String>,
#[serde(default)]
/// If true, show diffstat summary instead of full diff.
stat: Option<bool>,
}
/// Input for the GitBlame tool: shows per-line author/revision info for a file.
#[derive(Debug, Deserialize)]
struct GitBlameInput {
/// File path to blame. Required.
path: String,
#[serde(rename = "start_line")]
#[serde(default)]
/// Start of line range (1-based). Only used if end_line is also set.
start_line: Option<usize>,
#[serde(rename = "end_line")]
#[serde(default)]
/// End of line range (1-based). Only used if start_line is also set.
end_line: Option<usize>,
}
#[derive(Debug, Serialize)]
struct WebFetchOutput {
bytes: usize,