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
4 changes: 4 additions & 0 deletions .config/jp/tools/src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ mod log;
mod show;
mod stage_patch;
mod stage_patch_lines;
mod status;
mod unstage;

use add_intent::git_add_intent;
Expand All @@ -32,6 +33,7 @@ use log::git_log;
use show::git_show;
use stage_patch::git_stage_patch;
use stage_patch_lines::git_stage_patch_lines;
use status::git_status;
use unstage::git_unstage;

pub async fn run(ctx: Context, t: Tool) -> ToolResult {
Expand Down Expand Up @@ -71,6 +73,8 @@ pub async fn run(ctx: Context, t: Tool) -> ToolResult {

"show" => git_show(ctx.root, t.req("revision")?, opts).await,

"status" => git_status(ctx.root, opts).await,

"blame" => {
git_blame(
ctx.root,
Expand Down
163 changes: 163 additions & 0 deletions .config/jp/tools/src/git/status.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
use std::fmt::Write;

use camino::{Utf8Path, Utf8PathBuf};
use serde_json::{Map, Value};

use super::env_from_options;
use crate::util::{
ToolResult, error,
runner::{DuctProcessRunner, ProcessRunner},
};

/// Maximum number of untracked files to list before truncating.
///
/// Tracked changes are always shown in full; only the untracked listing is
/// capped, so a large untracked directory (e.g. non-ignored build output) can't
/// bury the tracked edits this guard exists to surface.
const MAX_UNTRACKED: usize = 500;

/// A single entry from `git status --porcelain`.
#[derive(Debug, PartialEq)]
struct StatusEntry {
/// The two-character XY status code (e.g.
/// `" M"`, `"??"`, `"A "`).
code: String,
/// The path, or `old -> new` for renames and copies.
path: String,
}

pub(crate) async fn git_status(root: Utf8PathBuf, options: &Map<String, Value>) -> ToolResult {
let env = env_from_options(options);
git_status_impl(&root, &DuctProcessRunner, &env)
}

fn git_status_impl<R: ProcessRunner>(
root: &Utf8Path,
runner: &R,
env: &[(&str, &str)],
) -> ToolResult {
// `core.quotePath=false` keeps non-ASCII paths readable instead of
// octal-escaped. `--untracked-files=all` expands untracked directories to
// individual files (the default collapses them to `dir/`), so the guard
// reports the actual paths an assistant needs to rule out local edits.
let output = runner.run_with_env(
"git",
&[
"-c",
"core.quotePath=false",
"status",
"--porcelain",
"--untracked-files=all",
],
root,
env,
)?;

if !output.status.is_success() {
return error(format!("git status failed: {}", output.stderr.trim()));
}

let entries = parse_status(&output.stdout);
Ok(format_status(&entries).into())
}

/// Parse `git status --porcelain` v1 output into per-file entries.
///
/// Each line is `XY<space>PATH`, where `XY` is the two-character status code
/// and `PATH` is `old -> new` for renames and copies.
fn parse_status(stdout: &str) -> Vec<StatusEntry> {
stdout
.lines()
.filter_map(|line| {
// Shortest valid line is `XY P` (code, space, one path char).
if line.len() < 4 {
return None;
}
let (code, rest) = line.split_at(2);
Some(StatusEntry {
code: code.to_string(),
// Strip exactly the one separator space after the XY code; a
// path that itself begins with spaces must survive intact.
path: rest.strip_prefix(' ').unwrap_or(rest).to_string(),
})
})
.collect()
}

fn format_status(entries: &[StatusEntry]) -> String {
if entries.is_empty() {
return "Working tree clean.".to_string();
}

let (untracked, tracked): (Vec<&StatusEntry>, Vec<&StatusEntry>) =
entries.iter().partition(|e| e.code == "??");

let mut out = String::from("<git_status>\n");

for e in tracked {
let _ = writeln!(out, " - {} ({})", e.path, describe(&e.code));
}

for e in untracked.iter().take(MAX_UNTRACKED) {
let _ = writeln!(out, " - {} ({})", e.path, describe(&e.code));
}

if untracked.len() > MAX_UNTRACKED {
let _ = writeln!(
out,
" ... and {} more untracked files not shown (output truncated).",
untracked.len() - MAX_UNTRACKED
);
}

out.push_str("</git_status>");
out
}

/// Decode a porcelain XY status code into a human-readable description.
///
/// The index (staged) and worktree (unstaged) columns are reported separately,
/// so `MM` becomes "modified, staged; modified, unstaged".
fn describe(code: &str) -> String {
if code == "??" {
return "untracked".to_string();
}
if code == "!!" {
return "ignored".to_string();
}

let mut chars = code.chars();
let index = chars.next().unwrap_or(' ');
let worktree = chars.next().unwrap_or(' ');

let mut parts = Vec::new();
if let Some(word) = describe_char(index) {
parts.push(format!("{word}, staged"));
}
if let Some(word) = describe_char(worktree) {
parts.push(format!("{word}, unstaged"));
}

if parts.is_empty() {
code.trim().to_string()
} else {
parts.join("; ")
}
}

fn describe_char(c: char) -> Option<&'static str> {
match c {
'M' => Some("modified"),
'A' => Some("added"),
'D' => Some("deleted"),
'R' => Some("renamed"),
'C' => Some("copied"),
'U' => Some("unmerged"),
'T' => Some("type changed"),
_ => None,
}
}

#[cfg(test)]
#[path = "status_tests.rs"]
mod tests;
137 changes: 137 additions & 0 deletions .config/jp/tools/src/git/status_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
use camino_tempfile::tempdir;

use super::*;
use crate::util::runner::MockProcessRunner;

#[test]
fn parses_porcelain_entries() {
let stdout = " M src/foo.rs\n?? new.txt\nA staged.rs\nR old.rs -> new.rs\n";
let entries = parse_status(stdout);

assert_eq!(entries.len(), 4);
assert_eq!(entries[0], StatusEntry {
code: " M".into(),
path: "src/foo.rs".into(),
});
assert_eq!(entries[1], StatusEntry {
code: "??".into(),
path: "new.txt".into(),
});
assert_eq!(entries[2], StatusEntry {
code: "A ".into(),
path: "staged.rs".into(),
});
assert_eq!(entries[3], StatusEntry {
code: "R ".into(),
path: "old.rs -> new.rs".into(),
});
}

#[test]
fn keeps_leading_space_in_path() {
// Porcelain is a fixed `XY<space>PATH`; only the one separator after the
// status code is stripped, so a path that itself begins with spaces must
// survive intact (the dirty-tree check relies on the real path).
let entries = parse_status("?? leading.rs\n");

assert_eq!(entries.len(), 1);
assert_eq!(entries[0], StatusEntry {
code: "??".into(),
path: " leading.rs".into(),
});
}

#[test]
fn describes_codes() {
assert_eq!(describe("??"), "untracked");
assert_eq!(describe(" M"), "modified, unstaged");
assert_eq!(describe("M "), "modified, staged");
assert_eq!(describe("MM"), "modified, staged; modified, unstaged");
assert_eq!(describe("A "), "added, staged");
assert_eq!(describe("D "), "deleted, staged");
assert_eq!(describe("R "), "renamed, staged");
}

#[test]
fn formats_clean_tree() {
assert_eq!(format_status(&[]), "Working tree clean.");
}

#[test]
fn basic_status() {
let dir = tempdir().unwrap();
let runner = MockProcessRunner::success(" M src/foo.rs\n?? new.txt\n");

let content = git_status_impl(dir.path(), &runner, &[])
.unwrap()
.into_content()
.unwrap();

assert!(content.contains("- src/foo.rs (modified, unstaged)"));
assert!(content.contains("- new.txt (untracked)"));
}

#[test]
fn requests_all_untracked_files() {
let dir = tempdir().unwrap();
let runner = MockProcessRunner::builder()
.expect("git")
.args(&[
"-c",
"core.quotePath=false",
"status",
"--porcelain",
"--untracked-files=all",
])
.returns_success("");

let _outcome = git_status_impl(dir.path(), &runner, &[]).unwrap();
}

#[test]
fn caps_untracked_but_always_shows_tracked() {
// A large untracked directory must not bury the tracked edits the guard
// exists to surface: tracked changes are shown in full, untracked are
// capped with a count of the remainder.
let mut entries = vec![StatusEntry {
code: " M".into(),
path: "src/keep.rs".into(),
}];
for i in 0..(MAX_UNTRACKED + 50) {
entries.push(StatusEntry {
code: "??".into(),
path: format!("junk/{i}.tmp"),
});
}

let out = format_status(&entries);

assert!(
out.contains("- src/keep.rs (modified, unstaged)"),
"tracked change must always show"
);
assert!(out.contains("and 50 more untracked files not shown"));
assert_eq!(out.matches("(untracked)").count(), MAX_UNTRACKED);
}

#[test]
fn clean_tree_via_runner() {
let dir = tempdir().unwrap();
let runner = MockProcessRunner::success("");

let content = git_status_impl(dir.path(), &runner, &[])
.unwrap()
.into_content()
.unwrap();

assert_eq!(content, "Working tree clean.");
}

#[test]
fn status_git_error() {
let dir = tempdir().unwrap();
let runner = MockProcessRunner::error("fatal: not a git repository");

let outcome = git_status_impl(dir.path(), &runner, &[]).unwrap();
assert!(outcome.into_content().is_none(), "expected error outcome");
}
14 changes: 9 additions & 5 deletions .jp/config/personas/pr-reviewer.toml
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,15 @@ items = [
with a reply, or leave them alone.\
""",
"""\
**2. Read for context, not just the diff.** When a hunk is too narrow to judge, use \
`github_read_file` (with `start_line` / `end_line` for any non-trivial file) to fetch the \
surrounding code at the PR's branch, or `github_pr_commits` / `github_commit` to inspect \
the PR's individual commits. (The local `git_log` / `git_diff_commit` tools only see \
commits already on your checked-out branch, not the PR's.)\
**2. Read for context, not just the diff.** When a hunk is too narrow to judge, read the \
surrounding code. If the prompt says the working tree is checked out at the PR's head, \
prefer the local `fs_*` and `git_*` tools — they are faster and complete (if it also says \
the tree is dirty, call `git_status` to list the changed files and confirm they don't affect \
your read). \
Otherwise the working tree does not match the PR, so use `github_read_file` (with \
`start_line` / `end_line`) to read code at the PR's branch and `github_pr_commits` / \
`github_commit` for its commits; the local `git_log` / `git_diff_commit` tools only see \
commits already on your checked-out branch.\
""",
"""\
**3. Cross-reference.** Use `github_issues` and `github_code_search` to find related work \
Expand Down
14 changes: 9 additions & 5 deletions .jp/config/personas/pr-triager.toml
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,15 @@ items = [
""",
"""\
**3. Ground each item against evidence.** Use `fs_grep_files`, `fs_read_file`, and \
`fs_list_files` to inspect the actual code at the lines under discussion. Use `git_log`, \
`git_show`, and `git_diff_commit` for history on your local branch, and `github_pr_commits` \
/ `github_commit` to inspect the PR's own commits when they aren't checked out locally. Use \
`cargo_check` and `cargo_test` if a comment claims a behavior you can verify by compiling or \
running tests. Use `github_issues` and `github_code_search` for context outside this PR.\
`fs_list_files` to inspect the actual code at the lines under discussion. If the prompt says \
the working tree is checked out at the PR's head, prefer the local `git_log`, `git_show`, \
and `git_diff_commit` tools for history — faster and complete (if it also says the tree is \
dirty, call `git_status` to list the changed files and confirm they don't affect your read). \
Otherwise the \
working tree does not match the PR, so use `github_pr_commits` / `github_commit` to inspect \
its commits. Use `cargo_check` and `cargo_test` if a comment claims a behavior you can \
verify by compiling or running tests. Use `github_issues` and `github_code_search` for \
context outside this PR.\
""",
"""\
**4. Think hard** before writing. The value of this turn is in the quality of the \
Expand Down
4 changes: 4 additions & 0 deletions .jp/config/skill/git-reading.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ use `git_diff_file` for that.
- git_diff_file: Working-tree analog of `git_diff_commit`. Show the full staged or unstaged \
diff for explicitly listed files. Requires `status` and `paths`. Supports `pattern` to grep \
within large diffs and `start_line` / `end_line` to page through large diffs in chunks.
- git_status: Show the working tree status — which files are modified, staged, or untracked \
(untracked files do not appear in `git_diff`).
- git_log: Search the commit history. Supports filtering by message text, file paths, date range, \
and result count.
- git_show: Show commit details (message and changed file stats). Does NOT include actual diff \
Expand All @@ -28,13 +30,15 @@ Use these tools in a drill-down workflow:
`git_diff_commit` to view the actual diff for specific files.
- For the working tree: `git_diff` for the overview, `git_diff_file` to drill into a file \
whose diff was truncated.
- For working-tree state: `git_status` to list modified / staged / untracked files.
- For line history: `git_blame` to find which commit introduced a line, then `git_show` or \
`git_diff_commit` on the blamed (or `previous`) hash to see why.
"""

[conversation.tools]
git_diff = { enable = true, run = "unattended", result = "unattended", style.inline_results = "off", style.results_file_link = "off" }
git_diff_file = { enable = true, run = "unattended", result = "unattended", style.inline_results = "full", style.results_file_link = "off" }
git_status = { enable = true, run = "unattended", result = "unattended", style.inline_results = "full", style.results_file_link = "off" }
git_log = { enable = true, run = "unattended", result = "unattended", style.inline_results = "full", style.results_file_link = "off" }
git_show = { enable = true, run = "unattended", result = "unattended", style.inline_results = "full", style.results_file_link = "off" }
git_diff_commit = { enable = true, run = "unattended", result = "unattended", style.inline_results = "full", style.results_file_link = "off" }
Expand Down
Loading
Loading