Greasy Fork

Greasy Fork is available in English.

GitHub PR Squasher

One-click tool to squash GitHub Pull Requests. Creates a new PR with squashed commits and preserves the description.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         GitHub PR Squasher
// @namespace    https://github.com/balakumardev/github-pr-squasher
// @version      2.0
// @description  One-click tool to squash GitHub Pull Requests. Creates a new PR with squashed commits and preserves the description.
// @author       Bala Kumar
// @license      MIT
// @match        https://github.com/*
// @match        https://*.github.com/*
// @match        https://*.github.io/*
// @match        https://*.githubusercontent.com/*
// @icon         https://github.githubassets.com/favicons/favicon.svg
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @connect      api.github.com
// @connect      *
// @supportURL   https://github.com/balakumardev/github-pr-squasher/issues
// @homepage     https://github.com/balakumardev/github-pr-squasher
// ==/UserScript==

(function() {
    'use strict';

    const DEBUG = true;

    // Add settings menu to Tampermonkey
    GM_registerMenuCommand('Set GitHub Token', async () => {
        const token = prompt('Enter your GitHub Personal Access Token (Classic):', GM_getValue('github_token', ''));
        if (token !== null) {
            if (token.startsWith('ghp_')) {
                await GM_setValue('github_token', token);
                alert('Token saved! Please refresh the page.');
            } else {
                alert('Invalid token format. Token should start with "ghp_"');
            }
        }
    });

    function debugLog(...args) {
        if (DEBUG) console.log('[PR Squasher]', ...args);
    }

    async function getGitHubToken() {
        const token = GM_getValue('github_token');
        if (!token) {
            throw new Error('GitHub token not set. Click on the Tampermonkey icon and select "Set GitHub Token"');
        }
        return token;
    }

    async function githubAPI(endpoint, method = 'GET', body = null) {
        debugLog(`API Call: ${method} ${endpoint}`);
        if (body) debugLog('Request Body:', body);

        const token = await getGitHubToken();

        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: method,
                url: `https://api.github.com${endpoint}`,
                headers: {
                    'Authorization': `Bearer ${token}`,
                    'Accept': 'application/vnd.github.v3+json',
                    'Content-Type': 'application/json',
                },
                data: body ? JSON.stringify(body) : null,
                onload: function(response) {
                    debugLog(`Response ${endpoint}:`, {
                        status: response.status,
                        statusText: response.statusText,
                        responseText: response.responseText.substring(0, 500) + (response.responseText.length > 500 ? '...' : '')
                    });

                    if (response.status >= 200 && response.status < 300) {
                        resolve(JSON.parse(response.responseText || '{}'));
                    } else {
                        reject(new Error(`GitHub API error: ${response.status} - ${response.responseText}`));
                    }
                },
                onerror: function(error) {
                    debugLog('Request failed:', error);
                    reject(error);
                }
            });
        });
    }

    async function handleSquash() {
        const button = document.getElementById('squash-button');
        button.disabled = true;
        button.innerHTML = '⏳ Starting...';

        try {
            await getGitHubToken();

            const prInfo = {
                owner: window.location.pathname.split('/')[1],
                repo: window.location.pathname.split('/')[2],
                prNumber: window.location.pathname.split('/')[4],
                branch: document.querySelector('.head-ref').innerText.trim(),
                title: document.querySelector('.js-issue-title').innerText.trim(),
                baseBranch: document.querySelector('.base-ref').innerText.trim()
            };
            debugLog('PR Info:', prInfo);

            button.innerHTML = '⏳ Getting PR details...';
            const prDetails = await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/pulls/${prInfo.prNumber}`);
            debugLog('PR Details:', prDetails);
            
            // Get the exact PR description from the API to preserve formatting
            prInfo.description = prDetails.body || '';

            // Get the latest base branch commit
            button.innerHTML = '⏳ Getting latest base branch...';
            const latestBaseRef = await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/git/refs/heads/${prInfo.baseBranch}`);
            const latestBaseSha = latestBaseRef.object.sha;
            debugLog('Latest Base SHA:', latestBaseSha);

            // Get the comparison between base and head
            button.innerHTML = '⏳ Comparing changes...';
            const comparison = await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/compare/${prDetails.base.sha}...${prDetails.head.sha}`);
            debugLog('Comparison:', comparison);

            // Create new branch name
            const timestamp = new Date().getTime();
            const newBranchName = `squashed-pr-${prInfo.prNumber}-${timestamp}`;
            debugLog('New Branch Name:', newBranchName);

            // Create new branch from the latest base
            button.innerHTML = '⏳ Creating new branch...';
            await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/git/refs`, 'POST', {
                ref: `refs/heads/${newBranchName}`,
                sha: latestBaseSha
            });

            // Get the PR commits to form a proper commit message
            button.innerHTML = '⏳ Getting PR commits...';
            const commits = await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/pulls/${prInfo.prNumber}/commits`);
            
            // Create a combined commit message
            let commitMessage = `${prInfo.title}\n\n`;
            if (prInfo.description) {
                commitMessage += `${prInfo.description}\n\n`;
            }
            commitMessage += `Squashed commits from #${prInfo.prNumber}:\n\n`;
            
            commits.forEach(commit => {
                commitMessage += `* ${commit.commit.message.split('\n')[0]}\n`;
            });

            // Get the PR files to apply changes
            button.innerHTML = '⏳ Getting PR changes...';
            const files = await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/pulls/${prInfo.prNumber}/files`);
            debugLog(`PR has ${files.length} changed files`);

            // Get the latest tree from the base branch
            const baseTree = await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/git/trees/${latestBaseSha}`);
            
            // Create a new tree with the changes
            button.innerHTML = '⏳ Creating new tree with changes...';
            
            // For each changed file, we need to get its content
            const treeItems = [];
            for (const file of files) {
                if (file.status === 'removed') {
                    // For deleted files, we don't include them in the new tree
                    treeItems.push({
                        path: file.filename,
                        mode: '100644',
                        type: 'blob',
                        sha: null // null SHA means delete the file
                    });
                } else {
                    // For added or modified files, get the content from the head branch
                    const fileContent = await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/contents/${file.filename}?ref=${prDetails.head.ref}`);
                    
                    // If the file is binary or too large, use its SHA directly
                    if (fileContent.encoding === 'base64') {
                        treeItems.push({
                            path: file.filename,
                            mode: '100644',
                            type: 'blob',
                            content: atob(fileContent.content.replace(/\s/g, ''))
                        });
                    } else {
                        // For other cases (like submodules or very large files), use the SHA
                        treeItems.push({
                            path: file.filename,
                            mode: '100644',
                            type: 'blob',
                            sha: fileContent.sha
                        });
                    }
                }
            }
            
            // Create a new tree
            const newTree = await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/git/trees`, 'POST', {
                base_tree: latestBaseSha,
                tree: treeItems
            });
            
            // Create the squashed commit
            button.innerHTML = '⏳ Creating squashed commit...';
            const newCommit = await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/git/commits`, 'POST', {
                message: commitMessage,
                tree: newTree.sha,
                parents: [latestBaseSha]
            });
            
            // Update the new branch to point to the squashed commit
            button.innerHTML = '⏳ Updating branch...';
            await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/git/refs/heads/${newBranchName}`, 'PATCH', {
                sha: newCommit.sha,
                force: true
            });

            // Create new PR with the exact same description
            button.innerHTML = '⏳ Creating new PR...';
            const newPR = await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/pulls`, 'POST', {
                title: `${prInfo.title} (Squashed)`,
                head: newBranchName,
                base: prInfo.baseBranch,
                body: `${prInfo.description}\n\n---\n_Squashed version of #${prInfo.prNumber}_`
            });

            // Close original PR
            button.innerHTML = '⏳ Closing original PR...';
            await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/pulls/${prInfo.prNumber}`, 'PATCH', {
                state: 'closed'
            });

            // Redirect to the new PR
            window.location.href = newPR.html_url;

        } catch (error) {
            console.error('Failed to squash PR:', error);
            debugLog('Error details:', error);
            alert(`Failed to squash PR: ${error.message}\nCheck console for details`);
            button.disabled = false;
            button.innerHTML = '🔄 Squash & Recreate PR';
        }
    }

    function addSquashButton() {
        if (window.location.href.includes('/pull/')) {
            const actionBar = document.querySelector('.gh-header-actions');
            if (actionBar && !document.getElementById('squash-button')) {
                const squashButton = document.createElement('button');
                squashButton.id = 'squash-button';
                squashButton.className = 'btn btn-sm btn-primary';
                squashButton.innerHTML = '🔄 Squash & Recreate PR';
                squashButton.onclick = handleSquash;
                actionBar.appendChild(squashButton);
            }
        }
    }

    // Add button when page loads
    addSquashButton();

    // Add button when navigation occurs
    const observer = new MutationObserver(() => {
        if (window.location.href.includes('/pull/')) {
            addSquashButton();
        }
    });

    observer.observe(document.body, { childList: true, subtree: true });
})();