/ opencodehub / src / lib / git-rewrite.ts
swadhinbiswas Enforce path permissions for PR rewrite operations · 2/19/2026
677e3c0
124 lines · 4.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124

import { simpleGit, SimpleGit } from "simple-git";
import { acquireRepo, releaseRepo } from "./git-storage";

export type RewriteOperation =
    | { type: "pick"; hash: string }
    | { type: "reword"; hash: string; newMessage: string }
    | { type: "squash"; hash: string } // Squash this into previous
    | { type: "drop"; hash: string };

export async function rewriteBranchHistory(
    repoOwner: string,
    repoName: string,
    baseBranch: string,
    headBranch: string,
    operations: RewriteOperation[]
): Promise<void> {
    const repoPath = await acquireRepo(repoOwner, repoName);
    const git = simpleGit(repoPath);

    try {
        await git.fetch();

        // 1. Reset to base
        // Create a temp branch to do the work
        const tempBranch = `rewrite-${Date.now()}`;
        await git.checkout(baseBranch);
        await git.pull();
        await git.checkoutLocalBranch(tempBranch);

        // 2. Perform operations
        // We iterate through operations.
        // For squash, we need to handle "squash into previous".
        // The operations list should be in the Desired Order.

        // Example: [pick A, squash B, pick C] -> Result: A+B, C.

        for (let i = 0; i < operations.length; i++) {
            const op = operations[i];

            if (op.type === "drop") continue;

            try {
                if (op.type === "pick") {
                    // git cherry-pick <hash>
                    await git.raw(["cherry-pick", op.hash]);
                }
                else if (op.type === "reword") {
                    await git.raw(["cherry-pick", op.hash]);
                    await git.commit(op.newMessage, { "--amend": null });
                }
                else if (op.type === "squash") {
                    // Squash: cherry-pick -n, then commit --amend (to previous)
                    // If first commit is squash, it fails (cannot squash into nothing).
                    // We assume valid input from UI.

                    const previousMessage = await readCommitMessage(git, "HEAD");
                    const squashMessage = await readCommitMessage(git, op.hash);

                    // 1. Apply changes without commit
                    // cherry-pick -n <hash>
                    await git.raw(["cherry-pick", "-n", op.hash]);

                    // 2. Amend previous commit
                    const combinedMessage = combineCommitMessages(previousMessage, squashMessage);
                    await git.commit(combinedMessage, { "--amend": null });
                }
            } catch (e: any) {
                if (e.message.includes("conflict")) {
                    await git.raw(["cherry-pick", "--abort"]).catch(() => { });
                    throw new Error(`Conflict while processing commit ${op.hash}. Rewriting aborted.`);
                }
                throw e;
            }
        }

        // 3. Force push to head branch
        // Push tempBranch to origin/headBranch
        await git.push("origin", `${tempBranch}:${headBranch}`, { "--force": null });

        // Clean up
        await git.checkout(baseBranch);
        await git.deleteLocalBranch(tempBranch, true);

    } finally {
        await releaseRepo(repoOwner, repoName, true); // True because we pushed
    }
}

export async function getRewriteOperationFiles(
    repoOwner: string,
    repoName: string,
    operations: RewriteOperation[]
): Promise<string[]> {
    const repoPath = await acquireRepo(repoOwner, repoName);
    const git = simpleGit(repoPath);

    try {
        const files = new Set<string>();

        for (const op of operations) {
            const output = await git.raw(["show", "--pretty=format:", "--name-only", op.hash]);
            for (const line of output.split("\n")) {
                const path = line.trim();
                if (path.length > 0) files.add(path);
            }
        }

        return [...files.values()];
    } finally {
        await releaseRepo(repoOwner, repoName, false);
    }
}

async function readCommitMessage(git: SimpleGit, rev: string): Promise<string> {
    return (await git.raw(["show", "-s", "--format=%B", rev])).trim();
}

function combineCommitMessages(previousMessage: string, squashMessage: string): string {
    if (!previousMessage) return squashMessage || "squashed commit";
    if (!squashMessage || previousMessage === squashMessage) return previousMessage;
    return `${previousMessage}\n\n${squashMessage}`.trim();
}