Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions packages/analysis-engine/src/csm.const.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/** Index of the second parent (starting point of merged branch) in a merge commit */
export const MERGE_PARENT_INDEX = 1;

/** Index of the first parent (main branch) in a commit */
export const FIRST_PARENT_INDEX = 0;
106 changes: 56 additions & 50 deletions packages/analysis-engine/src/csm.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
import {
collectSquashNodes,
extractNestedMergeParents,
findSquashEndIndex,
findSquashStartNodeIndex,
getMergeParentCommit,
} from "./csm.util";
import { convertPRCommitsToCommitNodes, convertPRDetailToCommitRaw } from "./pullRequest";
import type { CommitDict, CommitNode, CSMDictionary, CSMNode, PullRequest, PullRequestDict, StemDict } from "./types";

/**
* Builds a CSM node.
* For merge commits, collects squashed commits using DFS traversal.
*/
const buildCSMNode = (baseCommitNode: CommitNode, commitDict: CommitDict, stemDict: StemDict): CSMNode => {
const mergeParentCommit = commitDict.get(baseCommitNode.commit.parents[1]);
// Return empty source for non-merge commits
const mergeParentCommit = getMergeParentCommit(baseCommitNode, commitDict);
if (!mergeParentCommit) {
return {
base: baseCommitNode,
Expand All @@ -11,62 +23,45 @@ const buildCSMNode = (baseCommitNode: CommitNode, commitDict: CommitDict, stemDi
}

const squashCommitNodes: CommitNode[] = [];

const squashTaskQueue: CommitNode[] = [mergeParentCommit];

// Collect commits to be squashed using DFS
while (squashTaskQueue.length > 0) {
// get target
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const squashStartNode = squashTaskQueue.shift()!;
const squashStartNode = squashTaskQueue.shift();
if (!squashStartNode?.stemId) {
continue;
}

// get target's stem
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const squashStemId = squashStartNode.stemId!;
const squashStemId = squashStartNode.stemId;
const squashStem = stemDict.get(squashStemId);
if (!squashStem) {
continue;
}

// find squashStartNode index in stem
const squashStartNodeIndex = squashStem.nodes.findIndex(({ commit: { id } }) => id === squashStartNode.commit.id);
// Find the index of the start node in the stem
const squashStartNodeIndex = findSquashStartNodeIndex(squashStem, squashStartNode.commit.id);
if (squashStartNodeIndex === -1) {
continue;
}

// collect nodes from squashStartNode to end of stem
// (or until the next mergedIntoStem node for future merges)
const spliceCommitNodes: CommitNode[] = [];

// First, find the end index (before next mergedIntoStem or end of stem)
let endIndex = squashStem.nodes.length - 1;
for (let i = squashStartNodeIndex + 1; i < squashStem.nodes.length; i++) {
if (squashStem.nodes[i].mergedIntoStem) {
endIndex = i - 1;
break;
}
}

// Collect nodes from start to end
for (let i = squashStartNodeIndex; i <= endIndex; i++) {
spliceCommitNodes.push(squashStem.nodes[i]);
}

// remove collected nodes from stem
squashStem.nodes.splice(squashStartNodeIndex, spliceCommitNodes.length);
squashCommitNodes.push(...spliceCommitNodes);

// check nested-merge
const nestedMergeParentCommitIds = spliceCommitNodes
.filter((node) => node.commit.parents.length > 1)
.map((node) => node.commit.parents)
.reduce((pCommitIds, parents) => [...pCommitIds, ...parents], []);
const nestedMergeParentCommits = nestedMergeParentCommitIds
.map((commitId) => commitDict.get(commitId))
.filter((node): node is CommitNode => node !== undefined)
.filter((node) => node.stemId !== baseCommitNode.stemId && node.stemId !== squashStemId);

squashTaskQueue.push(...nestedMergeParentCommits);
// Find the end index for squash collection
const endIndex = findSquashEndIndex(squashStem, squashStartNodeIndex);

// Collect nodes and remove from stem
const collectedNodes = collectSquashNodes(squashStem, squashStartNodeIndex, endIndex);
squashCommitNodes.push(...collectedNodes);

// Handle nested merges: add branches to queue if collected nodes contain merge commits
const nestedMergeParents = extractNestedMergeParents(
collectedNodes,
commitDict,
baseCommitNode.stemId ?? "",
squashStemId
);
squashTaskQueue.push(...nestedMergeParents);
}

// Sort by sequence order (reverse -> forward)
squashCommitNodes.sort((a, b) => a.commit.sequence - b.commit.sequence);

return {
Expand All @@ -75,6 +70,14 @@ const buildCSMNode = (baseCommitNode: CommitNode, commitDict: CommitDict, stemDi
};
};

/**
* Integrates Pull Request information into a CSM node.
* Reflects PR details in commit message and statistics.
*
* @param csmNode - Existing CSM node
* @param pr - Pull Request information
* @returns CSM node with integrated PR information
*/
Comment on lines +79 to +86

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오우 function document 도 넘 좋습니다!!! 주석이라면(?) 이 정도는 되어야 주석이죠!!

const buildCSMNodeWithPullRequest = (csmNode: CSMNode, pr: PullRequest): CSMNode => {
const convertedCommit = convertPRDetailToCommitRaw(csmNode.base.commit, pr);

Expand All @@ -88,13 +91,16 @@ const buildCSMNodeWithPullRequest = (csmNode: CSMNode, pr: PullRequest): CSMNode
};

/**
* CSM 생성
* Builds a CSM (Commit Summary Model) dictionary.
* Creates CSM nodes for each commit in the base branch,
* and integrates Pull Request information if available.
*
* @param {Map<string, CommitNode>} commitDict
* @param {Map<string, Stem>} stemDict
* @param {string} baseBranchName
* @param {Array<PullRequest>} pullRequests
* @returns {CSMDictionary}
* @param commitDict - Commit dictionary
* @param stemDict - Stem dictionary
* @param baseBranchName - Base branch name (e.g., 'main', 'master')
* @param pullRequests - Pull Request array (optional)
* @returns CSM dictionary (CSM node array per branch)
* @throws {Error} When there are no stems or no base branch stem
*/
export const buildCSMDict = (
commitDict: CommitDict,
Expand All @@ -107,7 +113,7 @@ export const buildCSMDict = (
// return {};
}

// v0.1 에서는 master STEM 으로만 CSM 생성함
// In v0.1, CSM is only created from the master STEM
const masterStem = stemDict.get(baseBranchName);
if (!masterStem) {
throw new Error("no master-stem");
Expand Down
72 changes: 72 additions & 0 deletions packages/analysis-engine/src/csm.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { MERGE_PARENT_INDEX } from "./csm.const";
import type { CommitDict, CommitNode, Stem } from "./types";

/** Gets the second parent (starting point of merged branch) of a merge commit. */
export const getMergeParentCommit = (baseCommitNode: CommitNode, commitDict: CommitDict): CommitNode | undefined => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이게 정확하게는 git domain에서 사용하는 용어가 firstParent 입니다.
parent는 복수개가 있을 수가 있고,
stem할 때는 편의상 git의 firstparent를 따라가는 걸로 githru 초창기부터 구현해놓았습니다!

function 이름에도 반영해주시면 감사하겠습니다!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반영했습니다! 확인 감사합니다ㅎㅎ 상수도 이름 변경했습니다!

return commitDict.get(baseCommitNode.commit.parents[MERGE_PARENT_INDEX]);
};

/** Finds the index of a specific commit node in a stem. */
export const findSquashStartNodeIndex = (stem: Stem, commitId: string): number => {
return stem.nodes.findIndex(({ commit: { id } }) => id === commitId);
};

/**
* Finds the end index for squash collection.
* Collects until the next mergedIntoStem node appears or until the end of the stem.
*/
export const findSquashEndIndex = (stem: Stem, startIndex: number): number => {
let endIndex = stem.nodes.length - 1;

for (let i = startIndex + 1; i < stem.nodes.length; i++) {
if (stem.nodes[i].mergedIntoStem) {
endIndex = i - 1;
break;
}
}

return endIndex;
};

/**
* Collects nodes from start index to end index in a stem and removes them.
*
* @param stem - Target stem
* @param startIndex - Start index
* @param endIndex - End index
* @returns Array of collected commit nodes
*/
export const collectSquashNodes = (stem: Stem, startIndex: number, endIndex: number): CommitNode[] => {
const nodesToCollect: CommitNode[] = [];

for (let i = startIndex; i <= endIndex; i++) {
nodesToCollect.push(stem.nodes[i]);
}

// Remove collected nodes from stem
stem.nodes.splice(startIndex, nodesToCollect.length);

return nodesToCollect;
};

/**
* Extracts parent commits of nested merges from squashed nodes.
* Returns parent commits that do not belong to the base stem or current squash stem.
*/
export const extractNestedMergeParents = (
squashedNodes: CommitNode[],
commitDict: CommitDict,
baseStemId: string,
currentSquashStemId: string
): CommitNode[] => {
// Collect all parent commit IDs from merge commits
const nestedMergeParentCommitIds = squashedNodes
.filter((node) => node.commit.parents.length > 1)
.map((node) => node.commit.parents)
.reduce((pCommitIds, parents) => [...pCommitIds, ...parents], []);

return nestedMergeParentCommitIds
.map((commitId) => commitDict.get(commitId))
.filter((node): node is CommitNode => node !== undefined)
.filter((node) => node.stemId !== baseStemId && node.stemId !== currentSquashStemId);
};