@rethunk/mcp-multi-root-git 2.3.4 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +29 -3
- package/CHANGELOG.md +43 -0
- package/HUMANS.md +54 -5
- package/README.md +15 -1
- package/dist/server/batch-commit-tool.js +244 -19
- package/dist/server/git-diff-tool.js +132 -0
- package/dist/server/git-fetch-tool.js +131 -0
- package/dist/server/git-push-tool.js +8 -1
- package/dist/server/git-show-tool.js +148 -0
- package/dist/server/git-stash-tool.js +131 -0
- package/dist/server/git-tag-tool.js +162 -0
- package/dist/server/git.js +18 -2
- package/dist/server/roots.js +8 -4
- package/dist/server/test-harness.js +68 -5
- package/dist/server/tool-parameter-schemas.js +21 -1
- package/dist/server/tools.js +11 -0
- package/docs/install.md +19 -2
- package/docs/mcp-tools.md +214 -5
- package/package.json +7 -5
- package/schemas/batch_commit.json +125 -0
- package/schemas/git_cherry_pick.json +63 -0
- package/schemas/git_diff.json +62 -0
- package/schemas/git_diff_summary.json +75 -0
- package/schemas/git_fetch.json +52 -0
- package/schemas/git_inventory.json +74 -0
- package/schemas/git_log.json +75 -0
- package/schemas/git_merge.json +79 -0
- package/schemas/git_parity.json +74 -0
- package/schemas/git_push.json +50 -0
- package/schemas/git_reset_soft.json +42 -0
- package/schemas/git_show.json +39 -0
- package/schemas/git_stash_apply.json +44 -0
- package/schemas/git_stash_list.json +30 -0
- package/schemas/git_status.json +49 -0
- package/schemas/git_tag.json +50 -0
- package/schemas/git_worktree_add.json +52 -0
- package/schemas/git_worktree_list.json +30 -0
- package/schemas/git_worktree_remove.json +48 -0
- package/schemas/index.json +108 -0
- package/schemas/list_presets.json +44 -0
- package/tool-parameters.schema.json +317 -4
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { isSafeGitUpstreamToken, spawnGitAsync } from "./git.js";
|
|
3
|
+
import { jsonRespond } from "./json.js";
|
|
4
|
+
import { requireSingleRepo } from "./roots.js";
|
|
5
|
+
import { WorkspacePickSchema } from "./schemas.js";
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Helpers
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
/** Build the diff args array from parameters. */
|
|
10
|
+
function buildDiffArgs(opts) {
|
|
11
|
+
const args = ["diff"];
|
|
12
|
+
// Handle staged flag first
|
|
13
|
+
if (opts.staged === true) {
|
|
14
|
+
args.push("--staged");
|
|
15
|
+
}
|
|
16
|
+
else if (opts.base || opts.head) {
|
|
17
|
+
// Range-based diff: base..head or base...head
|
|
18
|
+
// If only base is given, use base~0..HEAD (implicit HEAD)
|
|
19
|
+
const baseStr = opts.base?.trim() ?? "HEAD";
|
|
20
|
+
const headStr = opts.head?.trim() ?? "HEAD";
|
|
21
|
+
if (!isSafeGitUpstreamToken(baseStr) || !isSafeGitUpstreamToken(headStr)) {
|
|
22
|
+
return { ok: false, error: "unsafe_range_token" };
|
|
23
|
+
}
|
|
24
|
+
// Use two-dot range: base..head
|
|
25
|
+
args.push(`${baseStr}..${headStr}`);
|
|
26
|
+
}
|
|
27
|
+
// Scope to path if provided
|
|
28
|
+
if (opts.path?.trim()) {
|
|
29
|
+
args.push("--", opts.path.trim());
|
|
30
|
+
}
|
|
31
|
+
return { ok: true, args };
|
|
32
|
+
}
|
|
33
|
+
/** Human-readable label for the range. */
|
|
34
|
+
function rangeLabel(opts) {
|
|
35
|
+
let label = "";
|
|
36
|
+
if (opts.staged === true) {
|
|
37
|
+
label = "staged changes";
|
|
38
|
+
}
|
|
39
|
+
else if (opts.base || opts.head) {
|
|
40
|
+
const baseStr = opts.base?.trim() ?? "HEAD";
|
|
41
|
+
const headStr = opts.head?.trim() ?? "HEAD";
|
|
42
|
+
label = `${baseStr}..${headStr}`;
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
label = "unstaged changes";
|
|
46
|
+
}
|
|
47
|
+
if (opts.path?.trim()) {
|
|
48
|
+
label += ` (${opts.path.trim()})`;
|
|
49
|
+
}
|
|
50
|
+
return label;
|
|
51
|
+
}
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Tool registration
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
export function registerGitDiffTool(server) {
|
|
56
|
+
server.addTool({
|
|
57
|
+
name: "git_diff",
|
|
58
|
+
description: "Get diff text for scoped file or range. Returns the raw diff output. " +
|
|
59
|
+
"Use `staged: true` for staged changes, `base`/`head` for revision ranges, " +
|
|
60
|
+
"and `path` to scope to a specific file.",
|
|
61
|
+
annotations: {
|
|
62
|
+
readOnlyHint: true,
|
|
63
|
+
},
|
|
64
|
+
parameters: WorkspacePickSchema.extend({
|
|
65
|
+
base: z
|
|
66
|
+
.string()
|
|
67
|
+
.optional()
|
|
68
|
+
.describe('Base ref (e.g. "main", "HEAD~3"). Required for range diffs. ' +
|
|
69
|
+
"If omitted and `staged: false`, shows unstaged changes."),
|
|
70
|
+
head: z
|
|
71
|
+
.string()
|
|
72
|
+
.optional()
|
|
73
|
+
.describe('Head ref (e.g. "feature-branch"). If omitted, defaults to HEAD. ' +
|
|
74
|
+
"Only used if `base` is provided."),
|
|
75
|
+
path: z
|
|
76
|
+
.string()
|
|
77
|
+
.optional()
|
|
78
|
+
.describe('Scope diff to a single file path (e.g. "src/main.ts").'),
|
|
79
|
+
staged: z
|
|
80
|
+
.boolean()
|
|
81
|
+
.optional()
|
|
82
|
+
.default(false)
|
|
83
|
+
.describe("If true, show staged changes (git diff --staged). " + "Ignored if `base` is provided."),
|
|
84
|
+
}),
|
|
85
|
+
execute: async (args) => {
|
|
86
|
+
const pre = requireSingleRepo(server, args);
|
|
87
|
+
if (!pre.ok)
|
|
88
|
+
return jsonRespond(pre.error);
|
|
89
|
+
const gitTop = pre.gitTop;
|
|
90
|
+
// Build git diff args
|
|
91
|
+
const diffArgsResult = buildDiffArgs({
|
|
92
|
+
base: args.base,
|
|
93
|
+
head: args.head,
|
|
94
|
+
path: args.path,
|
|
95
|
+
staged: args.staged,
|
|
96
|
+
});
|
|
97
|
+
if (!diffArgsResult.ok) {
|
|
98
|
+
return jsonRespond({ error: diffArgsResult.error });
|
|
99
|
+
}
|
|
100
|
+
// Run git diff
|
|
101
|
+
const result = await spawnGitAsync(gitTop, diffArgsResult.args);
|
|
102
|
+
if (!result.ok) {
|
|
103
|
+
return jsonRespond({
|
|
104
|
+
error: "git_diff_failed",
|
|
105
|
+
detail: (result.stderr || result.stdout).trim(),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const label = rangeLabel({
|
|
109
|
+
base: args.base,
|
|
110
|
+
head: args.head,
|
|
111
|
+
path: args.path,
|
|
112
|
+
staged: args.staged,
|
|
113
|
+
});
|
|
114
|
+
if (args.format === "json") {
|
|
115
|
+
return jsonRespond({
|
|
116
|
+
range: label,
|
|
117
|
+
diff: result.stdout,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
// Markdown output
|
|
121
|
+
const lines = [];
|
|
122
|
+
lines.push(`# Diff: ${label}`, "");
|
|
123
|
+
if (result.stdout.trim()) {
|
|
124
|
+
lines.push("```diff", result.stdout.trimEnd(), "```");
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
lines.push("_(no changes)_");
|
|
128
|
+
}
|
|
129
|
+
return lines.join("\n");
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { spawnGitAsync } from "./git.js";
|
|
3
|
+
import { jsonRespond } from "./json.js";
|
|
4
|
+
import { requireSingleRepo } from "./roots.js";
|
|
5
|
+
import { WorkspacePickSchema } from "./schemas.js";
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Helpers
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
/**
|
|
10
|
+
* Parse `git fetch` output to extract updated and new refs.
|
|
11
|
+
* Lines containing "[new" indicate new refs (new branch, new tag, new ref).
|
|
12
|
+
* Lines with " -> " but not containing "[new" indicate updated refs.
|
|
13
|
+
*/
|
|
14
|
+
function parseGitFetchOutput(output) {
|
|
15
|
+
const lines = output.split("\n");
|
|
16
|
+
const updatedRefs = [];
|
|
17
|
+
const newRefs = [];
|
|
18
|
+
for (const line of lines) {
|
|
19
|
+
const trimmed = line.trim();
|
|
20
|
+
if (!trimmed)
|
|
21
|
+
continue;
|
|
22
|
+
// Lines containing "[new" indicate new refs (e.g. "[new branch]", "[new tag]", "[new ref]")
|
|
23
|
+
if (trimmed.includes("[new")) {
|
|
24
|
+
newRefs.push(trimmed);
|
|
25
|
+
}
|
|
26
|
+
// Lines with " -> " that don't contain "[new" indicate ref updates
|
|
27
|
+
else if (trimmed.includes(" -> ")) {
|
|
28
|
+
updatedRefs.push(trimmed);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { updatedRefs, newRefs };
|
|
32
|
+
}
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Tool registration
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
export function registerGitFetchTool(server) {
|
|
37
|
+
server.addTool({
|
|
38
|
+
name: "git_fetch",
|
|
39
|
+
description: "Fetch updates from a remote repository without modifying the working tree. " +
|
|
40
|
+
"Returns structured output distinguishing updated refs from new refs.",
|
|
41
|
+
annotations: {
|
|
42
|
+
readOnlyHint: false, // Fetch modifies refs but not working tree; not strictly read-only but safe
|
|
43
|
+
},
|
|
44
|
+
parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true, allWorkspaceRoots: true })
|
|
45
|
+
.pick({
|
|
46
|
+
workspaceRoot: true,
|
|
47
|
+
rootIndex: true,
|
|
48
|
+
format: true,
|
|
49
|
+
})
|
|
50
|
+
.extend({
|
|
51
|
+
remote: z
|
|
52
|
+
.string()
|
|
53
|
+
.optional()
|
|
54
|
+
.default("origin")
|
|
55
|
+
.describe("Remote to fetch from (default: origin)."),
|
|
56
|
+
branch: z
|
|
57
|
+
.string()
|
|
58
|
+
.optional()
|
|
59
|
+
.describe("If specified: fetch only this branch (e.g. 'main')."),
|
|
60
|
+
prune: z
|
|
61
|
+
.boolean()
|
|
62
|
+
.optional()
|
|
63
|
+
.default(false)
|
|
64
|
+
.describe("Pass --prune to remove deleted remote branches (default: false)."),
|
|
65
|
+
tags: z
|
|
66
|
+
.boolean()
|
|
67
|
+
.optional()
|
|
68
|
+
.default(false)
|
|
69
|
+
.describe("Pass --tags to also fetch all tags (default: false)."),
|
|
70
|
+
}),
|
|
71
|
+
execute: async (args) => {
|
|
72
|
+
const pre = requireSingleRepo(server, args);
|
|
73
|
+
if (!pre.ok) {
|
|
74
|
+
return jsonRespond(pre.error);
|
|
75
|
+
}
|
|
76
|
+
const gitTop = pre.gitTop;
|
|
77
|
+
const remote = (args.remote ?? "origin").trim();
|
|
78
|
+
const branch = args.branch?.trim();
|
|
79
|
+
const prune = args.prune === true;
|
|
80
|
+
const tags = args.tags === true;
|
|
81
|
+
// Build git fetch command
|
|
82
|
+
const fetchArgs = ["fetch"];
|
|
83
|
+
if (prune) {
|
|
84
|
+
fetchArgs.push("--prune");
|
|
85
|
+
}
|
|
86
|
+
if (tags) {
|
|
87
|
+
fetchArgs.push("--tags");
|
|
88
|
+
}
|
|
89
|
+
fetchArgs.push(remote);
|
|
90
|
+
if (branch) {
|
|
91
|
+
fetchArgs.push(branch);
|
|
92
|
+
}
|
|
93
|
+
const result = await spawnGitAsync(gitTop, fetchArgs);
|
|
94
|
+
const { updatedRefs, newRefs } = parseGitFetchOutput(result.stdout + result.stderr);
|
|
95
|
+
const fetchResult = {
|
|
96
|
+
ok: result.ok,
|
|
97
|
+
remote,
|
|
98
|
+
updatedRefs,
|
|
99
|
+
newRefs,
|
|
100
|
+
output: (result.stdout + result.stderr).trim(),
|
|
101
|
+
};
|
|
102
|
+
if (args.format === "json") {
|
|
103
|
+
return jsonRespond(fetchResult);
|
|
104
|
+
}
|
|
105
|
+
// Markdown output
|
|
106
|
+
const lines = [`# Git fetch from '${remote}'`];
|
|
107
|
+
if (!result.ok) {
|
|
108
|
+
lines.push("", "**Status**: Failed", "");
|
|
109
|
+
lines.push("```", result.stdout || result.stderr || "(no output)", "```");
|
|
110
|
+
return lines.join("\n");
|
|
111
|
+
}
|
|
112
|
+
lines.push("", "**Status**: Success", "");
|
|
113
|
+
if (updatedRefs.length > 0) {
|
|
114
|
+
lines.push("## Updated refs", "");
|
|
115
|
+
for (const ref of updatedRefs) {
|
|
116
|
+
lines.push(`- ${ref}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (newRefs.length > 0) {
|
|
120
|
+
lines.push("", "## New refs", "");
|
|
121
|
+
for (const ref of newRefs) {
|
|
122
|
+
lines.push(`- ${ref}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (updatedRefs.length === 0 && newRefs.length === 0 && result.stdout.trim()) {
|
|
126
|
+
lines.push("", "## Output", "", "```", result.stdout.trim(), "```");
|
|
127
|
+
}
|
|
128
|
+
return lines.join("\n");
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
}
|
|
@@ -94,6 +94,8 @@ export function registerGitPushTool(server) {
|
|
|
94
94
|
"@{u}",
|
|
95
95
|
]);
|
|
96
96
|
const upstream = upstreamProbe.ok ? upstreamProbe.stdout.trim() : `${remote}/${branch}`;
|
|
97
|
+
// Append git output (stdout/stderr) if non-empty
|
|
98
|
+
const gitOutput = (pushResult.stdout || pushResult.stderr).trim();
|
|
97
99
|
if (args.format === "json") {
|
|
98
100
|
return jsonRespond({
|
|
99
101
|
ok: true,
|
|
@@ -101,9 +103,14 @@ export function registerGitPushTool(server) {
|
|
|
101
103
|
remote,
|
|
102
104
|
upstream,
|
|
103
105
|
...spreadDefined("setUpstream", args.setUpstream || undefined),
|
|
106
|
+
...spreadDefined("output", gitOutput || undefined),
|
|
104
107
|
});
|
|
105
108
|
}
|
|
106
|
-
|
|
109
|
+
let result = `# Push\n✓ ${branch} → ${upstream}`;
|
|
110
|
+
if (gitOutput) {
|
|
111
|
+
result += `\n\n${gitOutput}`;
|
|
112
|
+
}
|
|
113
|
+
return result;
|
|
107
114
|
},
|
|
108
115
|
});
|
|
109
116
|
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { spawnGitAsync } from "./git.js";
|
|
3
|
+
import { jsonRespond } from "./json.js";
|
|
4
|
+
import { requireSingleRepo } from "./roots.js";
|
|
5
|
+
import { WorkspacePickSchema } from "./schemas.js";
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Helpers
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
/**
|
|
10
|
+
* Run git show for a single ref, optionally limiting to a specific path.
|
|
11
|
+
* Returns commit message and diff (or file content if path is specified).
|
|
12
|
+
*/
|
|
13
|
+
async function runGitShow(opts) {
|
|
14
|
+
const { top, ref, path } = opts;
|
|
15
|
+
// Build git show args. Start with --no-patch to get just the commit message.
|
|
16
|
+
const showArgs = ["show", ref];
|
|
17
|
+
if (path) {
|
|
18
|
+
// When path is specified, show that path at the ref without --no-patch
|
|
19
|
+
// to get the full content at that ref
|
|
20
|
+
showArgs.push("--", path);
|
|
21
|
+
}
|
|
22
|
+
const r = await spawnGitAsync(top, showArgs);
|
|
23
|
+
if (!r.ok) {
|
|
24
|
+
return {
|
|
25
|
+
error: "git_show_failed",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
// Parse the output. For a commit, git show outputs:
|
|
29
|
+
// - Header (commit, Author, Date, etc.)
|
|
30
|
+
// - Blank line
|
|
31
|
+
// - Commit message (may contain multiple lines and blank lines)
|
|
32
|
+
// - Blank line (separator before diff)
|
|
33
|
+
// - Diff (if --no-patch not used) or file content
|
|
34
|
+
const output = r.stdout;
|
|
35
|
+
let message = "";
|
|
36
|
+
let diff = "";
|
|
37
|
+
const lines = output.split("\n");
|
|
38
|
+
let inHeader = true;
|
|
39
|
+
let inMessage = false;
|
|
40
|
+
const messageLines = [];
|
|
41
|
+
const contentLines = [];
|
|
42
|
+
for (const line of lines) {
|
|
43
|
+
if (line === undefined)
|
|
44
|
+
continue;
|
|
45
|
+
// End header when we see a blank line
|
|
46
|
+
if (inHeader && line.trim() === "") {
|
|
47
|
+
inHeader = false;
|
|
48
|
+
inMessage = true;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
// In message: collect until we see "diff --git" which marks the start of the diff section
|
|
52
|
+
if (inMessage) {
|
|
53
|
+
if (line.startsWith("diff --git")) {
|
|
54
|
+
inMessage = false;
|
|
55
|
+
contentLines.push(line);
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
messageLines.push(line);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
else if (!inHeader) {
|
|
62
|
+
// In diff/content section
|
|
63
|
+
contentLines.push(line);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
message = messageLines.join("\n").trim();
|
|
67
|
+
diff = contentLines.join("\n").trim();
|
|
68
|
+
const result = {
|
|
69
|
+
ref,
|
|
70
|
+
message,
|
|
71
|
+
};
|
|
72
|
+
if (path) {
|
|
73
|
+
result.path = path;
|
|
74
|
+
}
|
|
75
|
+
if (diff) {
|
|
76
|
+
result.diff = diff;
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Markdown rendering
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
function renderShowMarkdown(result) {
|
|
84
|
+
const lines = [];
|
|
85
|
+
lines.push(`# git show ${result.ref}`);
|
|
86
|
+
if (result.path) {
|
|
87
|
+
lines.push(`_path: ${result.path}_`);
|
|
88
|
+
}
|
|
89
|
+
lines.push("");
|
|
90
|
+
lines.push("## Commit message");
|
|
91
|
+
lines.push("");
|
|
92
|
+
lines.push("```");
|
|
93
|
+
lines.push(result.message);
|
|
94
|
+
lines.push("```");
|
|
95
|
+
if (result.diff) {
|
|
96
|
+
lines.push("");
|
|
97
|
+
lines.push("## Diff");
|
|
98
|
+
lines.push("");
|
|
99
|
+
lines.push("```diff");
|
|
100
|
+
lines.push(result.diff);
|
|
101
|
+
lines.push("```");
|
|
102
|
+
}
|
|
103
|
+
return lines.join("\n");
|
|
104
|
+
}
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Tool registration
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
export function registerGitShowTool(server) {
|
|
109
|
+
server.addTool({
|
|
110
|
+
name: "git_show",
|
|
111
|
+
description: "Inspect commit content by ref/SHA. Returns commit message and diff (or file content at a specific path).",
|
|
112
|
+
annotations: {
|
|
113
|
+
readOnlyHint: true,
|
|
114
|
+
},
|
|
115
|
+
parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true, allWorkspaceRoots: true })
|
|
116
|
+
.pick({
|
|
117
|
+
workspaceRoot: true,
|
|
118
|
+
rootIndex: true,
|
|
119
|
+
format: true,
|
|
120
|
+
})
|
|
121
|
+
.extend({
|
|
122
|
+
ref: z.string().describe("Commit reference (SHA, branch, tag, or any git rev-spec)."),
|
|
123
|
+
path: z
|
|
124
|
+
.string()
|
|
125
|
+
.optional()
|
|
126
|
+
.describe("Optional file path to inspect at the ref. If provided, shows that path's content at the ref instead of the diff."),
|
|
127
|
+
}),
|
|
128
|
+
execute: async (args) => {
|
|
129
|
+
const pre = requireSingleRepo(server, args);
|
|
130
|
+
if (!pre.ok)
|
|
131
|
+
return jsonRespond(pre.error);
|
|
132
|
+
const top = pre.gitTop;
|
|
133
|
+
const result = await runGitShow({
|
|
134
|
+
top,
|
|
135
|
+
ref: args.ref,
|
|
136
|
+
path: args.path,
|
|
137
|
+
});
|
|
138
|
+
if ("error" in result) {
|
|
139
|
+
return jsonRespond(result);
|
|
140
|
+
}
|
|
141
|
+
if (args.format === "json") {
|
|
142
|
+
return jsonRespond(result);
|
|
143
|
+
}
|
|
144
|
+
// Markdown
|
|
145
|
+
return renderShowMarkdown(result);
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { spawnGitAsync } from "./git.js";
|
|
3
|
+
import { jsonRespond, spreadDefined } from "./json.js";
|
|
4
|
+
import { requireSingleRepo } from "./roots.js";
|
|
5
|
+
import { WorkspacePickSchema } from "./schemas.js";
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// git_stash_list
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
export function registerGitStashListTool(server) {
|
|
10
|
+
server.addTool({
|
|
11
|
+
name: "git_stash_list",
|
|
12
|
+
description: "List all git stashes. Returns array of `{ index: number, message: string, sha: string }`.",
|
|
13
|
+
annotations: {
|
|
14
|
+
readOnlyHint: true,
|
|
15
|
+
},
|
|
16
|
+
parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true, allWorkspaceRoots: true }).pick({
|
|
17
|
+
workspaceRoot: true,
|
|
18
|
+
rootIndex: true,
|
|
19
|
+
format: true,
|
|
20
|
+
}),
|
|
21
|
+
execute: async (args) => {
|
|
22
|
+
const pre = requireSingleRepo(server, args);
|
|
23
|
+
if (!pre.ok)
|
|
24
|
+
return jsonRespond(pre.error);
|
|
25
|
+
const { gitTop } = pre;
|
|
26
|
+
// List all stashes: git stash list --format='%(refname:short)|%(subject)|%(objectname:short)'
|
|
27
|
+
const r = await spawnGitAsync(gitTop, [
|
|
28
|
+
"stash",
|
|
29
|
+
"list",
|
|
30
|
+
"--format=%(refname:short)|%(subject)|%(objectname:short)",
|
|
31
|
+
]);
|
|
32
|
+
if (!r.ok) {
|
|
33
|
+
// If there are no stashes, git still returns ok=true with empty output
|
|
34
|
+
// Only treat as error if git itself failed
|
|
35
|
+
return jsonRespond({
|
|
36
|
+
error: "stash_list_failed",
|
|
37
|
+
detail: (r.stderr || r.stdout).trim(),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
const stashes = [];
|
|
41
|
+
const lines = (r.stdout || "")
|
|
42
|
+
.split("\n")
|
|
43
|
+
.map((l) => l.trim())
|
|
44
|
+
.filter((l) => l.length > 0);
|
|
45
|
+
for (let i = 0; i < lines.length; i++) {
|
|
46
|
+
const line = lines[i];
|
|
47
|
+
if (line) {
|
|
48
|
+
const parts = line.split("|");
|
|
49
|
+
if (parts.length >= 3 && parts[1] && parts[2]) {
|
|
50
|
+
stashes.push({
|
|
51
|
+
index: i,
|
|
52
|
+
message: parts[1],
|
|
53
|
+
sha: parts[2],
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (args.format === "json") {
|
|
59
|
+
return jsonRespond({ stashes });
|
|
60
|
+
}
|
|
61
|
+
if (stashes.length === 0) {
|
|
62
|
+
return "# Stashes\n_(none)_";
|
|
63
|
+
}
|
|
64
|
+
const lines_out = ["# Stashes", ""];
|
|
65
|
+
for (const s of stashes) {
|
|
66
|
+
lines_out.push(`- **stash@{${s.index}}** — ${s.message} (\`${s.sha}\`)`);
|
|
67
|
+
}
|
|
68
|
+
return lines_out.join("\n");
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// git_stash_apply
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
export function registerGitStashApplyTool(server) {
|
|
76
|
+
server.addTool({
|
|
77
|
+
name: "git_stash_apply",
|
|
78
|
+
description: "Apply or pop a git stash. `index` defaults to 0 (stash@{0}). " +
|
|
79
|
+
"Set `pop: true` to run `git stash pop` instead of `git stash apply` (removes stash after applying). " +
|
|
80
|
+
"Returns `{ applied: boolean, stashIndex: number, popped: boolean, output: string }`.",
|
|
81
|
+
annotations: {
|
|
82
|
+
readOnlyHint: false,
|
|
83
|
+
destructiveHint: false,
|
|
84
|
+
idempotentHint: false,
|
|
85
|
+
},
|
|
86
|
+
parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true, allWorkspaceRoots: true })
|
|
87
|
+
.pick({
|
|
88
|
+
workspaceRoot: true,
|
|
89
|
+
rootIndex: true,
|
|
90
|
+
format: true,
|
|
91
|
+
})
|
|
92
|
+
.extend({
|
|
93
|
+
index: z
|
|
94
|
+
.number()
|
|
95
|
+
.int()
|
|
96
|
+
.min(0)
|
|
97
|
+
.optional()
|
|
98
|
+
.default(0)
|
|
99
|
+
.describe("Stash index (defaults to 0 for stash@{0})."),
|
|
100
|
+
pop: z
|
|
101
|
+
.boolean()
|
|
102
|
+
.optional()
|
|
103
|
+
.default(false)
|
|
104
|
+
.describe("If true, runs `git stash pop` instead of `git stash apply` (removes stash after applying)."),
|
|
105
|
+
}),
|
|
106
|
+
execute: async (args) => {
|
|
107
|
+
const pre = requireSingleRepo(server, args);
|
|
108
|
+
if (!pre.ok)
|
|
109
|
+
return jsonRespond(pre.error);
|
|
110
|
+
const { gitTop } = pre;
|
|
111
|
+
const stashRef = `stash@{${args.index}}`;
|
|
112
|
+
const cmd = args.pop ? "pop" : "apply";
|
|
113
|
+
const r = await spawnGitAsync(gitTop, ["stash", cmd, stashRef]);
|
|
114
|
+
const applied = r.ok;
|
|
115
|
+
const output = (r.stdout || r.stderr).trim();
|
|
116
|
+
if (args.format === "json") {
|
|
117
|
+
return jsonRespond({
|
|
118
|
+
applied,
|
|
119
|
+
stashIndex: args.index,
|
|
120
|
+
popped: args.pop,
|
|
121
|
+
...spreadDefined("output", output),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
const verb = args.pop ? "popped" : "applied";
|
|
125
|
+
if (applied) {
|
|
126
|
+
return `# Stash ${verb}\n✓ ${stashRef} → ${verb}`;
|
|
127
|
+
}
|
|
128
|
+
return `# Stash ${verb} (failed)\n✗ ${stashRef}\n\n\`\`\`\n${output}\n\`\`\``;
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
}
|