Skip to content

Commit 6813211

Browse files
committed
feat: implement autoSquash!
Signed-off-by: Kipras Melnikovas <kipras@kipras.org>
1 parent b8c6016 commit 6813211

File tree

6 files changed

+203
-7
lines changed

6 files changed

+203
-7
lines changed

apply.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@ export const apply: BranchSequencerBase = (args) =>
2626
delayMsBetweenCheckouts: 0,
2727
behaviorOfGetBranchBoundaries: BehaviorOfGetBranchBoundaries["parse-from-not-yet-applied-state"],
2828
reverseCheckoutOrder: false,
29+
30+
/**
31+
* `apply` does not perform the rebase operation
32+
* and thus cannot fully modify local commit history,
33+
* thus `autoSquash` is disabled
34+
* (it would produce incorrect results otherwise).
35+
*/
36+
autoSquash: false,
2937
}).then(
3038
(ret) => (markThatApplied(args.pathToStackedRebaseDirInsideDotGit), ret) //
3139
);

autosquash.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/* eslint-disable indent */
2+
3+
import Git from "nodegit";
4+
5+
import { CommitAndBranchBoundary } from "./git-stacked-rebase";
6+
7+
import { Termination } from "./util/error";
8+
import { assertNever } from "./util/assertNever";
9+
10+
/**
11+
* the general approach on how to handle autosquashing
12+
* is the following, in order:
13+
*
14+
* 1. collect your commits,
15+
* 2. extend them with branch boundaries,
16+
* 3. re-order the "fixup!" and "squash!" commits,
17+
* 4. convert from objects to strings that are joined
18+
* with a newline and written to the git-rebase-todo file
19+
*
20+
*
21+
* if we were to do (3) before (2)
22+
* (which is what happens if we would use git's native rebase
23+
* to collect the commits),
24+
* then, in a situation where a commit with a "fixup!" or "squash!" subject
25+
* is the latest commit of any branch in the stack,
26+
* that commit will move not only itself, but it's branch as well.
27+
*
28+
* we don't want that obviously - we instead want the branch
29+
* to point to a commit that was before the "fixup!" or "squash!" commit
30+
* (and same applies if there were multiple "fixup!" / "squash!" commits in a row).
31+
*
32+
* see the `--no-autosquash` enforcement/limitation in the
33+
* `getWantedCommitsWithBranchBoundariesUsingNativeGitRebase` function.
34+
*
35+
*/
36+
export async function autosquash(repo: Git.Repository, extendedCommits: CommitAndBranchBoundary[]): Promise<void> {
37+
// type SHA = string;
38+
// const commitLookupTable: Map<SHA, Git.Commit> = new Map();
39+
const autoSquashableSummaryPrefixes = ["squash!", "fixup!"] as const;
40+
41+
for (let i = 0; i < extendedCommits.length; i++) {
42+
const commit = extendedCommits[i];
43+
44+
const summary: string = commit.commit.summary();
45+
const hasAutoSquashablePrefix = (prefix: string): boolean => summary.startsWith(prefix);
46+
47+
const autoSquashCommandIdx: number = autoSquashableSummaryPrefixes.findIndex(hasAutoSquashablePrefix);
48+
const shouldBeAutoSquashed = autoSquashCommandIdx !== -1;
49+
50+
if (!shouldBeAutoSquashed) {
51+
continue;
52+
}
53+
54+
const command = autoSquashableSummaryPrefixes[autoSquashCommandIdx];
55+
const targetedCommittish: string = summary.split(" ")[1];
56+
57+
/**
58+
* https://libgit2.org/libgit2/#HEAD/group/revparse
59+
*/
60+
// Git.Revparse.ext(target, )
61+
const target: Git.Object = await Git.Revparse.single(repo, targetedCommittish);
62+
const targetRev: Git.Object = await target.peel(Git.Object.TYPE.COMMIT);
63+
const targetType: number = await targetRev.type();
64+
const targetIsCommit: boolean = targetType === Git.Object.TYPE.COMMIT;
65+
66+
if (!targetIsCommit) {
67+
const msg =
68+
`\ntried to parse auto-squashable commit's target revision, but failed.` +
69+
`\ncommit = ${commit.commit.sha()} (${commit.commit.summary()})` +
70+
`\ncommand = ${command}` +
71+
`\ntarget = ${targetRev.id().tostrS()}` +
72+
`\ntarget type (expected ${Git.Object.TYPE.COMMIT}) = ${targetType}` +
73+
`\n\n`;
74+
75+
throw new Termination(msg);
76+
}
77+
78+
const indexOfTargetCommit: number = extendedCommits.findIndex((c) => !target.id().cmp(c.commit.id()));
79+
const wasNotFound = indexOfTargetCommit === -1;
80+
81+
if (wasNotFound) {
82+
const msg =
83+
`\ntried to re-order an auto-squashable commit, ` +
84+
`but the target commit was not within the commits that are being rebased.` +
85+
`\ncommit = ${commit.commit.sha()} (${commit.commit.summary()})` +
86+
`\ncommand = ${command}` +
87+
`\ntarget = ${targetRev.id().tostrS()}` +
88+
`\n\n`;
89+
90+
throw new Termination(msg);
91+
}
92+
93+
commit.commitCommand =
94+
command === "squash!"
95+
? "squash" //
96+
: command === "fixup!"
97+
? "fixup"
98+
: assertNever(command);
99+
100+
/**
101+
* first remove the commit from the array,
102+
* and only then insert it in the array.
103+
*
104+
* this will always work, and the opposite will never work
105+
* because of index mismatch:
106+
*
107+
* you cannot reference commit SHAs that will appear in the future,
108+
* only in the past.
109+
* thus, we know that an auto-squashable commit's target will always be
110+
* earlier in the history than the auto-squashable commit itself.
111+
*
112+
* thus, we first remove the auto-squashable commit,
113+
* so that the index of the target commit stays the same,
114+
* and only then insert the auto-squashable commit.
115+
*
116+
*
117+
* TODO optimal implementation with a linked list + a map
118+
*
119+
*/
120+
extendedCommits.splice(i, 1); // remove 1 element (`commit`)
121+
extendedCommits.splice(indexOfTargetCommit + 1, 0, commit); // insert the `commit` in the new position
122+
}
123+
}

