Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .config/jp/tools/src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
37 changes: 37 additions & 0 deletions .config/jp/tools/src/git/add_intent.rs
Original file line number Diff line number Diff line change
@@ -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<String>) -> ToolResult {
git_add_intent_impl(root, &paths, &DuctProcessRunner)
}

fn git_add_intent_impl<R: ProcessRunner>(
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;
66 changes: 66 additions & 0 deletions .config/jp/tools/src/git/add_intent_tests.rs
Original file line number Diff line number Diff line change
@@ -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"));
}
134 changes: 134 additions & 0 deletions .config/jp/tools/src/git/stage_raw_patch.rs
Original file line number Diff line number Diff line change
@@ -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<R: ProcessRunner>(
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
"});
}
}
7 changes: 7 additions & 0 deletions .config/jp/tools/src/util/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
22 changes: 21 additions & 1 deletion .jp/config/personas/stager.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """
Expand All @@ -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.\
""",
]
14 changes: 10 additions & 4 deletions .jp/config/skill/git-stage.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
31 changes: 31 additions & 0 deletions .jp/mcp/tools/git/add_intent.toml
Original file line number Diff line number Diff line change
@@ -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."
2 changes: 1 addition & 1 deletion .jp/mcp/tools/git/list_patches.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading