Skip to content
Closed
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).

#### Unreleased

##### Fixed

- `push_files` can now update files that already exist. Every commit action was hardcoded to `create`, and GitLab rejects that for a tracked path with `400 A file with this name already exists` — so the tool could only ever add new files. The action is now resolved per file with a `HEAD` request against the Repository Files API: `update` when the path exists in the target branch, `create` when it does not.

##### Added

- Add `list_group_members` tool for searching group members by name or username ([#631](https://github.com/zereight/gitlab-mcp/issues/631))
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -559,7 +559,7 @@ Register the skill directory in your AI client to get optimal tool usage guidanc
10. `create_repository` - Create a new GitLab project
11. `create_group` - Create a new GitLab group or subgroup (name, path, description, visibility, and optional parent_id)
12. `get_file_contents` - Get the contents of a file or directory from a GitLab project
13. `push_files` - Push multiple files to a GitLab project in a single commit
13. `push_files` - Create or update multiple files in a GitLab project in a single commit
14. `create_issue` - Create a new issue in a GitLab project
15. `create_merge_request` - Create a new merge request in a GitLab project
16. `fork_repository` - Fork a GitLab project to your account or specified namespace
Expand Down
2 changes: 1 addition & 1 deletion docs/tools/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Project search/creation/fork plus the Files API for reading and writing reposito
| [`search_repositories`](repositories.md#search_repositories) | Search for GitLab projects | 📖 |
| [`create_repository`](repositories.md#create_repository) | Create a new GitLab project | ✏️ |
| [`get_file_contents`](repositories.md#get_file_contents) | Get contents of a file or directory from a GitLab project | 📖 |
| [`push_files`](repositories.md#push_files) | Push multiple files in a single commit | ✏️ |
| [`push_files`](repositories.md#push_files) | Create or update multiple files in a single commit | ✏️ |
| [`create_or_update_file`](repositories.md#create_or_update_file) | Create or update a file in a GitLab project | ✏️ |
| [`fork_repository`](repositories.md#fork_repository) | Fork a project to your account or specified namespace | ✏️ |
| [`get_repository_tree`](repositories.md#get_repository_tree) | List files and directories in a repository | 📖 |
Expand Down
2 changes: 1 addition & 1 deletion docs/tools/repositories.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ Get contents of a file or directory from a GitLab project

*✏️ Writes*

Push multiple files in a single commit
Create or update multiple files in a single commit

**Parameters**

Expand Down
55 changes: 49 additions & 6 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4786,6 +4786,42 @@ function encodeRepoFilePayloadContent(content: string): string {
return content;
}

/**
* Check whether a path already exists in a branch.
*
* Uses HEAD on the Repository Files API so the check costs headers rather than the
* whole blob — a commit may carry several files, and their content is irrelevant here.
* A missing branch answers 404 just like a missing file, which is the right answer:
* a commit that also creates the branch has to use `create`.
*
* @param {string} projectId - The ID or URL-encoded path of the project
* @param {string} filePath - The path to look for, relative to the repository root
* @param {string} ref - The branch the commit targets
* @returns {Promise<boolean>} Whether the path is already tracked in that ref
*/
async function repositoryFileExists(
projectId: string,
filePath: string,
ref: string
): Promise<boolean> {
const url = new URL(
`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/files/${encodeURIComponent(filePath)}`
);
url.searchParams.append("ref", ref);

const response = await fetch(url.toString(), {
...getFetchConfig(),
method: "HEAD",
});

if (response.status === 404) {
return false;
}

await handleGitLabError(response);
return true;
}

/**
* Create or update a file in a GitLab project
* 파일 생성 또는 업데이트
Expand Down Expand Up @@ -4894,18 +4930,25 @@ async function createCommit(
`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/commits`
);

// The action has to be resolved per file: GitLab rejects `create` for a path that
// already exists ("A file with this name already exists") and `update` for one that
// does not. Assuming `create` made push_files unable to change any tracked file.
const resolvedActions = await Promise.all(
actions.map(async action => ({
action: (await repositoryFileExists(projectId, action.path, branch)) ? "update" : "create",
file_path: action.path,
content: encodeRepoFilePayloadContent(action.content),
encoding: GITLAB_REPO_FILE_ENCODING,
}))
);

const response = await fetch(url.toString(), {
...getFetchConfig(),
method: "POST",
body: JSON.stringify({
branch,
commit_message: message,
actions: actions.map(action => ({
action: "create",
file_path: action.path,
content: encodeRepoFilePayloadContent(action.content),
encoding: GITLAB_REPO_FILE_ENCODING,
})),
actions: resolvedActions,
}),
});

Expand Down
220 changes: 220 additions & 0 deletions test/test-push-files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import { describe, test } from "node:test";
import assert from "node:assert";
import { spawn } from "child_process";
import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";

const MOCK_TOKEN = "mock-token-push-files";
const PROJECT_ID = "42";
const BRANCH = "feature/update";

const MOCK_COMMIT = {
id: "0123456789abcdef0123456789abcdef01234567",
short_id: "01234567",
title: "Update files",
author_name: "Tester",
author_email: "tester@example.com",
authored_date: "2025-01-01T00:00:00.000Z",
committer_name: "Tester",
committer_email: "tester@example.com",
committed_date: "2025-01-01T00:00:00.000Z",
web_url: "https://gitlab.example.com/group/project/-/commit/01234567",
parent_ids: [],
};

interface CommitAction {
action: string;
file_path: string;
content: string;
}

async function callPushFiles(
args: Record<string, unknown>,
env: NodeJS.ProcessEnv
): Promise<unknown> {
return new Promise((resolve, reject) => {
const proc = spawn("node", ["build/index.js"], {
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env, ...env },
});

let output = "";
let errorOutput = "";
proc.stdout?.on("data", (d: Buffer) => (output += d));
proc.stderr?.on("data", (d: Buffer) => (errorOutput += d));

proc.on("close", code => {
if (code !== 0) {
return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
}

const line = output.split("\n").find(l => l.startsWith("{"));
if (!line) return reject(new Error("No JSON output found"));

try {
const response = JSON.parse(line);
if (response.error) {
reject(new Error(response.error?.message ?? String(response.error)));
} else {
const content = response.result?.content?.[0]?.text;
if (response.result?.isError) {
return reject(new Error(content ?? "Tool call failed"));
}
resolve(content ? JSON.parse(content) : response.result);
}
} catch (e) {
reject(e);
}
});

proc.stdin?.end(
JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: { name: "push_files", arguments: args },
}) + "\n"
);
});
}

/**
* Starts a mock GitLab where `existingPaths` are already tracked in the branch and
* everything else 404s, then runs one push_files call and returns the commit actions
* the server received.
*/
async function actionsForPush(
existingPaths: string[],
files: { file_path: string; content: string }[]
): Promise<CommitAction[]> {
const mockPort = await findMockServerPort();
const mockServer = new MockGitLabServer({ port: mockPort, validTokens: [MOCK_TOKEN] });
let receivedActions: CommitAction[] = [];

for (const path of existingPaths) {
mockServer.addMockHandler(
"head",
`/projects/${PROJECT_ID}/repository/files/${encodeURIComponent(path)}`,
(req, res) => {
assert.strictEqual(req.query.ref, BRANCH);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
res.status(200).end();
}
);
}
Comment thread
nikes marked this conversation as resolved.

mockServer.addMockHandler("post", `/projects/${PROJECT_ID}/repository/commits`, (req, res) => {
receivedActions = (req.body as { actions: CommitAction[] }).actions;
res.status(201).json(MOCK_COMMIT);
});

await mockServer.start();

try {
await callPushFiles(
{
project_id: PROJECT_ID,
branch: BRANCH,
commit_message: "Update files",
files,
},
{
GITLAB_API_URL: `${mockServer.getUrl()}/api/v4`,
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
}
);
} finally {
await mockServer.stop();
}

return receivedActions;
}

describe("When push_files commits a file that already exists", () => {
test("should send action 'update' instead of 'create'", async () => {
// Regression: every action was hardcoded to "create", and GitLab answers
// `400 A file with this name already exists` for a tracked path — so push_files
// could only ever add new files.
const actions = await actionsForPush(
["src/index.ts"],
[{ file_path: "src/index.ts", content: "export const x = 1;\n" }]
);

assert.strictEqual(actions.length, 1);
assert.strictEqual(actions[0].action, "update");
assert.strictEqual(actions[0].file_path, "src/index.ts");
});
});

describe("When push_files commits a file that does not exist", () => {
test("should send action 'create'", async () => {
const actions = await actionsForPush(
[],
[{ file_path: "docs/new-page.md", content: "# New\n" }]
);

assert.strictEqual(actions.length, 1);
assert.strictEqual(actions[0].action, "create");
});
});

describe("When checking whether a file exists fails", () => {
test("should propagate the error without sending a commit request", async () => {
const mockPort = await findMockServerPort();
const mockServer = new MockGitLabServer({ port: mockPort, validTokens: [MOCK_TOKEN] });
let commitRequested = false;

mockServer.addMockHandler(
"head",
`/projects/${PROJECT_ID}/repository/files/${encodeURIComponent("src/index.ts")}`,
(req, res) => {
assert.strictEqual(req.query.ref, BRANCH);
res.status(403).end();
}
);
mockServer.addMockHandler("post", `/projects/${PROJECT_ID}/repository/commits`, (_req, res) => {
commitRequested = true;
res.status(201).json(MOCK_COMMIT);
});

await mockServer.start();

try {
await assert.rejects(
callPushFiles(
{
project_id: PROJECT_ID,
branch: BRANCH,
commit_message: "Update files",
files: [{ file_path: "src/index.ts", content: "export const x = 1;\n" }],
},
{
GITLAB_API_URL: `${mockServer.getUrl()}/api/v4`,
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
}
),
/GitLab API error: 403 Forbidden/
);
} finally {
await mockServer.stop();
}

assert.strictEqual(commitRequested, false);
});
});

describe("When push_files commits a mix of new and existing files", () => {
test("should resolve the action per file", async () => {
const actions = await actionsForPush(
["src/index.ts"],
[
{ file_path: "src/index.ts", content: "export const x = 2;\n" },
{ file_path: "src/new-module.ts", content: "export const y = 3;\n" },
]
);

const byPath = Object.fromEntries(actions.map(a => [a.file_path, a.action]));
assert.deepStrictEqual(byPath, {
"src/index.ts": "update",
"src/new-module.ts": "create",
});
});
});
6 changes: 5 additions & 1 deletion test/utils/mock-gitlab-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,11 @@ export class MockGitLabServer {
this.setupRoutes();
}

public addMockHandler(method: "get" | "post" | "put" | "delete", path: string, handler: Handler) {
public addMockHandler(
method: "get" | "post" | "put" | "delete" | "head",
path: string,
handler: Handler
) {
// Note: path should be relative to /api/v4
const key = `${method.toUpperCase()}:${path}`;
console.log(`[MockServer] Adding custom handler: ${key}`);
Expand Down
2 changes: 1 addition & 1 deletion tools/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ export const allTools = [
},
{
name: "push_files",
description: "Push multiple files in a single commit",
description: "Create or update multiple files in a single commit",
inputSchema: toJSONSchema(PushFilesSchema),
},
{
Expand Down