fix(tools): commit_plan is propose-only (#3999 rework)

Maintainer rework of #5870 (fixes #3999) on top of @goransh-walia's
commit, per review:

- The atomic-commit planner (Git action `commit_plan`, GitCommitPlanTool)
  is propose-only: it returns a dependency-ordered split plan with
  messages and writes nothing. The old mutation paths — `git add -N` on
  untracked files, `git apply --cached` plus a spawned `git commit` —
  are deleted. Untracked files are discovered with
  `ls-files --others` and read from disk for symbol analysis; landing
  commits stays on the ordinary `git add` / `git commit` shell path,
  where the approval gate already applies.
- The non-existent `ApprovalRequirement::Manual` override is gone. The
  tool declares ReadOnly + Sandboxable and derives ApprovalRequirement::Auto
  explicitly, matching its git_status/git_diff siblings.
- Every alias consumer classes commit_plan read-only: the execution
  envelope (classify_call -> Bounded, is_read_only_for), the approval
  policy (Safe / Benign), the hooks tool_category gate ("safe"), the
  tool card family (Read), and history activity (File).
- Removed the now-dead Hunk old_range/new_range fields and parse_range
  (nothing rebuilds a patch from them anymore); a group of a source file
  plus its test scopes its commit message by the shared stem, not the
  directory.
- Tests: grouping, dependency ordering, and cycle rejection kept; new
  propose-only proof (index, HEAD, and the untracked list untouched),
  staged-changes warning, clean-tree report, and envelope classification.

Gates, final tree (rebased onto origin/main f9746854c):
- cargo fmt --all: clean
- cargo clippy --workspace --all-targets --all-features --locked
  (-D warnings, allowing uninlined_format_args/too_many_arguments/
  unnecessary_map_or): exit 0
- cargo test -p codewhale-tui --lib --locked --
  tools::git git_tool commit_plan canonical_action:
  49 passed, 0 failed, 0 ignored
- cargo test -p codewhale-tui --lib --locked:
  11880 passed, 0 failed, 13 ignored

Note on the shared target dir: two earlier full-suite runs on the
pre-rebase tree reported 2143 failures and one intermediate run 1
failure; all were traced to the shared CARGO_TARGET_DIR serving this
worktree codewhale-config / test-binary artifacts built from other
in-flight worktrees (e.g. a 49-variant ProviderKind rlib against this
tree's 48-entry FROM_KIND_LOOKUP). The counts above are from a run on
this tree's own binary.

Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
CodeWhale Bot committed 2026-09-06 19:46:49 -07:00
1 parent 0afd84e8eb
commit f94d4aa950
9 files changed
+1010 -589

No files matched your search

+11
View File
@@ -117,6 +117,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
per-World session container before each command — full tree first, then
only changes and deletions — so remote builds and tests run on the files
just edited locally and their outputs persist across commands.
- `Git` grows a `commit_plan` action: a propose-only planner that splits the
working tree into ordered atomic commits (#3999). It groups whole files —
lock files ride with their manifest, tests ride with the source they name —
orders the groups so a commit that defines a symbol lands before the commit
that uses it, and refuses the whole plan when that dependency graph has a
cycle. It reads `git diff HEAD` plus the untracked-file list and writes
nothing: no `git add -N`, no `git apply --cached`, no `git commit`, so
staging and committing stay with the ordinary `git add` / `git commit` shell
path where the approval gate already applies. Thanks
[@goransh-walia](https://github.com/goransh-walia) for the original
implementation (PR #5870, fixes #3999).
## [0.9.12] - 2026-09-03
+11
View File
@@ -117,6 +117,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
per-World session container before each command — full tree first, then
only changes and deletions — so remote builds and tests run on the files
just edited locally and their outputs persist across commands.
- `Git` grows a `commit_plan` action: a propose-only planner that splits the
working tree into ordered atomic commits (#3999). It groups whole files —
lock files ride with their manifest, tests ride with the source they name —
orders the groups so a commit that defines a symbol lands before the commit
that uses it, and refuses the whole plan when that dependency graph has a
cycle. It reads `git diff HEAD` plus the untracked-file list and writes
nothing: no `git add -N`, no `git apply --cached`, no `git commit`, so
staging and committing stay with the ordinary `git add` / `git commit` shell
path where the approval gate already applies. Thanks
[@goransh-walia](https://github.com/goransh-walia) for the original
implementation (PR #5870, fixes #3999).
## [0.9.12] - 2026-09-03
+5 -1
View File
@@ -2517,7 +2517,7 @@ fn tool_category_for(tool_name: &str, tool_args: Option<&str>) -> &'static str {
"Git" | "git" => match action.as_deref() {
// Every shipped Git action is read-only today; classify by action
// anyway so adding a mutating one cannot silently inherit `safe`.
Some("status" | "diff" | "log" | "show" | "blame") => "safe",
Some("status" | "diff" | "log" | "show" | "blame" | "commit_plan") => "safe",
_ => "other",
},
// `Run` executes test/verifier commands — closer to shell than safe.
@@ -5821,6 +5821,10 @@ command = "echo project"
tool_category_for("Git", Some(r#"{"action":"log"}"#)),
"safe"
);
assert_eq!(
tool_category_for("Git", Some(r#"{"action":"commit_plan"}"#)),
"safe"
);
assert_eq!(tool_category_for("web.run", None), "other");
}
}
+1
View File
@@ -37,6 +37,7 @@ pub(crate) const CANONICAL_ACTION_ALIASES: &[(&str, &str, &str)] = &[
("Git", "log", "git_log"),
("Git", "show", "git_show"),
("Git", "blame", "git_blame"),
("Git", "commit_plan", "git_commit_plan"),
("Run", "tests", "run_tests"),
("Run", "verifiers", "run_verifiers"),
("Web", "search", "web_search"),
File diff suppressed because it is too large. Load diff
+23 -8
View File
@@ -1,14 +1,16 @@
//! Canonical action-based wrapper for git inspection tools.
//!
//! The model sees one tool: `Git` with an `action` parameter
//! (status | diff | log | show | blame). The per-action legacy execution
//! aliases were removed in v0.9.3.
//! (status | diff | log | show | blame | commit_plan). The per-action legacy
//! execution aliases were removed in v0.9.3. `commit_plan` (#3999) is the
//! propose-only atomic-commit planner: it returns a split plan and writes
//! nothing, so the family stays read-only end to end.
use async_trait::async_trait;
use serde_json::{Value, json};
use super::canonical_action::required_action;
use super::git::{GitDiffTool, GitStatusTool};
use super::git::{GitCommitPlanTool, GitDiffTool, GitStatusTool};
use super::git_history::{GitBlameTool, GitLogTool, GitShowTool};
use super::spec::{
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
@@ -27,7 +29,8 @@ impl GitTool {
}
}
const ACTIONS: &'static [&'static str] = &["status", "diff", "log", "show", "blame"];
const ACTIONS: &'static [&'static str] =
&["status", "diff", "log", "show", "blame", "commit_plan"];
fn required_action(&self, input: &Value) -> Result<String, ToolError> {
if let Some(forced) = self.forced_action {
@@ -60,7 +63,7 @@ impl ToolSpec for GitTool {
}
fn description(&self) -> &'static str {
"Inspect repository state and history with status, diff, log, show, or blame. All actions are read-only and parallel-safe."
"Inspect repository state and history with status, diff, log, show, or blame; commit_plan proposes an ordered atomic-commit split of the working tree. All actions are read-only and parallel-safe."
}
fn input_schema(&self) -> Value {
@@ -69,8 +72,8 @@ impl ToolSpec for GitTool {
"properties": {
"action": {
"type": "string",
"enum": ["status", "diff", "log", "show", "blame"],
"description": "Action to perform"
"enum": ["status", "diff", "log", "show", "blame", "commit_plan"],
"description": "Action to perform. commit_plan returns a proposed split of the working tree into dependency-ordered commits (rejecting cycles) and writes nothing; land each group with git add/commit."
},
"path": {
"type": "string",
@@ -159,6 +162,7 @@ impl ToolSpec for GitTool {
"log" => GitLogTool.execute(input, context).await,
"show" => GitShowTool.execute(input, context).await,
"blame" => GitBlameTool.execute(input, context).await,
"commit_plan" => GitCommitPlanTool.execute(input, context).await,
other => Err(ToolError::invalid_input(format!(
"Unknown Git action \"{other}\"; nothing was run. Pass one of: {}.",
Self::ACTIONS.join(", ")
@@ -196,11 +200,22 @@ mod tests {
let message = err(json!({"action": "commit"})).await;
assert!(message.contains("commit"), "{message}");
assert!(
message.contains("status, diff, log, show, blame"),
message.contains("status, diff, log, show, blame, commit_plan"),
"{message}"
);
}
/// `commit_plan` proposes and never writes, so the envelope must class it
/// with the other read-only Git actions rather than as a mutation (#3999).
#[test]
fn commit_plan_is_bounded_read_only_for_the_envelope() {
use crate::tools::execution_envelope::{CallClass, classify_call};
let tool = GitTool::new("Git");
let input = json!({"action": "commit_plan"});
assert!(tool.is_read_only_for(&input));
assert_eq!(classify_call("Git", &input, &tool), CallClass::Bounded);
}
#[test]
fn advertised_actions_match_the_actions_that_dispatch() {
let schema = GitTool::new("Git").input_schema();
+2
View File
@@ -120,6 +120,7 @@ pub fn get_tool_category(name: &str) -> ToolCategory {
| "git_log"
| "git_show"
| "git_blame"
| "git_commit_plan"
| "project"
| "diagnostics"
) || name.starts_with("read_")
@@ -386,6 +387,7 @@ mod tests {
("Git", "log", ToolCategory::Safe, RiskLevel::Benign),
("Git", "show", ToolCategory::Safe, RiskLevel::Benign),
("Git", "blame", ToolCategory::Safe, RiskLevel::Benign),
("Git", "commit_plan", ToolCategory::Safe, RiskLevel::Benign),
(
"Run",
"tests",
+2 -2
View File
@@ -187,7 +187,7 @@ fn classify_tool_name_activity(name: &str) -> ToolRunActivity {
let normalized = name.trim().to_ascii_lowercase();
match normalized.as_str() {
"read_file" | "list_dir" | "view_image" | "explore" | "git_status" | "git_diff"
| "git_log" | "git_show" | "git_blame" => ToolRunActivity::File,
| "git_log" | "git_show" | "git_blame" | "git_commit_plan" => ToolRunActivity::File,
"grep_files" | "file_search" | "web_search" | "fetch_url" | "registry_sync" => {
ToolRunActivity::Search
}
@@ -369,7 +369,7 @@ mod tests {
#[test]
fn normalized_git_and_run_actions_keep_truthful_activity_buckets() {
for action in ["status", "diff", "log", "show", "blame"] {
for action in ["status", "diff", "log", "show", "blame", "commit_plan"] {
let input = json!({"action": action});
assert_eq!(
classify_tool_name_activity(canonical_action_alias("Git", &input)),
+2 -1
View File
@@ -80,7 +80,7 @@ pub fn tool_family_for_title(title: &str) -> ToolFamily {
pub fn tool_family_for_name(name: &str) -> ToolFamily {
match name {
"read_file" | "list_dir" | "view_image" | "git_status" | "git_diff" | "git_log"
| "git_show" | "git_blame" => ToolFamily::Read,
| "git_show" | "git_blame" | "git_commit_plan" => ToolFamily::Read,
"edit_file" | "apply_patch" | "write_file" => ToolFamily::Patch,
"exec_shell"
| "exec_shell_wait"
@@ -403,6 +403,7 @@ mod tests {
("Git", "log", ToolFamily::Read),
("Git", "show", ToolFamily::Read),
("Git", "blame", ToolFamily::Read),
("Git", "commit_plan", ToolFamily::Read),
("Run", "tests", ToolFamily::Verify),
("Run", "verifiers", ToolFamily::Verify),
("Web", "search", ToolFamily::Find),