Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@

const stackService = inject(STACK_SERVICE);

const stackQuery = $derived(stackService.allStackById(projectId, stackId));
const stackQuery = $derived(stackService.stackById(projectId, stackId));
</script>

<ReduxResult result={stackQuery.result} {projectId} {stackId} {onerror}>
Expand Down
151 changes: 151 additions & 0 deletions apps/desktop/src/lib/stacks/headInfoAdapters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { transformWorkspaceDetails } from "$lib/stacks/headInfoAdapters";
import { describe, expect, test } from "vitest";
import type { Author, Commit, RefInfo, Segment, UpstreamCommit } from "@gitbutler/but-sdk";

const encoder = new TextEncoder();

function bytes(value: string): number[] {
return [...encoder.encode(value)];
}

const author: Author = {
name: "Ada",
email: "ada@example.com",
gravatarUrl: "",
};

const localCommit: Commit = {
id: "1111111111111111111111111111111111111111",
parentIds: ["0000000000000000000000000000000000000000"],
message: "Local commit",
hasConflicts: true,
state: { type: "LocalOnly" },
createdAt: 1000,
author,
changeId: "I111",
gerritReviewUrl: null,
};

const upstreamCommit: UpstreamCommit = {
id: "2222222222222222222222222222222222222222",
message: "Remote commit",
createdAt: 2000,
author,
changeId: "I222",
};

function segment(overrides: Partial<Segment> = {}): Segment {
return {
refName: {
fullNameBytes: bytes("refs/heads/feature/top"),
displayName: "feature/top",
},
remoteTrackingRefName: {
fullNameBytes: bytes("refs/remotes/origin/feature/top"),
displayName: "feature/top",
remoteName: "origin",
},
commits: [localCommit],
commitsOnRemote: [upstreamCommit],
commitsOutside: null,
metadata: {
refInfo: {
createdAt: null,
updatedAt: { seconds: 123, offset: 0 },
},
review: {
pullRequest: 7,
reviewId: "review-7",
},
},
isEntrypoint: true,
pushStatus: "unpushedCommits",
base: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
...overrides,
};
}

function refInfo(stacks: RefInfo["stacks"]): RefInfo {
return {
workspaceRef: null,
stacks,
target: null,
isManagedRef: true,
isManagedCommit: true,
isEntrypoint: true,
};
}

describe("headInfoAdapters", () => {
test("maps head_info stacks to legacy stack entries and stack details", () => {
const result = transformWorkspaceDetails(
refInfo([
{
id: "stack-1",
base: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
segments: [segment()],
},
]),
);

expect(result.stacks.ids).toEqual(["stack-1"]);
expect(result.stacks.entities["stack-1"]).toMatchObject({
id: "stack-1",
tip: localCommit.id,
order: 0,
isCheckedOut: true,
heads: [
{
name: "feature/top",
tip: localCommit.id,
reviewId: 7,
isCheckedOut: true,
},
],
});

const details = result.stackDetails["stack-1"]!;
expect(details.stackInfo).toMatchObject({
derivedName: "feature/top",
pushStatus: "unpushedCommits",
isConflicted: true,
});
expect(details.stackInfo.branchDetails[0]).toMatchObject({
name: "feature/top",
reference: "refs/heads/feature/top",
remoteTrackingBranch: "refs/remotes/origin/feature/top",
prNumber: 7,
reviewId: "review-7",
tip: localCommit.id,
baseCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
lastUpdatedAt: 123000,
commits: [localCommit],
upstreamCommits: [upstreamCommit],
isRemoteHead: false,
});
expect(details.commits.ids).toEqual([localCommit.id]);
expect(details.upstreamCommits.ids).toEqual([upstreamCommit.id]);
});

test("uses the stack base as the tip for empty segments", () => {
const result = transformWorkspaceDetails(
refInfo([
{
id: "stack-1",
base: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
segments: [
segment({
commits: [],
base: null,
}),
],
},
]),
);

expect(result.stacks.entities["stack-1"]?.tip).toBe("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
expect(result.stackDetails["stack-1"]?.stackInfo.branchDetails[0]?.tip).toBe(
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
);
});
});
194 changes: 194 additions & 0 deletions apps/desktop/src/lib/stacks/headInfoAdapters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { createEntityAdapter, type EntityState } from "@reduxjs/toolkit";
import type {
BranchDetails,
Commit,
RefInfo,
Segment,
Stack as RefInfoStack,
StackDetails,
StackEntry,
UpstreamCommit,
} from "@gitbutler/but-sdk";

export type WorkspaceStackDetails = {
stackInfo: StackDetails;
branchDetails: EntityState<BranchDetails, string>;
commits: EntityState<Commit, string>;
upstreamCommits: EntityState<UpstreamCommit, string>;
};

export type WorkspaceDetails = {
stacks: EntityState<StackEntry, string>;
stackDetails: Record<string, WorkspaceStackDetails>;
};

const NULL_SHA = "0000000000000000000000000000000000000000";

const stackAdapter = createEntityAdapter<StackEntry, string>({
selectId: (stack) => stack.id ?? stack.heads.at(0)?.name ?? stack.tip,
});

const branchDetailsAdapter = createEntityAdapter<BranchDetails, string>({
selectId: (branch) => branch.name,
});

const commitAdapter = createEntityAdapter<Commit, string>({
selectId: (commit) => commit.id,
});

const upstreamCommitAdapter = createEntityAdapter<UpstreamCommit, string>({
selectId: (commit) => commit.id,
});

function decodeBytes(bytes: number[]): string {
return new TextDecoder().decode(new Uint8Array(bytes));
}

function stackKey(stack: StackEntry): string {
return stack.id ?? stack.heads.at(0)?.name ?? stack.tip;
}

function segmentName(segment: Segment): string {
if (!segment.refName) {
throw new Error("Cannot map anonymous head_info segment to legacy branch details");
}
return segment.refName.displayName;
}

function segmentReference(segment: Segment): string {
if (!segment.refName) {
throw new Error("Cannot map anonymous head_info segment to legacy branch details");
}
return decodeBytes(segment.refName.fullNameBytes);
}

function segmentTip(stack: RefInfoStack, segment: Segment): string {
return segment.commits.at(0)?.id ?? segment.base ?? stack.base ?? NULL_SHA;
}

function branchLastUpdatedAt(segment: Segment): number | null {
const seconds = segment.metadata?.refInfo.updatedAt?.seconds;
return seconds === undefined ? null : seconds * 1000;
}

function branchAuthors(segment: Segment): BranchDetails["authors"] {
const authors = [...segment.commits, ...segment.commitsOnRemote].map((commit) => commit.author);
return Array.from(
new Map(authors.map((author) => [JSON.stringify(author), author])).values(),
).sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""));
}

