Fix file mode permissions in commit signing operations

- Add getFileMode() function to detect proper file permissions
- Update commit_files tool to preserve execute permissions
- Support all Git file modes: 100644, 100755, 040000, 120000
- Prevent executable files from losing execute permissions
- Add resign-commits.ts and branch cleanup logic for commit signing

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Chris Lloyd
2025-08-15 12:58:37 -07:00
committed by Ashwin Bhat
parent e55fe60b4e
commit baeaddf546
3 changed files with 239 additions and 4 deletions

View File

@@ -1,6 +1,8 @@
import type { Octokits } from "../api/client";
import { GITHUB_SERVER_URL } from "../api/config";
import { $ } from "bun";
import { resignCommits } from "./resign-commits";
import { parseGitHubContext } from "../context";
export async function checkAndCommitOrDeleteBranch(
octokit: Octokits,
@@ -101,7 +103,48 @@ export async function checkAndCommitOrDeleteBranch(
shouldDeleteBranch = true;
}
} else {
// Only add branch link if there are commits
// Branch has commits
console.log(
`Branch ${claudeBranch} has ${comparison.total_commits} commits`,
);
// First, check for any uncommitted changes
try {
const gitStatus = await $`git status --porcelain`.quiet();
const hasUncommittedChanges =
gitStatus.stdout.toString().trim().length > 0;
if (hasUncommittedChanges) {
console.log(
"Found uncommitted changes after commits, committing them...",
);
// Add all changes
await $`git add -A`;
// Commit any remaining changes
const runId = process.env.GITHUB_RUN_ID || "unknown";
const commitMessage = `Auto-commit: Save remaining changes from Claude\n\nRun ID: ${runId}`;
await $`git commit -m ${commitMessage}`;
// Push the changes
await $`git push origin ${claudeBranch}`;
console.log(
"✅ Successfully committed and pushed remaining changes",
);
}
} catch (gitError) {
console.error("Error checking for remaining changes:", gitError);
}
// Re-sign all commits made by Claude if commit signing is enabled
if (useCommitSigning) {
const context = parseGitHubContext();
await resignCommits(claudeBranch, baseBranch, octokit, context);
}
// Add branch link since there are commits
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`;
branchLink = `\n[View branch](${branchUrl})`;
}

View File

@@ -0,0 +1,159 @@
import type { Octokits } from "../api/client";
import type { GitHubContext } from "../context";
import { $ } from "bun";
interface CommitInfo {
sha: string;
message: string;
author: {
name: string;
email: string;
date: string;
};
files: string[];
}
/**
* Get all commits made by claude[bot] on the current branch that aren't on the base branch
*/
async function getClaudeCommits(baseBranch: string): Promise<CommitInfo[]> {
try {
// Get commits that are on current branch but not on base branch
const output =
await $`git log ${baseBranch}..HEAD --pretty=format:"%H|%an|%ae|%aI|%B%x00" --name-only`.quiet();
const rawCommits = output.stdout
.toString()
.trim()
.split("\x00")
.filter((c) => c);
const commits: CommitInfo[] = [];
for (const rawCommit of rawCommits) {
const lines = rawCommit.trim().split("\n");
if (lines.length === 0) continue;
const firstLine = lines[0];
if (!firstLine) continue;
const parts = firstLine.split("|");
if (parts.length < 4) continue;
const [sha, authorName, authorEmail, date, ...rest] = parts;
// Find where the file list starts (after empty line)
let messageEndIndex = rest.findIndex((line) => line === "");
if (messageEndIndex === -1) messageEndIndex = rest.length;
const message = rest.slice(0, messageEndIndex).join("\n");
const files = rest.slice(messageEndIndex + 1).filter((f) => f);
// Only include commits by claude[bot]
if (
authorName &&
authorEmail &&
(authorName === "claude[bot]" || authorEmail.includes("claude"))
) {
commits.push({
sha: sha || "",
message,
author: {
name: authorName,
email: authorEmail,
date: date || new Date().toISOString(),
},
files,
});
}
}
return commits.reverse(); // Return in chronological order
} catch (error) {
console.error("Error getting Claude commits:", error);
return [];
}
}
/**
* Re-create commits using GitHub API to get them signed
*/
export async function resignCommits(
branch: string,
baseBranch: string,
client: Octokits,
context: GitHubContext,
): Promise<boolean> {
try {
console.log(
`Checking for unsigned commits by Claude on branch ${branch}...`,
);
// Get all commits made by Claude
const claudeCommits = await getClaudeCommits(baseBranch);
if (claudeCommits.length === 0) {
console.log("No commits by Claude found to re-sign");
return false;
}
console.log(`Found ${claudeCommits.length} commits by Claude to re-sign`);
// Get the base commit (last commit before Claude's commits)
const baseCommitOutput = await $`git rev-parse ${baseBranch}`.quiet();
const baseCommitSha = baseCommitOutput.stdout.toString().trim();
// Create a mapping of old SHA to new SHA
const shaMapping = new Map<string, string>();
let currentParentSha = baseCommitSha;
for (const commit of claudeCommits) {
console.log(
`Re-signing commit: ${commit.sha.substring(0, 7)} - ${commit.message.split("\n")[0]}`,
);
// Get the tree SHA for this commit
const treeOutput = await $`git rev-parse ${commit.sha}^{tree}`.quiet();
const treeSha = treeOutput.stdout.toString().trim();
// Create the commit via API (which will sign it)
const { data: newCommit } = await client.rest.git.createCommit({
owner: context.repository.owner,
repo: context.repository.repo,
message: commit.message,
tree: treeSha,
parents: [currentParentSha],
author: {
name: commit.author.name,
email: commit.author.email,
date: commit.author.date,
},
});
console.log(` Created signed commit: ${newCommit.sha.substring(0, 7)}`);
shaMapping.set(commit.sha, newCommit.sha);
currentParentSha = newCommit.sha;
}
// Update the branch to point to the new commit
console.log(`Updating branch ${branch} to point to signed commits...`);
await client.rest.git.updateRef({
owner: context.repository.owner,
repo: context.repository.repo,
ref: `heads/${branch}`,
sha: currentParentSha,
force: true,
});
// Pull the updated branch locally
console.log("Pulling signed commits locally...");
await $`git fetch origin ${branch}`;
await $`git reset --hard origin/${branch}`;
console.log(`✅ Successfully re-signed ${claudeCommits.length} commits`);
return true;
} catch (error) {
console.error("Error re-signing commits:", error);
// Don't fail the action if we can't re-sign
return false;
}
}

View File

@@ -3,8 +3,9 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { readFile } from "fs/promises";
import { readFile, stat } from "fs/promises";
import { join } from "path";
import { constants } from "fs";
import fetch from "node-fetch";
import { GITHUB_API_URL } from "../github/api/config";
import { retryWithBackoff } from "../utils/retry";
@@ -162,6 +163,35 @@ async function getOrCreateBranchRef(
return baseSha;
}
// Get the appropriate Git file mode for a file
async function getFileMode(filePath: string): Promise<string> {
try {
const fileStat = await stat(filePath);
if (fileStat.isFile()) {
// Check if execute bit is set for user
if (fileStat.mode & constants.S_IXUSR) {
return "100755"; // Executable file
} else {
return "100644"; // Regular file
}
} else if (fileStat.isDirectory()) {
return "040000"; // Directory (tree)
} else if (fileStat.isSymbolicLink()) {
return "120000"; // Symbolic link
} else {
// Fallback for unknown file types
return "100644";
}
} catch (error) {
// If we can't stat the file, default to regular file
console.warn(
`Could not determine file mode for ${filePath}, using default: ${error}`,
);
return "100644";
}
}
// Commit files tool
server.tool(
"commit_files",
@@ -223,6 +253,9 @@ server.tool(
? filePath
: join(REPO_DIR, filePath);
// Get the proper file mode based on file permissions
const fileMode = await getFileMode(fullPath);
// Check if file is binary (images, etc.)
const isBinaryFile =
/\.(png|jpg|jpeg|gif|webp|ico|pdf|zip|tar|gz|exe|bin|woff|woff2|ttf|eot)$/i.test(
@@ -261,7 +294,7 @@ server.tool(
// Return tree entry with blob SHA
return {
path: filePath,
mode: "100644",
mode: fileMode,
type: "blob",
sha: blobData.sha,
};
@@ -270,7 +303,7 @@ server.tool(
const content = await readFile(fullPath, "utf-8");
return {
path: filePath,
mode: "100644",
mode: fileMode,
type: "blob",
content: content,
};