mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
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:
1 parent
0afd84e8eb
commit
f94d4aa950
9 files changed
+1010
-589
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
+953
-577
File diff suppressed because it is too large.
Load diff
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user