branchSequencer.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import assert from "assert";
33

44
import Git from "nodegit";
55

6-
import { getWantedCommitsWithBranchBoundariesOurCustomImpl } from "./git-stacked-rebase";
6+
import { AutoSquash, getWantedCommitsWithBranchBoundariesOurCustomImpl } from "./git-stacked-rebase";
77

88
import { createExecSyncInRepo } from "./util/execSyncInRepo";
99
import { Termination } from "./util/error";
@@ -23,6 +23,7 @@ export type GetBranchesCtx = BranchRefs & {
2323
rootLevelCommandName: string;
2424
repo: Git.Repository;
2525
pathToStackedRebaseTodoFile: string;
26+
autoSquash: boolean;
2627
};
2728
export type SimpleBranchAndCommit = {
2829
commitSHA: string | null;
@@ -117,7 +118,8 @@ const getBoundariesInclInitialWithSipleBranchTraversal: GetBoundariesInclInitial
117118
getWantedCommitsWithBranchBoundariesOurCustomImpl(
118119
argsBase.repo, //
119120
argsBase.initialBranch,
120-
argsBase.currentBranch
121+
argsBase.currentBranch,
122+
argsBase.autoSquash
121123
).then((boundaries) =>
122124
boundaries
123125
.filter((b) => !!b.branchEnd?.length)
@@ -240,6 +242,14 @@ export type BranchSequencerArgs = BranchSequencerArgsBase & {
240242
*
241243
*/
242244
reverseCheckoutOrder: boolean;
245+
246+
/**
247+
* almost feels like it should default to `false`,
248+
* or even shouldn't be selectable here & always be `false`.
249+
*
250+
* TODO further investigation
251+
*/
252+
autoSquash: AutoSquash;
243253
};
244254

245255
export type BranchSequencerBase = (args: BranchSequencerArgsBase) => Promise<void>;
@@ -260,6 +270,8 @@ export const branchSequencer: BranchSequencer = async ({
260270
currentBranch,
261271
//
262272
reverseCheckoutOrder = false,
273+
//
274+
autoSquash,
263275
}) => {
264276
const execSyncInRepo = createExecSyncInRepo(repo);
265277

@@ -276,6 +288,7 @@ export const branchSequencer: BranchSequencer = async ({
276288
rootLevelCommandName,
277289
initialBranch,
278290
currentBranch,
291+
autoSquash,
279292
})
280293
).map((boundary) => {
281294
boundary.branchEndFullName = boundary.branchEndFullName.map((x) => x.replace("refs/heads/", ""));

configKeys.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ export const configKeyPrefix = "stackedrebase" as const;
33
export const configKeys = {
44
gpgSign: "commit.gpgSign",
55
autoApplyIfNeeded: `${configKeyPrefix}.autoApplyIfNeeded`,
6+
autoSquash: "rebase.autoSquash",
67
} as const;

forcePush.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,4 +133,12 @@ export const forcePush: BranchSequencerBase = (argsBase) =>
133133
* would solve this.
134134
*/
135135
reverseCheckoutOrder: true,
136+
137+
/**
138+
* `forcePush` does not perform the rebase operation
139+
* and thus cannot fully modify local commit history,
140+
* thus `autoSquash` is disabled
141+
* (it would produce incorrect results otherwise).
142+
*/
143+
autoSquash: false,
136144
});

git-stacked-rebase.ts

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { configKeys } from "./configKeys";
1919
import { apply, applyIfNeedsToApply, markThatNeedsToApply as _markThatNeedsToApply } from "./apply";
2020
import { forcePush } from "./forcePush";
2121
import { BehaviorOfGetBranchBoundaries, branchSequencer } from "./branchSequencer";
22+
import { autosquash } from "./autosquash";
2223

2324
import { createExecSyncInRepo } from "./util/execSyncInRepo";
2425
import { noop } from "./util/noop";
@@ -138,6 +139,7 @@ export const gitStackedRebase = async (
138139
const configValues = {
139140
gpgSign: !!(await config.getBool(configKeys.gpgSign).catch(() => 0)),
140141
autoApplyIfNeeded: !!(await config.getBool(configKeys.autoApplyIfNeeded).catch(() => 0)),
142+
autoSquash: !!(await config.getBool(configKeys.autoSquash).catch(() => 0)),
141143
} as const;
142144

143145
console.log({ configValues });
@@ -305,6 +307,17 @@ export const gitStackedRebase = async (
305307
"if-stacked-rebase-in-progress-then-parse-not-applied-state-otherwise-simple-branch-traverse"
306308
],
307309
reverseCheckoutOrder: false,
310+
311+
/**
312+
* `branchSequencer` does not perform the rebase operation
313+
* and thus cannot fully modify local commit history,
314+
* thus `autoSquash` is disabled
315+
* (it would produce incorrect results otherwise).
316+
*
317+
* TODO further investigation
318+
*
319+
*/
320+
autoSquash: false,
308321
});
309322
} else {
310323
/**
@@ -338,7 +351,8 @@ export const gitStackedRebase = async (
338351
initialBranch,
339352
currentBranch,
340353
// __default__pathToStackedRebaseTodoFile
341-
pathToStackedRebaseTodoFile
354+
pathToStackedRebaseTodoFile,
355+
configValues.autoSquash
342356
// () =>
343357
// getWantedCommitsWithBranchBoundariesUsingNativeGitRebase({
344358
// gitCmd: options.gitCmd,
@@ -853,16 +867,31 @@ export function removeUndefinedProperties<T, K extends keyof Partial<T>>(
853867
);
854868
}
855869

870+
/**
871+
* should commits with "squash!" and "fixup!" subjects be autosquashed.
872+
*
873+
* if an actual rebase operation is NOT being performed
874+
* (i.e. commits are not being rewritten),
875+
* then SHALL BE set to `false`.
876+
*
877+
* otherwise, should be configured in some way -- most likely
878+
* via the git config, and/or the CLI.
879+
*
880+
*/
881+
export type AutoSquash = boolean;
882+
856883
async function createInitialEditTodoOfGitStackedRebase(
857884
repo: Git.Repository, //
858885
initialBranch: Git.Reference,
859886
currentBranch: Git.Reference,
860887
pathToRebaseTodoFile: string,
888+
autoSquash: AutoSquash,
861889
getCommitsWithBranchBoundaries: () => Promise<CommitAndBranchBoundary[]> = () =>
862890
getWantedCommitsWithBranchBoundariesOurCustomImpl(
863891
repo, //
864892
initialBranch,
865-
currentBranch
893+
currentBranch,
894+
autoSquash
866895
)
867896
): Promise<void> {
868897
// .catch(logErr);
@@ -999,7 +1028,7 @@ function callAll(keyToFunctionMap: KeyToFunctionMap) {
9991028
);
10001029
}
10011030

1002-
type CommitAndBranchBoundary = {
1031+
export type CommitAndBranchBoundary = {
10031032
commit: Git.Commit;
10041033
commitCommand: RegularRebaseEitherCommandOrAlias;
10051034
branchEnd: Git.Reference[] | null;
@@ -1009,7 +1038,8 @@ export async function getWantedCommitsWithBranchBoundariesOurCustomImpl(
10091038
repo: Git.Repository, //
10101039
/** beginningBranch */
10111040
bb: Git.Reference,
1012-
currentBranch: Git.Reference
1041+
currentBranch: Git.Reference,
1042+
autoSquash: boolean
10131043
): Promise<CommitAndBranchBoundary[]> {
10141044
/**
10151045
* BEGIN check e.g. fork & origin/fork
@@ -1076,7 +1106,20 @@ export async function getWantedCommitsWithBranchBoundariesOurCustomImpl(
10761106
)
10771107
);
10781108

1079-
return extendCommitsWithBranchEnds(repo, bb, currentBranch, wantedCommits);
1109+
const extended: CommitAndBranchBoundary[] = await extendCommitsWithBranchEnds(
1110+
repo,
1111+
bb,
1112+
currentBranch,
1113+
wantedCommits
1114+
);
1115+
1116+
if (!autoSquash) {
1117+
return extended;
1118+
}
1119+
1120+
await autosquash(repo, extended);
1121+
1122+
return extended;
10801123
}
10811124

10821125
noop(getWantedCommitsWithBranchBoundariesUsingNativeGitRebase);

0 commit comments

Comments
 (0)