@rethunk/mcp-multi-root-git 2.3.3 → 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.
Files changed (42) hide show
  1. package/AGENTS.md +30 -3
  2. package/CHANGELOG.md +61 -0
  3. package/HUMANS.md +106 -2
  4. package/README.md +15 -1
  5. package/dist/server/batch-commit-tool.js +244 -19
  6. package/dist/server/coverage.js +22 -0
  7. package/dist/server/git-diff-tool.js +132 -0
  8. package/dist/server/git-fetch-tool.js +131 -0
  9. package/dist/server/git-push-tool.js +8 -1
  10. package/dist/server/git-show-tool.js +148 -0
  11. package/dist/server/git-stash-tool.js +131 -0
  12. package/dist/server/git-tag-tool.js +162 -0
  13. package/dist/server/git.js +18 -2
  14. package/dist/server/roots.js +8 -4
  15. package/dist/server/test-harness.js +77 -6
  16. package/dist/server/tool-parameter-schemas.js +94 -0
  17. package/dist/server/tools.js +11 -0
  18. package/docs/install.md +19 -2
  19. package/docs/mcp-tools.md +235 -5
  20. package/package.json +15 -8
  21. package/schemas/batch_commit.json +125 -0
  22. package/schemas/git_cherry_pick.json +63 -0
  23. package/schemas/git_diff.json +62 -0
  24. package/schemas/git_diff_summary.json +75 -0
  25. package/schemas/git_fetch.json +52 -0
  26. package/schemas/git_inventory.json +74 -0
  27. package/schemas/git_log.json +75 -0
  28. package/schemas/git_merge.json +79 -0
  29. package/schemas/git_parity.json +74 -0
  30. package/schemas/git_push.json +50 -0
  31. package/schemas/git_reset_soft.json +42 -0
  32. package/schemas/git_show.json +39 -0
  33. package/schemas/git_stash_apply.json +44 -0
  34. package/schemas/git_stash_list.json +30 -0
  35. package/schemas/git_status.json +49 -0
  36. package/schemas/git_tag.json +50 -0
  37. package/schemas/git_worktree_add.json +52 -0
  38. package/schemas/git_worktree_list.json +30 -0
  39. package/schemas/git_worktree_remove.json +48 -0
  40. package/schemas/index.json +108 -0
  41. package/schemas/list_presets.json +44 -0
  42. package/tool-parameters.schema.json +1125 -0
