diff --git a/.config/jp/tools/src/git.rs b/.config/jp/tools/src/git.rs index 4224d803..d99309a8 100644 --- a/.config/jp/tools/src/git.rs +++ b/.config/jp/tools/src/git.rs @@ -3,26 +3,34 @@ use crate::{ util::{ToolResult, unknown_tool}, }; +mod add_intent; mod commit; mod diff; mod list_patches; mod stage_patch; +mod stage_raw_patch; mod unstage; +use add_intent::git_add_intent; use commit::git_commit; use diff::git_diff; use list_patches::git_list_patches; use stage_patch::git_stage_patch; +use stage_raw_patch::git_stage_raw_patch; use unstage::git_unstage; pub async fn run(ctx: Context, t: Tool) -> ToolResult { match t.name.trim_start_matches("git_") { + "add_intent" => git_add_intent(&ctx.root, t.req("paths")?).await, + "commit" => git_commit(ctx.root, t.req("message")?).await, "stage_patch" => { git_stage_patch(ctx, &t.answers, t.req("path")?, t.req("patch_ids")?).await } + "stage_raw_patch" => git_stage_raw_patch(ctx, t.req("path")?, t.req("diff")?).await, + "list_patches" => git_list_patches(&ctx.root, t.req("files")?), "unstage" => git_unstage(&ctx.root, t.req("paths")?).await, diff --git a/.config/jp/tools/src/git/add_intent.rs b/.config/jp/tools/src/git/add_intent.rs new file mode 100644 index 00000000..be274919 --- /dev/null +++ b/.config/jp/tools/src/git/add_intent.rs @@ -0,0 +1,37 @@ +use camino::Utf8Path; + +use crate::util::{ + OneOrMany, ToolResult, + runner::{DuctProcessRunner, ProcessRunner}, +}; + +pub(crate) async fn git_add_intent(root: &Utf8Path, paths: OneOrMany) -> ToolResult { + git_add_intent_impl(root, &paths, &DuctProcessRunner) +} + +fn git_add_intent_impl( + root: &Utf8Path, + paths: &[String], + runner: &R, +) -> ToolResult { + for path in paths { + let output = runner.run("git", &["add", "--intent-to-add", "--", path], root)?; + + if !output.success() { + return Err( + format!("Failed to intent-to-add for '{}': {}", path, output.stderr).into(), + ); + } + } + + let count = paths.len(); + let noun = if count == 1 { "file" } else { "files" }; + Ok(format!( + "Marked {count} {noun} as intent-to-add. They are now visible to `git_list_patches`." + ) + .into()) +} + +#[cfg(test)] +#[path = "add_intent_tests.rs"] +mod tests; diff --git a/.config/jp/tools/src/git/add_intent_tests.rs b/.config/jp/tools/src/git/add_intent_tests.rs new file mode 100644 index 00000000..88d06b86 --- /dev/null +++ b/.config/jp/tools/src/git/add_intent_tests.rs @@ -0,0 +1,66 @@ +use camino_tempfile::tempdir; + +use super::*; +use crate::util::runner::MockProcessRunner; + +#[test] +fn test_add_intent_single_file() { + let dir = tempdir().unwrap(); + + let runner = MockProcessRunner::builder() + .expect("git") + .args(&["add", "--intent-to-add", "--", "new_file.rs"]) + .returns_success(""); + + let content = git_add_intent_impl(dir.path(), &["new_file.rs".to_string()], &runner) + .unwrap() + .into_content() + .unwrap(); + + assert_eq!( + content, + "Marked 1 file as intent-to-add. They are now visible to `git_list_patches`." + ); +} + +#[test] +fn test_add_intent_multiple_files() { + let dir = tempdir().unwrap(); + + let runner = MockProcessRunner::builder() + .expect("git") + .args(&["add", "--intent-to-add", "--", "a.rs"]) + .returns_success("") + .expect("git") + .args(&["add", "--intent-to-add", "--", "b.rs"]) + .returns_success(""); + + let content = git_add_intent_impl( + dir.path(), + &["a.rs".to_string(), "b.rs".to_string()], + &runner, + ) + .unwrap() + .into_content() + .unwrap(); + + assert_eq!( + content, + "Marked 2 files as intent-to-add. They are now visible to `git_list_patches`." + ); +} + +#[test] +fn test_add_intent_failure() { + let dir = tempdir().unwrap(); + + let runner = MockProcessRunner::builder() + .expect("git") + .args(&["add", "--intent-to-add", "--", "missing.rs"]) + .returns_error("fatal: pathspec 'missing.rs' did not match any files"); + + let err = git_add_intent_impl(dir.path(), &["missing.rs".to_string()], &runner).unwrap_err(); + + assert!(err.to_string().contains("Failed to intent-to-add")); + assert!(err.to_string().contains("missing.rs")); +} diff --git a/.config/jp/tools/src/git/stage_raw_patch.rs b/.config/jp/tools/src/git/stage_raw_patch.rs new file mode 100644 index 00000000..6f781342 --- /dev/null +++ b/.config/jp/tools/src/git/stage_raw_patch.rs @@ -0,0 +1,134 @@ +use jp_tool::Context; + +use crate::util::{ + ToolResult, + runner::{DuctProcessRunner, ProcessRunner}, +}; + +pub(crate) async fn git_stage_raw_patch(ctx: Context, path: String, diff: String) -> ToolResult { + git_stage_raw_patch_impl(&ctx, &path, &diff, &DuctProcessRunner) +} + +fn git_stage_raw_patch_impl( + ctx: &Context, + path: &str, + diff: &str, + runner: &R, +) -> ToolResult { + let patch = build_patch(path, diff); + + if ctx.action.is_format_arguments() { + return Ok(patch.into()); + } + + let output = runner.run_with_env_and_stdin( + "git", + &["apply", "--cached", "--unidiff-zero", "-"], + &ctx.root, + &[], + Some(&patch), + )?; + + if !output.success() { + return Err(format!("Failed to apply patch: {}", output.stderr).into()); + } + + Ok("Patch applied.".into()) +} + +fn build_patch(path: &str, diff: &str) -> String { + indoc::formatdoc! {" + diff --git a/{path} b/{path} + --- a/{path} + +++ b/{path} + {diff} + "} +} + +#[cfg(test)] +mod tests { + use camino_tempfile::tempdir; + use jp_tool::Action; + + use super::*; + use crate::util::runner::MockProcessRunner; + + fn ctx(root: &camino::Utf8Path) -> Context { + Context { + root: root.to_owned(), + action: Action::Run, + } + } + + #[test] + fn test_stage_raw_patch_success() { + let dir = tempdir().unwrap(); + let ctx = ctx(dir.path()); + + let diff = "@@ -5 +5 @@\n-old line\n+new line\n"; + + let runner = MockProcessRunner::builder() + .expect("git") + .args(&["apply", "--cached", "--unidiff-zero", "-"]) + .returns_success(""); + + let content = git_stage_raw_patch_impl(&ctx, "src/lib.rs", diff, &runner) + .unwrap() + .into_content() + .unwrap(); + + assert_eq!(content, "Patch applied."); + } + + #[test] + fn test_stage_raw_patch_apply_failure() { + let dir = tempdir().unwrap(); + let ctx = ctx(dir.path()); + + let runner = MockProcessRunner::builder() + .expect("git") + .args(&["apply", "--cached", "--unidiff-zero", "-"]) + .returns_error("error: patch failed"); + + let err = git_stage_raw_patch_impl(&ctx, "src/lib.rs", "@@ -1 +1 @@\n-a\n+b\n", &runner) + .unwrap_err(); + + assert!(err.to_string().contains("Failed to apply patch")); + } + + #[test] + fn test_stage_raw_patch_format_arguments() { + let dir = tempdir().unwrap(); + let ctx = Context { + root: dir.path().to_owned(), + action: Action::FormatArguments, + }; + + let runner = MockProcessRunner::never_called(); + + let content = + git_stage_raw_patch_impl(&ctx, "src/lib.rs", "@@ -5 +5 @@\n-old\n+new\n", &runner) + .unwrap() + .into_content() + .unwrap(); + + assert!(content.contains("diff --git a/src/lib.rs b/src/lib.rs")); + assert!(content.contains("--- a/src/lib.rs")); + assert!(content.contains("+++ b/src/lib.rs")); + assert!(content.contains("@@ -5 +5 @@")); + } + + #[test] + fn test_build_patch() { + let patch = build_patch("src/lib.rs", "@@ -1 +1 @@\n-old\n+new"); + + assert_eq!(patch, indoc::indoc! {" + diff --git a/src/lib.rs b/src/lib.rs + --- a/src/lib.rs + +++ b/src/lib.rs + @@ -1 +1 @@ + -old + +new + "}); + } +} diff --git a/.config/jp/tools/src/util/runner.rs b/.config/jp/tools/src/util/runner.rs index ae5b5248..bbb804ab 100644 --- a/.config/jp/tools/src/util/runner.rs +++ b/.config/jp/tools/src/util/runner.rs @@ -202,6 +202,13 @@ impl MockProcessRunner { Self::builder().expect_any().returns_error(stderr) } + /// Create a mock that expects no commands. Panics if any command is run. + pub fn never_called() -> Self { + Self { + expectations: Arc::new(Mutex::new(VecDeque::new())), + } + } + /// Create a new builder for setting up expectations. pub fn builder() -> MockProcessRunnerBuilder { MockProcessRunnerBuilder { diff --git a/.jp/config/personas/stager.toml b/.jp/config/personas/stager.toml index fbf1f0f8..2bbf31b9 100644 --- a/.jp/config/personas/stager.toml +++ b/.jp/config/personas/stager.toml @@ -129,6 +129,23 @@ items = [ """, ] +[[assistant.instructions]] +title = "Git Staging: Untracked Files" +description = """ +New files that haven't been added to git require an extra step before they can be staged. +""" +items = [ + """\ + The "Newly added files" attachment lists untracked files. If any are relevant to the current \ + staging topic, use `git_add_intent` to mark them as intent-to-add before calling \ + `git_list_patches`.\ + """, + """\ + After `git_add_intent`, `git_list_patches` will show the entire file content as insertion \ + hunks. Stage them normally with `git_stage_patch`.\ + """, +] + [[assistant.instructions]] title = "Git Staging: Surgical Precision" description = """ @@ -143,6 +160,9 @@ items = [ stage only the changes you want.\ """, """\ - If a patch cannot be staged cleanly, report this to the project maintainer and ask for help.\ + If a hunk from `git_list_patches` contains mixed unrelated changes on adjacent lines that \ + cannot be separated by patch ID, use `git_stage_raw_patch` to apply an edited version of \ + the diff. To exclude a deletion, change the `-` prefix to a space (context line). To exclude \ + an addition, remove the `+` line entirely. Adjust the `@@` hunk header line counts to match.\ """, ] diff --git a/.jp/config/skill/git-stage.toml b/.jp/config/skill/git-stage.toml index 924c2756..666ff7bd 100644 --- a/.jp/config/skill/git-stage.toml +++ b/.jp/config/skill/git-stage.toml @@ -7,18 +7,24 @@ value = """ You have been given the git-staging skill. You are an expert at diffing and staging files and \ hunks in git repositories. The following tools help you with this: +- git_add_intent: Mark untracked files as intent-to-add so they become visible to \ +`git_list_patches`. Use this when you need to stage new (untracked) files. - git_diff: Diff the current state of the git repository. - git_list_patches: List all patches for a given file that can be staged with `git_stage_patch`. \ - git_stage_patch: Reads the supplied diff output (i.e. "a patch") and applies it to the index. \ +- git_stage_raw_patch: Apply a hand-edited unified diff to the index. Use this when a hunk from \ +`git_list_patches` contains mixed changes that need to be staged partially. - git_unstage: Unstage (restore/reset) one or more staged files, undoing the effects of \ `git_stage_patch`. """ [conversation.tools] -git_diff = { enable = "explicit", run = "unattended", result = "unattended", style.inline_results = "off", style.results_file_link = "off" } -git_list_patches = { enable = "explicit", run = "unattended", result = "unattended", style.inline_results = "off", style.results_file_link = "full" } -git_stage_patch = { enable = "explicit", run = "unattended", result = "unattended", style.inline_results = "full", style.results_file_link = "off" } -git_unstage = { enable = "explicit", run = "unattended", result = "unattended", style.inline_results = "full", style.results_file_link = "off" } +git_add_intent = { enable = true, run = "unattended", result = "unattended", style.inline_results = "full", style.results_file_link = "off" } +git_diff = { enable = true, run = "unattended", result = "unattended", style.inline_results = "off", style.results_file_link = "off" } +git_list_patches = { enable = true, run = "unattended", result = "unattended", style.inline_results = "off", style.results_file_link = "full" } +git_stage_patch = { enable = true, run = "unattended", result = "unattended", style.inline_results = "full", style.results_file_link = "off" } +git_stage_raw_patch = { enable = true, run = "unattended", result = "unattended", style.inline_results = "full", style.results_file_link = "off" } +git_unstage = { enable = true, run = "unattended", result = "unattended", style.inline_results = "full", style.results_file_link = "off" } [[assistant.instructions]] title = "Git Staging: How To Determine Change Set Grouping" diff --git a/.jp/mcp/tools/git/add_intent.toml b/.jp/mcp/tools/git/add_intent.toml new file mode 100644 index 00000000..135b08f9 --- /dev/null +++ b/.jp/mcp/tools/git/add_intent.toml @@ -0,0 +1,31 @@ +[conversation.tools.git_add_intent] +enable = "explicit" +run = "unattended" +style.inline_results = "full" + +source = "local" +command = "just serve-tools {{context}} {{tool}}" +summary = "Mark untracked files as intent-to-add, making them visible to `git_list_patches`." +description = """ +Runs `git add --intent-to-add` for each path. This records the file in the \ +index as an empty blob, so `git_list_patches` will show the entire file \ +content as insertions. From there, use `git_stage_patch` or \ +`git_stage_raw_patch` to stage all or part of the file. +""" + +examples = """ +```json +{"paths": ["crates/jp_new_crate/src/lib.rs"]} +``` + +Multiple files: +```json +{"paths": ["src/new_module.rs", "src/new_module_tests.rs"]} +``` +""" + +[conversation.tools.git_add_intent.parameters.paths] +type = "array" +required = true +items.type = "string" +summary = "One or more file paths to mark as intent-to-add." diff --git a/.jp/mcp/tools/git/list_patches.toml b/.jp/mcp/tools/git/list_patches.toml index 5ae78d88..45ea2321 100644 --- a/.jp/mcp/tools/git/list_patches.toml +++ b/.jp/mcp/tools/git/list_patches.toml @@ -5,7 +5,7 @@ style.inline_results = "full" source = "local" command = "just serve-tools {{context}} {{tool}}" -summary = "List all patches for a given file that can be staged with `git_stage_patch`." +summary = "List all patches (using --unified=0) for a given file that can be staged with `git_stage_patch`." description = """ The listed patches have a unique identifier, which can be used in \ `git_stage_patch` to stage the patch in the repository cache. diff --git a/.jp/mcp/tools/git/stage_raw_patch.toml b/.jp/mcp/tools/git/stage_raw_patch.toml new file mode 100644 index 00000000..9a2da66b --- /dev/null +++ b/.jp/mcp/tools/git/stage_raw_patch.toml @@ -0,0 +1,46 @@ +[conversation.tools.git_stage_raw_patch] +enable = "explicit" +run = "unattended" +style.inline_results = "full" + +source = "local" +command = "just serve-tools {{context}} {{tool}}" +summary = "Apply a hand-edited unified diff to the git index, enabling surgical staging of partial hunks." +description = """ +Accepts a file path and raw unified diff content (hunk headers and body), \ +wraps it with the appropriate git diff headers, and applies it to the index \ +via `git apply --cached --unidiff-zero`. + +Use this when `git_stage_patch` cannot stage changes at the granularity you \ +need — for example, when a single hunk from `git_list_patches` contains \ +multiple unrelated changes on adjacent lines. + +To edit a hunk retrieved from `git_list_patches`: +- To exclude a deletion: change the `-` prefix to a space (` `) so the line \ +becomes context. +- To exclude an addition: remove the `+` line entirely. +- Adjust the line counts in the `@@` hunk header to match. +""" + +examples = """ +Stage only part of a hunk: +```json +{"path": "src/lib.rs", "diff": "@@ -5,2 +5,1 @@\\n-old line 5\\n-old line 6\\n+new line 5\\n"} +``` +""" + + +[conversation.tools.git_stage_raw_patch.parameters.path] +type = "string" +required = true +summary = "The file path being patched." + +[conversation.tools.git_stage_raw_patch.parameters.diff] +type = "string" +required = true +summary = "Raw unified diff content to apply." +description = """ +One or more unified diff hunks, each starting with a `@@` hunk header \ +followed by the diff body (lines prefixed with `-`, `+`, or ` `). \ +The tool wraps this with the `diff --git`/`---`/`+++` headers automatically. +"""