export function stackEntryFromHeadInfoStack(stack: RefInfoStack, index: number): StackEntry {
const heads = stack.segments.map((segment) => ({
name: segmentName(segment),
tip: segmentTip(stack, segment),
reviewId: segment.metadata?.review.pullRequest ?? null,
isCheckedOut: segment.isEntrypoint,
}));
const tip = heads.at(0)?.tip ?? stack.base ?? NULL_SHA;

return {
id: stack.id,
heads,
tip,
order: index,
isCheckedOut: heads.some((head) => head.isCheckedOut),
};
}

export function stackDetailsFromHeadInfoStack(stack: RefInfoStack): StackDetails {
const branchDetails = stack.segments.map((segment): BranchDetails => {
const baseCommit = segment.base ?? stack.base ?? NULL_SHA;
const remoteTrackingBranch = segment.remoteTrackingRefName
? decodeBytes(segment.remoteTrackingRefName.fullNameBytes)
: null;

return {
name: segmentName(segment),
reference: segmentReference(segment),
linkedWorktreeId: null,
remoteTrackingBranch,
prNumber: segment.metadata?.review.pullRequest ?? null,
reviewId: segment.metadata?.review.reviewId ?? null,
tip: segmentTip(stack, segment),
baseCommit,
pushStatus: segment.pushStatus,
lastUpdatedAt: branchLastUpdatedAt(segment),
authors: branchAuthors(segment),
isConflicted: segment.commits.some((commit) => commit.hasConflicts),
commits: segment.commits,
upstreamCommits: segment.commitsOnRemote,
isRemoteHead: segmentReference(segment).startsWith("refs/remotes/"),
};
});
const topmostBranch = branchDetails.at(0);

if (!topmostBranch) {
throw new Error("Cannot map empty head_info stack to legacy stack details");
}

return {
derivedName: topmostBranch.name,
pushStatus: topmostBranch.pushStatus,
branchDetails,
isConflicted: topmostBranch.isConflicted,
};
}

export function transformWorkspaceDetails(response: RefInfo): WorkspaceDetails {
const stacks = response.stacks.map(stackEntryFromHeadInfoStack);
const stackDetails = Object.fromEntries(
response.stacks.map((stack, index) => {
const entry = stacks[index]!;
const details = stackDetailsFromHeadInfoStack(stack);
return [
stackKey(entry),
{
stackInfo: details,
branchDetails: branchDetailsAdapter.addMany(
branchDetailsAdapter.getInitialState(),
details.branchDetails,
),
commits: commitAdapter.addMany(
commitAdapter.getInitialState(),
details.branchDetails.flatMap((branch) => branch.commits),
),
upstreamCommits: upstreamCommitAdapter.addMany(
upstreamCommitAdapter.getInitialState(),
details.branchDetails.flatMap((branch) => branch.upstreamCommits),
),
},
];
}),
);

return {
stacks: stackAdapter.addMany(stackAdapter.getInitialState(), stacks),
stackDetails,
};
}

export function selectWorkspaceStackDetails(
workspaceDetails: WorkspaceDetails,
stackId?: string,
branchName?: string,
): WorkspaceStackDetails | undefined {
if (stackId) return workspaceDetails.stackDetails[stackId];
if (branchName) {
return Object.values(workspaceDetails.stackDetails).find((details) =>
details.stackInfo.branchDetails.some((branch) => branch.name === branchName),
);
}
return Object.values(workspaceDetails.stackDetails).at(0);
}

export function selectWorkspaceStackById(
workspaceDetails: WorkspaceDetails,
stackId: string,
): StackEntry | undefined {
return stackAdapter.getSelectors().selectById(workspaceDetails.stacks, stackId);
}

export function workspaceStackDetailTags(workspaceDetails: WorkspaceDetails): string[] {
return Object.keys(workspaceDetails.stackDetails);
}
Loading
Loading