@@ -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
- return `# Push\n✓ ${branch} → ${upstream}`;
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
+ }
@@ -0,0 +1,162 @@
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
+ /**
10
+ * Get the SHA of a given ref (tag, commit, branch, etc).
11
+ */
12
+ async function getRefSha(gitTop, ref) {
13
+ const result = await spawnGitAsync(gitTop, ["rev-parse", ref]);
14
+ if (!result.ok)
15
+ return null;
16
+ return result.stdout.trim();
17
+ }
18
+ /**
19
+ * Check if a tag is annotated or lightweight.
20
+ */
21
+ async function getTagType(gitTop, tag) {
22
+ // For annotated tags, `git cat-file -t <tag>` returns "tag"
23
+ // For lightweight tags, it returns "commit"
24
+ const result = await spawnGitAsync(gitTop, ["cat-file", "-t", tag]);
25
+ if (!result.ok)
26
+ return null;
27
+ const type = result.stdout.trim();
28
+ if (type === "tag")
29
+ return "annotated";
30
+ if (type === "commit")
31
+ return "lightweight";
32
+ return null;
33
+ }
34
+ // ---------------------------------------------------------------------------
35
+ // Tool registration
36
+ // ---------------------------------------------------------------------------
37
+ export function registerGitTagTool(server) {
38
+ server.addTool({
39
+ name: "git_tag",
40
+ description: "Create, delete, or inspect git tags. Create annotated tags (with message) or lightweight tags (ref only). " +
41
+ "Returns tag name, type, and SHA.",
42
+ annotations: {
43
+ readOnlyHint: false,
44
+ destructiveHint: true,
45
+ },
46
+ parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true, allWorkspaceRoots: true })
47
+ .pick({
48
+ workspaceRoot: true,
49
+ rootIndex: true,
50
+ format: true,
51
+ })
52
+ .extend({
53
+ tag: z.string().min(1).describe("Tag name (e.g. 'v1.2.3')."),
54
+ message: z
55
+ .string()
56
+ .optional()
57
+ .describe("If provided, create an annotated tag with this message. If absent, create a lightweight tag."),
58
+ ref: z
59
+ .string()
60
+ .optional()
61
+ .describe("Commit/ref to tag (default: HEAD). Ignored if `delete` is true."),
62
+ delete: z
63
+ .boolean()
64
+ .optional()
65
+ .default(false)
66
+ .describe("If true, delete the named tag instead of creating it."),
67
+ }),
68
+ execute: async (args) => {
69
+ const pre = requireSingleRepo(server, args);
70
+ if (!pre.ok)
71
+ return jsonRespond(pre.error);
72
+ const gitTop = pre.gitTop;
73
+ const tag = args.tag.trim();
74
+ if (!tag) {
75
+ return jsonRespond({ error: "tag_empty" });
76
+ }
77
+ // Validate tag name: no shell metacharacters
78
+ if (!isSafeGitUpstreamToken(tag)) {
79
+ return jsonRespond({ error: "tag_unsafe", tag });
80
+ }
81
+ // Handle deletion
82
+ if (args.delete === true) {
83
+ const delResult = await spawnGitAsync(gitTop, ["tag", "-d", tag]);
84
+ if (!delResult.ok) {
85
+ return jsonRespond({
86
+ error: "tag_delete_failed",
87
+ detail: (delResult.stderr || delResult.stdout).trim(),
88
+ });
89
+ }
90
+ if (args.format === "json") {
91
+ return jsonRespond({
92
+ tag,
93
+ type: "deleted",
94
+ sha: "", // Deleted tags have no SHA
95
+ });
96
+ }
97
+ return `Deleted tag: ${tag}`;
98
+ }
99
+ // Determine the ref to tag (default HEAD)
100
+ const ref = (args.ref ?? "HEAD").trim();
101
+ if (!isSafeGitUpstreamToken(ref)) {
102
+ return jsonRespond({ error: "ref_unsafe", ref });
103
+ }
104
+ // Get the SHA of the ref to tag
105
+ const sha = await getRefSha(gitTop, ref);
106
+ if (!sha) {
107
+ return jsonRespond({
108
+ error: "ref_not_found",
109
+ ref,
110
+ });
111
+ }
112
+ // Create tag (annotated or lightweight)
113
+ const tagArgs = ["tag"];
114
+ if (args.message) {
115
+ // Annotated tag
116
+ tagArgs.push("-a", "-m", args.message);
117
+ }
118
+ else {
119
+ // Lightweight tag (just the tag name and ref)
120
+ }
121
+ tagArgs.push(tag, ref);
122
+ const createResult = await spawnGitAsync(gitTop, tagArgs);
123
+ if (!createResult.ok) {
124
+ return jsonRespond({
125
+ error: "tag_create_failed",
126
+ detail: (createResult.stderr || createResult.stdout).trim(),
127
+ });
128
+ }
129
+ // Verify the tag was created and get its type
130
+ const tagType = await getTagType(gitTop, tag);
131
+ if (!tagType) {
132
+ return jsonRespond({
133
+ error: "tag_verification_failed",
134
+ tag,
135
+ });
136
+ }
137
+ const result = {
138
+ tag,
139
+ type: tagType,
140
+ sha,
141
+ };
142
+ if (args.format === "json") {
143
+ return jsonRespond(result);
144
+ }
145
+ // Markdown output
146
+ const lines = [];
147
+ lines.push(`# Tag: ${tag}`);
148
+ lines.push("");
149
+ lines.push(`**Type:** ${tagType}`);
150
+ lines.push(`**SHA:** \`${sha}\``);
151
+ if (args.message) {
152
+ lines.push("");
153
+ lines.push("**Message:**");
154
+ lines.push("");
155
+ lines.push("```");
156
+ lines.push(args.message);
157
+ lines.push("```");
158
+ }
159
+ return lines.join("\n");
160
+ },
161
+ });
162
+ }
@@ -1,8 +1,24 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
+ import { cpus } from "node:os";
3
4
  import { join } from "node:path";
