swadhinbiswas Enforce path permissions for PR rewrite operations · 2/19/2026
677e3c0
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();
}