diff --git a/src/github/operations/branch-cleanup.ts b/src/github/operations/branch-cleanup.ts index 88de6de..612ef90 100644 --- a/src/github/operations/branch-cleanup.ts +++ b/src/github/operations/branch-cleanup.ts @@ -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})`; } diff --git a/src/github/operations/resign-commits.ts b/src/github/operations/resign-commits.ts new file mode 100644 index 0000000..fb701b2 --- /dev/null +++ b/src/github/operations/resign-commits.ts @@ -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 { + 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 { + 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(); + 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; + } +} diff --git a/src/mcp/github-file-ops-server.ts b/src/mcp/github-file-ops-server.ts index e3da6f4..40f8a91 100644 --- a/src/mcp/github-file-ops-server.ts +++ b/src/mcp/github-file-ops-server.ts @@ -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 { + 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, };