Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
172 changes: 172 additions & 0 deletions test/test-push-files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
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;
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 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