4
- /** Parallel git subprocesses for inventory rows and git_status submodule rows. */
5
- export const GIT_SUBPROCESS_PARALLELISM = 4;
5
+ /**
6
+ * Parallel git subprocesses for inventory rows and git_status submodule rows.
7
+ * Reads from GIT_SUBPROCESS_PARALLELISM env var (default 4), clamped to [1, 2×CPU_COUNT].
8
+ */
9
+ function resolveGitSubprocessParallelism() {
10
+ const env = process.env.GIT_SUBPROCESS_PARALLELISM;
11
+ if (env) {
12
+ const n = Number.parseInt(env, 10);
13
+ if (!Number.isNaN(n) && n >= 1) {
14
+ const cpuCount = cpus().length;
15
+ const maxParallel = cpuCount * 2;
16
+ return Math.min(n, maxParallel);
17
+ }
18
+ }
19
+ return 4;
20
+ }
21
+ export const GIT_SUBPROCESS_PARALLELISM = resolveGitSubprocessParallelism();
6
22
  let gitPathState = "unknown";
7
23
  const GIT_NOT_FOUND_BODY = {
8
24
  error: "git_not_found",
@@ -15,12 +15,16 @@ function uriToPath(uri) {
15
15
  }
16
16
  function listFileRoots(server) {
17
17
  const sessions = server.sessions;
18
- const roots = sessions[0]?.roots ?? [];
19
18
  const paths = [];
20
- for (const root of roots) {
21
- const p = uriToPath(root.uri);
22
- if (p)
19
+ const seen = new Set();
20
+ for (const session of sessions) {
21
+ for (const root of session.roots ?? []) {
22
+ const p = uriToPath(root.uri);
23
+ if (!p || seen.has(p))
24
+ continue;
25
+ seen.add(p);
23
26
  paths.push(p);
27
+ }
24
28
  }
25
29
  return paths;
26
30
  }
@@ -16,7 +16,8 @@
16
16
  * const result = await tool({ workspaceRoot: dir, commits: [...] });
17
17
  * // result is string (markdown) or JSON-parseable string
18
18
  */
19
- import { mkdtempSync, rmSync } from "node:fs";
19
+ import { execFileSync } from "node:child_process";
20
+ import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
20
21
  import { tmpdir } from "node:os";
21
22
  import { join } from "node:path";
22
23
  // Stub context — no tool currently uses context
@@ -54,15 +55,25 @@ export function cleanupTmpPaths() {
54
55
  rmSync(p, { recursive: true, force: true });
55
56
  }
56
57
  }
58
+ export function writeTestGitConfig(repo) {
59
+ appendFileSync(join(repo, ".git", "config"), "\n[user]\n\temail = test@example.com\n\tname = Test User\n[commit]\n\tgpgsign = false\n");
60
+ }
57
61
  // ---------------------------------------------------------------------------
58
62
  // Fake server
59
63
  // ---------------------------------------------------------------------------
60
- function makeFakeServer() {
64
+ function makeFakeServer(roots = []) {
61
65
  const tools = [];
62
66
  const server = {
63
- sessions: [],
67
+ sessions: [
68
+ {
69
+ roots: roots.map((uri) => ({ uri })),
70
+ },
71
+ ],
64
72
  addTool(tool) {
65
- tools.push({ name: tool.name, execute: tool.execute });
73
+ tools.push({ name: tool.name, parameters: tool.parameters, execute: tool.execute });
74
+ },
75
+ addResource() {
76
+ // Resource tests do not need transport behavior; tool-surface tests only need registration to complete.
66
77
  },
67
78
  };
68
79
  return { server, tools };
@@ -75,8 +86,8 @@ function makeFakeServer() {
75
86
  * The returned function accepts tool args (always include `workspaceRoot`)
76
87
  * and returns the raw result as a string.
77
88
  */
78
- export function captureTool(register, toolName) {
79
- const { server, tools } = makeFakeServer();
89
+ export function captureTool(register, toolName, roots = []) {
90
+ const { server, tools } = makeFakeServer(roots);
80
91
  register(server);
81
92
  const pick = toolName ? tools.find((t) => t.name === toolName) : tools[0];
82
93
  if (!pick) {
@@ -89,3 +100,63 @@ export function captureTool(register, toolName) {
89
100
  return JSON.stringify(result);
90
101
  };
91
102
  }
103
+ export function captureToolDefinitions(register) {
104
+ const { server, tools } = makeFakeServer();
105
+ register(server);
106
+ return tools;
107
+ }
108
+ // ---------------------------------------------------------------------------
109
+ // Shared git test helpers (extracted from per-file duplication)
110
+ // ---------------------------------------------------------------------------
111
+ /** Execute git command with standard test environment and encoding. */
112
+ export function gitCmd(cwd, ...args) {
113
+ const opts = {
114
+ cwd,
115
+ encoding: "utf8",
116
+ env: {
117
+ ...process.env,
118
+ GIT_AUTHOR_NAME: "Test User",
119
+ GIT_AUTHOR_EMAIL: "test@example.com",
120
+ GIT_COMMITTER_NAME: "Test User",
121
+ GIT_COMMITTER_EMAIL: "test@example.com",
122
+ GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z",
123
+ GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z",
124
+ },
125
+ };
126
+ return execFileSync("git", args, opts);
127
+ }
128
+ /** Initialize a basic git repo with test config. */
129
+ export function makeRepo(prefix = "mcp-test-repo-") {
130
+ const dir = mkTmpDir(prefix);
131
+ gitCmd(dir, "init", "-b", "main");
132
+ writeTestGitConfig(dir);
133
+ return dir;
134
+ }
135
+ /** Initialize a repo with a seed commit (useful for branch/cherry-pick tests). */
136
+ export function makeRepoWithSeed(prefix = "mcp-test-repo-") {
137
+ const dir = makeRepo(prefix);
138
+ writeFileSync(join(dir, "seed.txt"), "seed\n");
139
+ gitCmd(dir, "add", "seed.txt");
140
+ gitCmd(dir, "commit", "-m", "chore: seed");
141
+ return dir;
142
+ }
143
+ /** Initialize work repo + bare remote with tracking set up. */
144
+ export function makeRepoWithUpstream(workPrefix = "mcp-work-", remotePrefix = "mcp-remote-") {
145
+ const remote = mkTmpDir(remotePrefix);
146
+ gitCmd(remote, "init", "--bare", "-b", "main");
147
+ const work = makeRepoWithSeed(workPrefix);
148
+ gitCmd(work, "remote", "add", "origin", remote);
149
+ gitCmd(work, "push", "-u", "origin", "main");
150
+ return { work, remote };
151
+ }
152
+ /** Initialize a git repo with main branch and test config. */
153
+ export function gitInitMain(dir) {
154
+ gitCmd(dir, "init", "-b", "main");
155
+ writeTestGitConfig(dir);
156
+ }
157
+ /** Add a commit to a repo with specified file content. */
158
+ export function addCommit(dir, file, content, message) {
159
+ writeFileSync(join(dir, file), content);
160
+ gitCmd(dir, "add", file);
161
+ gitCmd(dir, "commit", "-m", message);
162
+ }