diff --git a/README.md b/README.md
index 8d5eebfeb..1788901ee 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,7 @@ Try Live Editor previews of future releases:
@@ -82,6 +82,10 @@ You can also use Mermaid within [GitHub](https://github.blog/2022-02-14-include-
For a more detailed introduction to Mermaid and some of its more basic uses, look to the [Beginner's Guide](https://mermaid.js.org/intro/getting-started.html), [Usage](https://mermaid.js.org/config/usage.html) and [Tutorials](https://mermaid.js.org/ecosystem/tutorials.html).
+Our PR Visual Regression Testing is powered by [Argos](https://argos-ci.com/?utm_source=mermaid&utm_campaign=oss) with their generous Open Source plan. It makes the process of reviewing PRs with visual changes a breeze.
+
+[](https://argos-ci.com?utm_source=mermaid&utm_campaign=oss)
+
In our release process we rely heavily on visual regression tests using [applitools](https://applitools.com/). Applitools is a great service which has been easy to use and integrate with our tests.
diff --git a/cypress/integration/rendering/flowchart-v2.spec.js b/cypress/integration/rendering/flowchart-v2.spec.js
index c2fd0b011..452cdb5a0 100644
--- a/cypress/integration/rendering/flowchart-v2.spec.js
+++ b/cypress/integration/rendering/flowchart-v2.spec.js
@@ -99,7 +99,7 @@ describe('Flowchart v2', () => {
const style = svg.attr('style');
expect(style).to.match(/^max-width: [\d.]+px;$/);
const maxWidthValue = parseFloat(style.match(/[\d.]+/g).join(''));
- expect(maxWidthValue).to.be.within(446 * 0.95 - 1, 446 * 1.05);
+ expect(maxWidthValue).to.be.within(417 * 0.95, 417 * 1.05);
});
});
it('8: should render a flowchart when useMaxWidth is false', () => {
@@ -118,7 +118,7 @@ describe('Flowchart v2', () => {
const width = parseFloat(svg.attr('width'));
// use within because the absolute value can be slightly different depending on the environment ±5%
// expect(height).to.be.within(446 * 0.95, 446 * 1.05);
- expect(width).to.be.within(446 * 0.95 - 1, 446 * 1.05);
+ expect(width).to.be.within(417 * 0.95, 417 * 1.05);
expect(svg).to.not.have.attr('style');
});
});
diff --git a/docs/community/contributing.md b/docs/community/contributing.md
index c78a3cb40..792c90a98 100644
--- a/docs/community/contributing.md
+++ b/docs/community/contributing.md
@@ -370,9 +370,9 @@ If the users have no way to know that things have changed, then you haven't real
Likewise, if users don't know that there is a new feature that you've implemented, it will forever remain unknown and unused.
The documentation has to be updated for users to know that things have been changed and added!
-If you are adding a new feature, add `(v10.8.0+)` in the title or description. It will be replaced automatically with the current version number when the release happens.
+If you are adding a new feature, add `(v+)` in the title or description. It will be replaced automatically with the current version number when the release happens.
-eg: `# Feature Name (v10.8.0+)`
+eg: `# Feature Name (v+)`
We know it can sometimes be hard to code _and_ write user documentation.
diff --git a/docs/intro/index.md b/docs/intro/index.md
index fda36f1da..5a71b45a4 100644
--- a/docs/intro/index.md
+++ b/docs/intro/index.md
@@ -55,6 +55,10 @@ For a more detailed introduction to Mermaid and some of its more basic uses, loo
**Thanks to all involved, people committing pull requests, people answering questions and special thanks to Tyler Long who is helping me maintain the project 🙏**
+Our PR Visual Regression Testing is powered by [Argos](https://argos-ci.com/?utm_source=mermaid&utm_campaign=oss) with their generous Open Source plan. It makes the process of reviewing PRs with visual changes a breeze.
+
+[](https://argos-ci.com?utm_source=mermaid&utm_campaign=oss)
+
In our release process we rely heavily on visual regression tests using [applitools](https://applitools.com/). Applitools is a great service which has been easy to use and integrate with our tests.
diff --git a/docs/intro/syntax-reference.md b/docs/intro/syntax-reference.md
index 00330f21d..f736840e6 100644
--- a/docs/intro/syntax-reference.md
+++ b/docs/intro/syntax-reference.md
@@ -83,3 +83,139 @@ Allows for the limited reconfiguration of a diagram just before it is rendered.
### [Theme Manipulation](../config/theming.md)
An application of using Directives to change [Themes](../config/theming.md). `Theme` is a value within Mermaid's configuration that dictates the color scheme for diagrams.
+
+### Layout and look
+
+We've restructured how Mermaid renders diagrams, enabling new features like selecting layout and look. **Currently, this is supported for flowcharts and state diagrams**, with plans to extend support to all diagram types.
+
+### Selecting Diagram Looks
+
+Mermaid offers a variety of styles or “looks” for your diagrams, allowing you to tailor the visual appearance to match your specific needs or preferences. Whether you prefer a hand-drawn or classic style, you can easily customize your diagrams.
+
+**Available Looks:**
+
+```
+• Hand-Drawn Look: For a more personal, creative touch, the hand-drawn look brings a sketch-like quality to your diagrams. This style is perfect for informal settings or when you want to add a bit of personality to your diagrams.
+• Classic Look: If you prefer the traditional Mermaid style, the classic look maintains the original appearance that many users are familiar with. It’s great for consistency across projects or when you want to keep the familiar aesthetic.
+```
+
+**How to Select a Look:**
+
+You can select a look by adding the look parameter in the metadata section of your Mermaid diagram code. Here’s an example:
+
+```mermaid-example
+---
+config:
+ look: handDrawn
+ theme: neutral
+---
+flowchart LR
+ A[Start] --> B{Decision}
+ B -->|Yes| C[Continue]
+ B -->|No| D[Stop]
+```
+
+```mermaid
+---
+config:
+ look: handDrawn
+ theme: neutral
+---
+flowchart LR
+ A[Start] --> B{Decision}
+ B -->|Yes| C[Continue]
+ B -->|No| D[Stop]
+```
+
+#### Selecting Layout Algorithms
+
+In addition to customizing the look of your diagrams, Mermaid Chart now allows you to choose different layout algorithms to better organize and present your diagrams, especially when dealing with more complex structures. The layout algorithm dictates how nodes and edges are arranged on the page.
+
+#### Supported Layout Algorithms:
+
+```
+• Dagre (default): This is the classic layout algorithm that has been used in Mermaid for a long time. It provides a good balance of simplicity and visual clarity, making it ideal for most diagrams.
+• ELK: For those who need more sophisticated layout capabilities, especially when working with large or intricate diagrams, the ELK (Eclipse Layout Kernel) layout offers advanced options. It provides a more optimized arrangement, potentially reducing overlapping and improving readability. This is not included out the box but needs to be added when integrating mermaid for sites/applications that want to have elk support.
+```
+
+#### How to Select a Layout Algorithm:
+
+You can specify the layout algorithm directly in the metadata section of your Mermaid diagram code. Here’s an example:
+
+```mermaid-example
+---
+config:
+ layout: elk
+ look: handDrawn
+ theme: dark
+---
+flowchart TB
+ A[Start] --> B{Decision}
+ B -->|Yes| C[Continue]
+ B -->|No| D[Stop]
+```
+
+```mermaid
+---
+config:
+ layout: elk
+ look: handDrawn
+ theme: dark
+---
+flowchart TB
+ A[Start] --> B{Decision}
+ B -->|Yes| C[Continue]
+ B -->|No| D[Stop]
+```
+
+In this example, the `layout: elk` line configures the diagram to use the ELK layout algorithm, along with the hand drawn look and forest theme.
+
+#### Customizing ELK Layout:
+
+When using the ELK layout, you can further refine the diagram’s configuration, such as how nodes are placed and whether parallel edges should be combined:
+
+- To combine parallel edges, use mergeEdges: true | false.
+- To configure node placement, use nodePlacementStrategy with the following options:
+ - SIMPLE
+ - NETWORK_SIMPLEX
+ - LINEAR_SEGMENTS
+ - BRANDES_KOEPF (default)
+
+**Example configuration:**
+
+```
+---
+config:
+ layout: elk
+ elk:
+ mergeEdges: true
+ nodePlacementStrategy: LINEAR_SEGMENTS
+---
+flowchart LR
+ A[Start] --> B{Choose Path}
+ B -->|Option 1| C[Path 1]
+ B -->|Option 2| D[Path 2]
+
+#### Using Dagre Layout with Classic Look:
+```
+
+Another example:
+
+```
+---
+config:
+ layout: dagre
+ look: classic
+ theme: default
+---
+
+flowchart LR
+A[Start] --> B{Choose Path}
+B -->|Option 1| C[Path 1]
+B -->|Option 2| D[Path 2]
+
+```
+
+These options give you the flexibility to create diagrams that not only look great but are also arranged to best suit your data’s structure and flow.
+
+When integrating Mermaid, you can include look and layout configuration with the initialize call. This is also where you add the loading of elk.
diff --git a/docs/news/blog.md b/docs/news/blog.md
index 4c7c982c3..372247b86 100644
--- a/docs/news/blog.md
+++ b/docs/news/blog.md
@@ -6,6 +6,24 @@
# Blog
+## [Mermaid v11 is out!](https://www.mermaidchart.com/blog/posts/mermaid-v11/)
+
+23 August 2024 · 2 mins
+
+Mermaid v11 introduces advanced layout options, new diagram types, and enhanced customization features, thanks to the incredible contributions from our community.
+
+## [Mermaid Innovation - Introducing New Looks for Mermaid Diagrams](https://www.mermaidchart.com/blog/posts/mermaid-innovation-introducing-new-looks-for-mermaid-diagrams/)
+
+6 August 2024 ·3 mins
+
+Discover the fresh new and unique Neo and Hand-Drawn looks for Mermaid Diagrams, while still offering the classic look you love.
+
+## [The Mermaid Chart Plugin for Jira: A How-To User Guide](https://www.mermaidchart.com/blog/posts/the-mermaid-chart-plugin-for-jira-a-how-to-user-guide/)
+
+31 July 2024 · 5 mins
+
+The Mermaid Chart plugin for Jira has arrived!
+
## [Mermaid AI Is Here to Change the Game For Diagram Creation](https://www.mermaidchart.com/blog/posts/mermaid-ai-is-here-to-change-the-game-for-diagram-creation/)
22 July 2024 · 5 mins
diff --git a/packages/mermaid-layout-elk/CHANGELOG.md b/packages/mermaid-layout-elk/CHANGELOG.md
new file mode 100644
index 000000000..e1ec1d2dd
--- /dev/null
+++ b/packages/mermaid-layout-elk/CHANGELOG.md
@@ -0,0 +1,16 @@
+# @mermaid-js/layout-elk
+
+## 0.1.2
+
+### Patch Changes
+
+- [#5761](https://github.com/mermaid-js/mermaid/pull/5761) [`b34dfe8`](https://github.com/mermaid-js/mermaid/commit/b34dfe8f45eded31da10965ced7ea40fde1ca76c) Thanks [@sidharthv96](https://github.com/sidharthv96)! - Fix type file path
+
+## 0.1.1
+
+### Patch Changes
+
+- [#5758](https://github.com/mermaid-js/mermaid/pull/5758) [`501a55d`](https://github.com/mermaid-js/mermaid/commit/501a55d8f225901ba345c498dec4298490a0196e) Thanks [@sidharthv96](https://github.com/sidharthv96)! - fix: Types path
+
+- Updated dependencies [[`5deaef4`](https://github.com/mermaid-js/mermaid/commit/5deaef456e74d796866431c26f69360e4e74dbff)]:
+ - mermaid@11.0.2
diff --git a/packages/mermaid-layout-elk/package.json b/packages/mermaid-layout-elk/package.json
index 78ad309b8..5fa491bc8 100644
--- a/packages/mermaid-layout-elk/package.json
+++ b/packages/mermaid-layout-elk/package.json
@@ -1,14 +1,14 @@
{
"name": "@mermaid-js/layout-elk",
- "version": "0.1.0",
+ "version": "0.1.2",
"description": "ELK layout engine for mermaid",
"module": "dist/mermaid-layout-elk.core.mjs",
- "types": "dist/packages/mermaid-layout-elk/src/index.d.ts",
+ "types": "dist/layouts.d.ts",
"type": "module",
"exports": {
".": {
"import": "./dist/mermaid-layout-elk.core.mjs",
- "types": "./dist/packages/mermaid-layout-elk/src/index.d.ts"
+ "types": "./dist/layouts.d.ts"
},
"./*": "./*"
},
diff --git a/packages/mermaid/CHANGELOG.md b/packages/mermaid/CHANGELOG.md
index fe7360f81..5a34667fa 100644
--- a/packages/mermaid/CHANGELOG.md
+++ b/packages/mermaid/CHANGELOG.md
@@ -4,6 +4,21 @@
### Patch Changes
+- [#5664](https://github.com/mermaid-js/mermaid/pull/5664) [`5deaef4`](https://github.com/mermaid-js/mermaid/commit/5deaef456e74d796866431c26f69360e4e74dbff) Thanks [@Austin-Fulbright](https://github.com/Austin-Fulbright)! - chore: Migrate git graph to langium, use typescript for internals
+
+- Updated dependencies [[`5deaef4`](https://github.com/mermaid-js/mermaid/commit/5deaef456e74d796866431c26f69360e4e74dbff)]:
+ - @mermaid-js/parser@0.2.0
+
+## 11.0.1
+
+### Patch Changes
+
+- [#2](https://github.com/calvinvette/mermaid/pull/2) [`bf05d87`](https://github.com/mermaid-js/mermaid/commit/bf05d8781edacb580fdb053da167e968b7570117) Thanks [@calvinvette](https://github.com/calvinvette)! - test changeset
+
+## 11.0.2
+
+### Patch Changes
+
- Updated dependencies [[`83926c9`](https://github.com/mermaid-js/mermaid/commit/83926c9707b09c34e300888186250191ee8ae30a)]:
- @mermaid-js/parser@0.1.1
diff --git a/packages/mermaid/scripts/update-release-version.mts b/packages/mermaid/scripts/update-release-version.mts
index a5943b37b..0459d3444 100644
--- a/packages/mermaid/scripts/update-release-version.mts
+++ b/packages/mermaid/scripts/update-release-version.mts
@@ -5,23 +5,34 @@
* So contributors adding new features will only have to add the placeholder and not worry about updating the version number.
*
*/
+import { readFile, writeFile } from 'fs/promises';
import { posix } from 'path';
import {
- getGlobs,
getFilesFromGlobs,
- SOURCE_DOCS_DIR,
- readSyncedUTF8file,
+ getGlobs,
MERMAID_RELEASE_VERSION,
+ readSyncedUTF8file,
+ SOURCE_DOCS_DIR,
} from './docs.mjs';
-import { writeFile } from 'fs/promises';
const verifyOnly: boolean = process.argv.includes('--verify');
const versionPlaceholder = '';
+const verifyDocumentation = async () => {
+ const fileContent = await readFile('./src/docs/community/contributing.md', 'utf-8');
+ if (!fileContent.includes(versionPlaceholder)) {
+ console.error(
+ `The placeholder ${versionPlaceholder} is not present in the contributing.md file.`
+ );
+ process.exit(1);
+ }
+};
+
const main = async () => {
+ await verifyDocumentation();
const sourceDirGlob = posix.join('.', SOURCE_DOCS_DIR, '**');
const mdFileGlobs = getGlobs([posix.join(sourceDirGlob, '*.md')]);
- mdFileGlobs.push('!**/community/development.md', '!**/community/code.md');
+ mdFileGlobs.push('!**/community/contributing.md');
const mdFiles = await getFilesFromGlobs(mdFileGlobs);
mdFiles.sort();
const mdFilesWithPlaceholder: string[] = [];
diff --git a/packages/mermaid/src/diagrams/git/gitGraph.spec.ts b/packages/mermaid/src/diagrams/git/gitGraph.spec.ts
new file mode 100644
index 000000000..9b3236f90
--- /dev/null
+++ b/packages/mermaid/src/diagrams/git/gitGraph.spec.ts
@@ -0,0 +1,1322 @@
+import { rejects } from 'assert';
+import { db } from './gitGraphAst.js';
+import { parser } from './gitGraphParser.js';
+
+describe('when parsing a gitGraph', function () {
+ beforeEach(function () {
+ db.clear();
+ });
+ describe('when parsing basic gitGraph', function () {
+ it('should handle a gitGraph definition', async () => {
+ const str = `gitGraph:\n commit\n`;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+
+ expect(commits.size).toBe(1);
+ expect(db.getCurrentBranch()).toBe('main');
+ expect(db.getDirection()).toBe('LR');
+ expect(db.getBranches().size).toBe(1);
+ });
+
+ it('should handle set direction top to bottom', async () => {
+ const str = 'gitGraph TB:\n' + 'commit\n';
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+
+ expect(commits.size).toBe(1);
+ expect(db.getCurrentBranch()).toBe('main');
+ expect(db.getDirection()).toBe('TB');
+ expect(db.getBranches().size).toBe(1);
+ });
+
+ it('should handle set direction bottom to top', async () => {
+ const str = 'gitGraph BT:\n' + 'commit\n';
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+
+ expect(commits.size).toBe(1);
+ expect(db.getCurrentBranch()).toBe('main');
+ expect(db.getDirection()).toBe('BT');
+ expect(db.getBranches().size).toBe(1);
+ });
+
+ it('should checkout a branch', async () => {
+ const str = 'gitGraph:\n' + 'branch new\n' + 'checkout new\n';
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+
+ expect(commits.size).toBe(0);
+ expect(db.getCurrentBranch()).toBe('new');
+ });
+
+ it('should switch a branch', async () => {
+ const str = 'gitGraph:\n' + 'branch new\n' + 'switch new\n';
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+
+ expect(commits.size).toBe(0);
+ expect(db.getCurrentBranch()).toBe('new');
+ });
+
+ it('should add commits to checked out branch', async () => {
+ const str = 'gitGraph:\n' + 'branch new\n' + 'checkout new\n' + 'commit\n' + 'commit\n';
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+
+ expect(commits.size).toBe(2);
+ expect(db.getCurrentBranch()).toBe('new');
+ const branchCommit = db.getBranches().get('new');
+ expect(branchCommit).not.toBeNull();
+ if (branchCommit) {
+ expect(commits.get(branchCommit)?.parents).not.toBeNull();
+ }
+ });
+ it('should handle commit with args', async () => {
+ const str = 'gitGraph:\n' + 'commit "a commit"\n';
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+
+ expect(commits.size).toBe(1);
+ const key = commits.keys().next().value;
+ expect(commits.get(key)?.message).toBe('a commit');
+ expect(db.getCurrentBranch()).toBe('main');
+ });
+
+ it.skip('should reset a branch', async () => {
+ const str =
+ 'gitGraph:\n' +
+ 'commit\n' +
+ 'commit\n' +
+ 'branch newbranch\n' +
+ 'checkout newbranch\n' +
+ 'commit\n' +
+ 'reset main\n';
+
+ await parser.parse(str);
+
+ const commits = db.getCommits();
+ expect(commits.size).toBe(3);
+ expect(db.getCurrentBranch()).toBe('newbranch');
+ expect(db.getBranches().get('newbranch')).toEqual(db.getBranches().get('main'));
+ expect(db.getHead()?.id).toEqual(db.getBranches().get('newbranch'));
+ });
+
+ it.skip('reset can take an argument', async () => {
+ const str =
+ 'gitGraph:\n' +
+ 'commit\n' +
+ 'commit\n' +
+ 'branch newbranch\n' +
+ 'checkout newbranch\n' +
+ 'commit\n' +
+ 'reset main^\n';
+
+ await parser.parse(str);
+
+ const commits = db.getCommits();
+ expect(commits.size).toBe(3);
+ expect(db.getCurrentBranch()).toBe('newbranch');
+ const branch = db.getBranches().get('main');
+ const main = commits.get(branch ?? '');
+ expect(db.getHead()?.id).toEqual(main?.parents);
+ });
+
+ it.skip('should handle fast forwardable merges', async () => {
+ const str =
+ 'gitGraph:\n' +
+ 'commit\n' +
+ 'branch newbranch\n' +
+ 'checkout newbranch\n' +
+ 'commit\n' +
+ 'commit\n' +
+ 'checkout main\n' +
+ 'merge newbranch\n';
+
+ await parser.parse(str);
+
+ const commits = db.getCommits();
+ expect(commits.size).toBe(4);
+ expect(db.getCurrentBranch()).toBe('main');
+ expect(db.getBranches().get('newbranch')).toEqual(db.getBranches().get('main'));
+ expect(db.getHead()?.id).toEqual(db.getBranches().get('newbranch'));
+ });
+
+ it('should handle cases when merge is a noop', async () => {
+ const str =
+ 'gitGraph:\n' +
+ 'commit\n' +
+ 'branch newbranch\n' +
+ 'checkout newbranch\n' +
+ 'commit\n' +
+ 'commit\n' +
+ 'merge main\n';
+
+ await parser.parse(str);
+
+ const commits = db.getCommits();
+ expect(commits.size).toBe(4);
+ expect(db.getCurrentBranch()).toBe('newbranch');
+ expect(db.getBranches().get('newbranch')).not.toEqual(db.getBranches().get('main'));
+ expect(db.getHead()?.id).toEqual(db.getBranches().get('newbranch'));
+ });
+
+ it('should handle merge with 2 parents', async () => {
+ const str =
+ 'gitGraph:\n' +
+ 'commit\n' +
+ 'branch newbranch\n' +
+ 'checkout newbranch\n' +
+ 'commit\n' +
+ 'commit\n' +
+ 'checkout main\n' +
+ 'commit\n' +
+ 'merge newbranch\n';
+
+ await parser.parse(str);
+
+ const commits = db.getCommits();
+ expect(commits.size).toBe(5);
+ expect(db.getCurrentBranch()).toBe('main');
+ expect(db.getBranches().get('newbranch')).not.toEqual(db.getBranches().get('main'));
+ expect(db.getHead()?.id).toEqual(db.getBranches().get('main'));
+ });
+
+ it.skip('should handle ff merge when history walk has two parents (merge commit)', async () => {
+ const str =
+ 'gitGraph:\n' +
+ 'commit\n' +
+ 'branch newbranch\n' +
+ 'checkout newbranch\n' +
+ 'commit\n' +
+ 'commit\n' +
+ 'checkout main\n' +
+ 'commit\n' +
+ 'merge newbranch\n' +
+ 'commit\n' +
+ 'checkout newbranch\n' +
+ 'merge main\n';
+
+ await parser.parse(str);
+
+ const commits = db.getCommits();
+ expect(commits.size).toBe(7);
+ expect(db.getCurrentBranch()).toBe('newbranch');
+ expect(db.getBranches().get('newbranch')).toEqual(db.getBranches().get('main'));
+ expect(db.getHead()?.id).toEqual(db.getBranches().get('main'));
+
+ db.prettyPrint();
+ });
+
+ it('should generate an array of known branches', async () => {
+ const str =
+ 'gitGraph:\n' +
+ 'commit\n' +
+ 'branch b1\n' +
+ 'checkout b1\n' +
+ 'commit\n' +
+ 'commit\n' +
+ 'branch b2\n';
+
+ await parser.parse(str);
+ const branches = db.getBranchesAsObjArray();
+
+ expect(branches).toHaveLength(3);
+ expect(branches[0]).toHaveProperty('name', 'main');
+ expect(branches[1]).toHaveProperty('name', 'b1');
+ expect(branches[2]).toHaveProperty('name', 'b2');
+ });
+ });
+
+ describe('when parsing more advanced gitGraphs', () => {
+ it('should handle a gitGraph commit with NO params, get auto-generated read-only ID', async () => {
+ const str = `gitGraph:
+ commit
+ `;
+ await parser.parse(str);
+ const commits = db.getCommits();
+ //console.info(commits);
+ expect(commits.size).toBe(1);
+ expect(db.getCurrentBranch()).toBe('main');
+ expect(db.getDirection()).toBe('LR');
+ expect(db.getBranches().size).toBe(1);
+ const key = commits.keys().next().value;
+ expect(commits.get(key)?.message).toBe('');
+ expect(commits.get(key)?.id).not.toBeNull();
+ expect(commits.get(key)?.tags).toStrictEqual([]);
+ expect(commits.get(key)?.type).toBe(0);
+ });
+
+ it('should handle a gitGraph commit with custom commit id only', async () => {
+ const str = `gitGraph:
+ commit id:"1111"
+ `;
+ await parser.parse(str);
+ const commits = db.getCommits();
+ expect(commits.size).toBe(1);
+ expect(db.getCurrentBranch()).toBe('main');
+ expect(db.getDirection()).toBe('LR');
+ expect(db.getBranches().size).toBe(1);
+ const key = commits.keys().next().value;
+ expect(commits.get(key)?.message).toBe('');
+ expect(commits.get(key)?.id).toBe('1111');
+ expect(commits.get(key)?.tags).toStrictEqual([]);
+ expect(commits.get(key)?.type).toBe(0);
+ });
+
+ it('should handle a gitGraph commit with custom commit tag only', async () => {
+ const str = `gitGraph:
+ commit tag:"test"
+ `;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+ expect(commits.size).toBe(1);
+ expect(db.getCurrentBranch()).toBe('main');
+ expect(db.getDirection()).toBe('LR');
+ expect(db.getBranches().size).toBe(1);
+ const key = commits.keys().next().value;
+ expect(commits.get(key)?.message).toBe('');
+ expect(commits.get(key)?.id).not.toBeNull();
+ expect(commits.get(key)?.tags).toStrictEqual(['test']);
+ expect(commits.get(key)?.type).toBe(0);
+ });
+
+ it('should handle a gitGraph commit with custom commit type HIGHLIGHT only', async () => {
+ const str = `gitGraph:
+ commit type: HIGHLIGHT
+ `;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+ expect(commits.size).toBe(1);
+ expect(db.getCurrentBranch()).toBe('main');
+ expect(db.getDirection()).toBe('LR');
+ expect(db.getBranches().size).toBe(1);
+ const key = commits.keys().next().value;
+ expect(commits.get(key)?.message).toBe('');
+ expect(commits.get(key)?.id).not.toBeNull();
+ expect(commits.get(key)?.tags).toStrictEqual([]);
+ expect(commits.get(key)?.type).toBe(2);
+ });
+
+ it('should handle a gitGraph commit with custom commit type REVERSE only', async () => {
+ const str = `gitGraph:
+ commit type: REVERSE
+ `;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+ expect(commits.size).toBe(1);
+ expect(db.getCurrentBranch()).toBe('main');
+ expect(db.getDirection()).toBe('LR');
+ expect(db.getBranches().size).toBe(1);
+ const key = commits.keys().next().value;
+ expect(commits.get(key)?.message).toBe('');
+ expect(commits.get(key)?.id).not.toBeNull();
+ expect(commits.get(key)?.tags).toStrictEqual([]);
+ expect(commits.get(key)?.type).toBe(1);
+ });
+
+ it('should handle a gitGraph commit with custom commit type NORMAL only', async () => {
+ const str = `gitGraph:
+ commit type: NORMAL
+ `;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+ expect(commits.size).toBe(1);
+ expect(db.getCurrentBranch()).toBe('main');
+ expect(db.getDirection()).toBe('LR');
+ expect(db.getBranches().size).toBe(1);
+ const key = commits.keys().next().value;
+ expect(commits.get(key)?.message).toBe('');
+ expect(commits.get(key)?.id).not.toBeNull();
+ expect(commits.get(key)?.tags).toStrictEqual([]);
+ expect(commits.get(key)?.type).toBe(0);
+ });
+
+ it('should handle a gitGraph commit with custom commit msg only', async () => {
+ const str = `gitGraph:
+ commit "test commit"
+ `;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+ expect(commits.size).toBe(1);
+ expect(db.getCurrentBranch()).toBe('main');
+ expect(db.getDirection()).toBe('LR');
+ expect(db.getBranches().size).toBe(1);
+ const key = commits.keys().next().value;
+ expect(commits.get(key)?.message).toBe('test commit');
+ expect(commits.get(key)?.id).not.toBeNull();
+ expect(commits.get(key)?.tags).toStrictEqual([]);
+ expect(commits.get(key)?.type).toBe(0);
+ });
+
+ it('should handle a gitGraph commit with custom commit "msg:" key only', async () => {
+ const str = `gitGraph:
+ commit msg: "test commit"
+ `;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+ expect(commits.size).toBe(1);
+ expect(db.getCurrentBranch()).toBe('main');
+ expect(db.getDirection()).toBe('LR');
+ expect(db.getBranches().size).toBe(1);
+ const key = commits.keys().next().value;
+ expect(commits.get(key)?.message).toBe('test commit');
+ expect(commits.get(key)?.id).not.toBeNull();
+ expect(commits.get(key)?.tags).toStrictEqual([]);
+ expect(commits.get(key)?.type).toBe(0);
+ });
+
+ it('should handle a gitGraph commit with custom commit id, tag only', async () => {
+ const str = `gitGraph:
+ commit id:"1111" tag: "test tag"
+ `;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+ expect(commits.size).toBe(1);
+ expect(db.getCurrentBranch()).toBe('main');
+ expect(db.getDirection()).toBe('LR');
+ expect(db.getBranches().size).toBe(1);
+ const key = commits.keys().next().value;
+ expect(commits.get(key)?.message).toBe('');
+ expect(commits.get(key)?.id).toBe('1111');
+ expect(commits.get(key)?.tags).toStrictEqual(['test tag']);
+ expect(commits.get(key)?.type).toBe(0);
+ });
+
+ it('should handle a gitGraph commit with custom commit type, tag only', async () => {
+ const str = `gitGraph:
+ commit type:HIGHLIGHT tag: "test tag"
+ `;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+ expect(commits.size).toBe(1);
+ expect(db.getCurrentBranch()).toBe('main');
+ expect(db.getDirection()).toBe('LR');
+ expect(db.getBranches().size).toBe(1);
+ const key = commits.keys().next().value;
+ expect(commits.get(key)?.message).toBe('');
+ expect(commits.get(key)?.id).not.toBeNull();
+ expect(commits.get(key)?.tags).toStrictEqual(['test tag']);
+ expect(commits.get(key)?.type).toBe(2);
+ });
+
+ it('should handle a gitGraph commit with custom commit tag and type only', async () => {
+ const str = `gitGraph:
+ commit tag: "test tag" type:HIGHLIGHT
+ `;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+ expect(commits.size).toBe(1);
+ expect(db.getCurrentBranch()).toBe('main');
+ expect(db.getDirection()).toBe('LR');
+ expect(db.getBranches().size).toBe(1);
+ const key = commits.keys().next().value;
+ expect(commits.get(key)?.message).toBe('');
+ expect(commits.get(key)?.id).not.toBeNull();
+ expect(commits.get(key)?.tags).toStrictEqual(['test tag']);
+ expect(commits.get(key)?.type).toBe(2);
+ });
+
+ it('should handle a gitGraph commit with custom commit id, type and tag only', async () => {
+ const str = `gitGraph:
+ commit id:"1111" type:REVERSE tag: "test tag"
+ `;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+ expect(commits.size).toBe(1);
+ expect(db.getCurrentBranch()).toBe('main');
+ expect(db.getDirection()).toBe('LR');
+ expect(db.getBranches().size).toBe(1);
+ const key = commits.keys().next().value;
+ expect(commits.get(key)?.message).toBe('');
+ expect(commits.get(key)?.id).toBe('1111');
+ expect(commits.get(key)?.tags).toStrictEqual(['test tag']);
+ expect(commits.get(key)?.type).toBe(1);
+ });
+
+ it('should handle a gitGraph commit with custom commit id, type, tag and msg', async () => {
+ const str = `gitGraph:
+ commit id:"1111" type:REVERSE tag: "test tag" msg:"test msg"
+ `;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+ expect(commits.size).toBe(1);
+ expect(db.getCurrentBranch()).toBe('main');
+ expect(db.getDirection()).toBe('LR');
+ expect(db.getBranches().size).toBe(1);
+ const key = commits.keys().next().value;
+ expect(commits.get(key)?.message).toBe('test msg');
+ expect(commits.get(key)?.id).toBe('1111');
+ expect(commits.get(key)?.tags).toStrictEqual(['test tag']);
+ expect(commits.get(key)?.type).toBe(1);
+ });
+
+ it('should handle a gitGraph commit with custom type,tag, msg, commit id,', async () => {
+ const str = `gitGraph:
+ commit type:REVERSE tag: "test tag" msg: "test msg" id: "1111"
+
+ `;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+ expect(commits.size).toBe(1);
+ expect(db.getCurrentBranch()).toBe('main');
+ expect(db.getDirection()).toBe('LR');
+ expect(db.getBranches().size).toBe(1);
+ const key = commits.keys().next().value;
+ expect(commits.get(key)?.message).toBe('test msg');
+ expect(commits.get(key)?.id).toBe('1111');
+ expect(commits.get(key)?.tags).toStrictEqual(['test tag']);
+ expect(commits.get(key)?.type).toBe(1);
+ });
+
+ it('should handle a gitGraph commit with custom tag, msg, commit id, type,', async () => {
+ const str = `gitGraph:
+ commit tag: "test tag" msg:"test msg" id:"1111" type:REVERSE
+ `;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+ expect(commits.size).toBe(1);
+ expect(db.getCurrentBranch()).toBe('main');
+ expect(db.getDirection()).toBe('LR');
+ expect(db.getBranches().size).toBe(1);
+ const key = commits.keys().next().value;
+ expect(commits.get(key)?.message).toBe('test msg');
+ expect(commits.get(key)?.id).toBe('1111');
+ expect(commits.get(key)?.tags).toStrictEqual(['test tag']);
+ expect(commits.get(key)?.type).toBe(1);
+ });
+
+ it('should handle a gitGraph commit with custom msg, commit id, type,tag', async () => {
+ const str = `gitGraph:
+ commit msg:"test msg" id:"1111" type:REVERSE tag: "test tag"
+ `;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+ expect(commits.size).toBe(1);
+ expect(db.getCurrentBranch()).toBe('main');
+ expect(db.getDirection()).toBe('LR');
+ expect(db.getBranches().size).toBe(1);
+ const key = commits.keys().next().value;
+ expect(commits.get(key)?.message).toBe('test msg');
+ expect(commits.get(key)?.id).toBe('1111');
+ expect(commits.get(key)?.tags).toStrictEqual(['test tag']);
+ expect(commits.get(key)?.type).toBe(1);
+ });
+
+ it('should handle 3 straight commits', async () => {
+ const str = `gitGraph:
+ commit
+ commit
+ commit
+ `;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+ expect(commits.size).toBe(3);
+ expect(db.getCurrentBranch()).toBe('main');
+ expect(db.getDirection()).toBe('LR');
+ expect(db.getBranches().size).toBe(1);
+ });
+
+ it('should handle new branch creation', async () => {
+ const str = `gitGraph:
+ commit
+ branch testBranch
+ `;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+ expect(commits.size).toBe(1);
+ expect(db.getCurrentBranch()).toBe('testBranch');
+ expect(db.getDirection()).toBe('LR');
+ expect(db.getBranches().size).toBe(2);
+ });
+
+ it('should allow quoted branch names', async () => {
+ const str = `gitGraph:
+ commit
+ branch "branch"
+ checkout "branch"
+ commit
+ checkout main
+ merge "branch"
+ `;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+ expect(commits.size).toBe(3);
+ expect(db.getCurrentBranch()).toBe('main');
+ expect(db.getDirection()).toBe('LR');
+ expect(db.getBranches().size).toBe(2);
+ const [commit1, commit2, commit3] = commits.keys();
+ expect(commits.get(commit1)?.branch).toBe('main');
+ expect(commits.get(commit2)?.branch).toBe('branch');
+ expect(commits.get(commit3)?.branch).toBe('main');
+ expect(db.getBranchesAsObjArray()).toStrictEqual([{ name: 'main' }, { name: 'branch' }]);
+ });
+
+ it('should allow _-./ characters in branch names', async () => {
+ const str = `gitGraph:
+ commit
+ branch azAZ_-./test
+ `;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+ expect(commits.size).toBe(1);
+ expect(db.getCurrentBranch()).toBe('azAZ_-./test');
+ expect(db.getDirection()).toBe('LR');
+ expect(db.getBranches().size).toBe(2);
+ });
+
+ it('should allow branch names starting with numbers', async () => {
+ const str = `gitGraph:
+ commit
+ %% branch names starting with numbers are not recommended, but are supported by git
+ branch 1.0.1
+ `;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+ expect(commits.size).toBe(1);
+ expect(db.getCurrentBranch()).toBe('1.0.1');
+ expect(db.getDirection()).toBe('LR');
+ expect(db.getBranches().size).toBe(2);
+ });
+
+ it('should allow branch names starting with unusual prefixes', async () => {
+ const str = `gitGraph:
+ commit
+ %% branch names starting with numbers are not recommended, but are supported by git
+ branch branch01
+ branch checkout02
+ branch cherry-pick03
+ branch branch/example-branch
+ branch merge/test_merge
+ %% single character branch name
+ branch A
+ `;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+ expect(commits.size).toBe(1);
+ expect(db.getCurrentBranch()).toBe('A');
+ expect(db.getDirection()).toBe('LR');
+ expect(db.getBranches().size).toBe(7);
+ expect([...db.getBranches().keys()]).toEqual(
+ expect.arrayContaining([
+ 'branch01',
+ 'checkout02',
+ 'cherry-pick03',
+ 'branch/example-branch',
+ 'merge/test_merge',
+ 'A',
+ ])
+ );
+ });
+
+ it('should handle new branch checkout', async () => {
+ const str = `gitGraph:
+ commit
+ branch testBranch
+ checkout testBranch
+ `;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+ expect(commits.size).toBe(1);
+ expect(db.getCurrentBranch()).toBe('testBranch');
+ expect(db.getDirection()).toBe('LR');
+ expect(db.getBranches().size).toBe(2);
+ });
+ it('should handle new branch checkout with order', async () => {
+ const str = `gitGraph:
+ commit
+ branch test1 order: 3
+ branch test2 order: 2
+ branch test3 order: 1
+ `;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+ expect(commits.size).toBe(1);
+ expect(db.getCurrentBranch()).toBe('test3');
+ expect(db.getBranches().size).toBe(4);
+ expect(db.getBranchesAsObjArray()).toStrictEqual([
+ { name: 'main' },
+ { name: 'test3' },
+ { name: 'test2' },
+ { name: 'test1' },
+ ]);
+ });
+ it('should handle new branch checkout with and without order', async () => {
+ const str = `gitGraph:
+ commit
+ branch test1 order: 1
+ branch test2
+ branch test3
+ `;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+ expect(commits.size).toBe(1);
+ expect(db.getCurrentBranch()).toBe('test3');
+ expect(db.getBranches().size).toBe(4);
+ expect(db.getBranchesAsObjArray()).toStrictEqual([
+ { name: 'main' },
+ { name: 'test2' },
+ { name: 'test3' },
+ { name: 'test1' },
+ ]);
+ });
+
+ it('should handle new branch checkout & commit', async () => {
+ const str = `gitGraph:
+ commit
+ branch testBranch
+ checkout testBranch
+ commit
+ `;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+ expect(commits.size).toBe(2);
+ expect(db.getCurrentBranch()).toBe('testBranch');
+ expect(db.getDirection()).toBe('LR');
+ expect(db.getBranches().size).toBe(2);
+ const [commit1, commit2] = commits.keys();
+ expect(commits.get(commit1)?.branch).toBe('main');
+ expect(commits.get(commit1)?.parents).toStrictEqual([]);
+ expect(commits.get(commit2)?.branch).toBe('testBranch');
+ expect(commits.get(commit2)?.parents).toStrictEqual([commit1]);
+ });
+
+ it('should handle new branch checkout & commit and merge', async () => {
+ const str = `gitGraph:
+ commit
+ branch testBranch
+ checkout testBranch
+ commit
+ commit
+ checkout main
+ merge testBranch
+ `;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+ expect(commits.size).toBe(4);
+ expect(db.getCurrentBranch()).toBe('main');
+ expect(db.getDirection()).toBe('LR');
+ expect(db.getBranches().size).toBe(2);
+ const [commit1, commit2, commit3, commit4] = commits.keys();
+ expect(commits.get(commit1)?.branch).toBe('main');
+ expect(commits.get(commit1)?.parents).toStrictEqual([]);
+ expect(commits.get(commit2)?.branch).toBe('testBranch');
+ expect(commits.get(commit2)?.parents).toStrictEqual([commits.get(commit1)?.id]);
+ expect(commits.get(commit3)?.branch).toBe('testBranch');
+ expect(commits.get(commit3)?.parents).toStrictEqual([commits.get(commit2)?.id]);
+ expect(commits.get(commit4)?.branch).toBe('main');
+ expect(commits.get(commit4)?.parents).toStrictEqual([
+ commits.get(commit1)?.id,
+ commits.get(commit3)?.id,
+ ]);
+ expect(db.getBranchesAsObjArray()).toStrictEqual([{ name: 'main' }, { name: 'testBranch' }]);
+ });
+
+ it('should handle new branch switch', async () => {
+ const str = `gitGraph:
+ commit
+ branch testBranch
+ switch testBranch
+ `;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+ expect(commits.size).toBe(1);
+ expect(db.getCurrentBranch()).toBe('testBranch');
+ expect(db.getDirection()).toBe('LR');
+ expect(db.getBranches().size).toBe(2);
+ });
+
+ it('should handle new branch switch & commit', async () => {
+ const str = `gitGraph:
+ commit
+ branch testBranch
+ switch testBranch
+ commit
+ `;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+ expect(commits.size).toBe(2);
+ expect(db.getCurrentBranch()).toBe('testBranch');
+ expect(db.getDirection()).toBe('LR');
+ expect(db.getBranches().size).toBe(2);
+ const [commit1, commit2] = commits.keys();
+ expect(commits.get(commit1)?.branch).toBe('main');
+ expect(commits.get(commit1)?.parents).toStrictEqual([]);
+ expect(commits.get(commit2)?.branch).toBe('testBranch');
+ expect(commits.get(commit2)?.parents).toStrictEqual([commit1]);
+ });
+
+ it('should handle new branch switch & commit and merge', async () => {
+ const str = `gitGraph:
+ commit
+ branch testBranch
+ switch testBranch
+ commit
+ commit
+ switch main
+ merge testBranch
+ `;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+ expect(commits.size).toBe(4);
+ expect(db.getCurrentBranch()).toBe('main');
+ expect(db.getDirection()).toBe('LR');
+ expect(db.getBranches().size).toBe(2);
+ const [commit1, commit2, commit3, commit4] = commits.keys();
+ expect(commits.get(commit1)?.branch).toBe('main');
+ expect(commits.get(commit1)?.parents).toStrictEqual([]);
+ expect(commits.get(commit2)?.branch).toBe('testBranch');
+ expect(commits.get(commit2)?.parents).toStrictEqual([commits.get(commit1)?.id]);
+ expect(commits.get(commit3)?.branch).toBe('testBranch');
+ expect(commits.get(commit3)?.parents).toStrictEqual([commits.get(commit2)?.id]);
+ expect(commits.get(commit4)?.branch).toBe('main');
+ expect(commits.get(commit4)?.parents).toStrictEqual([
+ commits.get(commit1)?.id,
+ commits.get(commit3)?.id,
+ ]);
+ expect(db.getBranchesAsObjArray()).toStrictEqual([{ name: 'main' }, { name: 'testBranch' }]);
+ });
+
+ it('should handle merge tags', async () => {
+ const str = `gitGraph:
+ commit
+ branch testBranch
+ checkout testBranch
+ commit
+ checkout main
+ merge testBranch tag: "merge-tag"
+ `;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+ expect(commits.size).toBe(3);
+ expect(db.getCurrentBranch()).toBe('main');
+ expect(db.getDirection()).toBe('LR');
+ expect(db.getBranches().size).toBe(2);
+ const [commit1, commit2, commit3] = commits.keys();
+ expect(commits.get(commit1)?.branch).toBe('main');
+ expect(commits.get(commit1)?.parents).toStrictEqual([]);
+
+ expect(commits.get(commit2)?.branch).toBe('testBranch');
+ expect(commits.get(commit2)?.parents).toStrictEqual([commits.get(commit1)?.id]);
+
+ expect(commits.get(commit3)?.branch).toBe('main');
+ expect(commits.get(commit3)?.parents).toStrictEqual([
+ commits.get(commit1)?.id,
+ commits.get(commit2)?.id,
+ ]);
+ expect(commits.get(commit3)?.tags).toStrictEqual(['merge-tag']);
+ expect(db.getBranchesAsObjArray()).toStrictEqual([{ name: 'main' }, { name: 'testBranch' }]);
+ });
+
+ it('should handle merge with custom ids, tags and type', async () => {
+ const str = `gitGraph:
+ commit
+ branch testBranch
+ checkout testBranch
+ commit
+ checkout main
+ %% Merge Tag and ID
+ merge testBranch tag: "merge-tag" id: "2-222"
+ branch testBranch2
+ checkout testBranch2
+ commit
+ checkout main
+ %% Merge ID and Tag (reverse order)
+ merge testBranch2 id: "4-444" tag: "merge-tag2" type:HIGHLIGHT
+ branch testBranch3
+ checkout testBranch3
+ commit
+ checkout main
+ %% just Merge ID
+ merge testBranch3 id: "6-666"
+ `;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+ expect(commits.size).toBe(7);
+ expect(db.getCurrentBranch()).toBe('main');
+ expect(db.getDirection()).toBe('LR');
+
+ // The order of these commits is in alphabetical order of IDs
+ const [
+ mainCommit,
+ testBranchCommit,
+ testBranchMerge,
+ testBranch2Commit,
+ testBranch2Merge,
+ testBranch3Commit,
+ testBranch3Merge,
+ ] = [...commits.values()];
+
+ expect(mainCommit.branch).toBe('main');
+ expect(mainCommit.parents).toStrictEqual([]);
+
+ expect(testBranchCommit.branch).toBe('testBranch');
+ expect(testBranchCommit.parents).toStrictEqual([mainCommit.id]);
+
+ expect(testBranchMerge.branch).toBe('main');
+ expect(testBranchMerge.parents).toStrictEqual([mainCommit.id, testBranchCommit.id]);
+ expect(testBranchMerge.tags).toStrictEqual(['merge-tag']);
+ expect(testBranchMerge.id).toBe('2-222');
+
+ expect(testBranch2Merge.branch).toBe('main');
+ expect(testBranch2Merge.parents).toStrictEqual([testBranchMerge.id, testBranch2Commit.id]);
+ expect(testBranch2Merge.tags).toStrictEqual(['merge-tag2']);
+ expect(testBranch2Merge.id).toBe('4-444');
+ expect(testBranch2Merge.customType).toBe(2);
+ expect(testBranch2Merge.customId).toBe(true);
+
+ expect(testBranch3Merge.branch).toBe('main');
+ expect(testBranch3Merge.parents).toStrictEqual([testBranch2Merge.id, testBranch3Commit.id]);
+ expect(testBranch3Merge.id).toBe('6-666');
+
+ expect(db.getBranchesAsObjArray()).toStrictEqual([
+ { name: 'main' },
+ { name: 'testBranch' },
+ { name: 'testBranch2' },
+ { name: 'testBranch3' },
+ ]);
+ });
+
+ it('should support cherry-picking commits', async () => {
+ const str = `gitGraph
+ commit id: "ZERO"
+ branch develop
+ commit id:"A"
+ checkout main
+ cherry-pick id:"A"
+ `;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+ const cherryPickCommitID = [...commits.keys()][2];
+ expect(commits.get(cherryPickCommitID)?.tags).toStrictEqual(['cherry-pick:A']);
+ expect(commits.get(cherryPickCommitID)?.branch).toBe('main');
+ });
+
+ it('should support cherry-picking commits with custom tag', async () => {
+ const str = `gitGraph
+ commit id: "ZERO"
+ branch develop
+ commit id:"A"
+ checkout main
+ cherry-pick id:"A" tag:"MyTag"
+ `;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+ const cherryPickCommitID = [...commits.keys()][2];
+ expect(commits.get(cherryPickCommitID)?.tags).toStrictEqual(['MyTag']);
+ expect(commits.get(cherryPickCommitID)?.branch).toBe('main');
+ });
+
+ it('should support cherry-picking commits with no tag', async () => {
+ const str = `gitGraph
+ commit id: "ZERO"
+ branch develop
+ commit id:"A"
+ checkout main
+ cherry-pick id:"A" tag:""
+ `;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+ const cherryPickCommitID = [...commits.keys()][2];
+ expect(commits.get(cherryPickCommitID)?.tags).toStrictEqual([]);
+ expect(commits.get(cherryPickCommitID)?.branch).toBe('main');
+ });
+
+ it('should support cherry-picking of merge commits', async () => {
+ const str = `gitGraph
+ commit id: "ZERO"
+ branch feature
+ branch release
+ checkout feature
+ commit id: "A"
+ commit id: "B"
+ checkout main
+ merge feature id: "M"
+ checkout release
+ cherry-pick id: "M" parent:"B"
+ `;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+ const cherryPickCommitID = [...commits.keys()][4];
+ expect(commits.get(cherryPickCommitID)?.tags).toStrictEqual(['cherry-pick:M|parent:B']);
+ expect(commits.get(cherryPickCommitID)?.branch).toBe('release');
+ });
+
+ it('should support cherry-picking of merge commits with tag', async () => {
+ const str = `gitGraph
+ commit id: "ZERO"
+ branch feature
+ branch release
+ checkout feature
+ commit id: "A"
+ commit id: "B"
+ checkout main
+ merge feature id: "M"
+ checkout release
+ cherry-pick id: "M" parent:"ZERO" tag: "v1.0"
+ `;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+ const cherryPickCommitID = [...commits.keys()][4];
+ expect(commits.get(cherryPickCommitID)?.tags).toStrictEqual(['v1.0']);
+ expect(commits.get(cherryPickCommitID)?.branch).toBe('release');
+ });
+
+ it('should support cherry-picking of merge commits with additional commit', async () => {
+ const str = `gitGraph
+ commit id: "ZERO"
+ branch feature
+ branch release
+ checkout feature
+ commit id: "A"
+ commit id: "B"
+ checkout main
+ merge feature id: "M"
+ checkout release
+ commit id: "C"
+ cherry-pick id: "M" tag: "v2.1:ZERO" parent:"ZERO"
+ commit id: "D"
+ `;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+ const cherryPickCommitID = [...commits.keys()][5];
+ expect(commits.get(cherryPickCommitID)?.tags).toStrictEqual(['v2.1:ZERO']);
+ expect(commits.get(cherryPickCommitID)?.branch).toBe('release');
+ });
+
+ it('should support cherry-picking of merge commits with empty tag', async () => {
+ const str = `gitGraph
+ commit id: "ZERO"
+ branch feature
+ branch release
+ checkout feature
+ commit id: "A"
+ commit id: "B"
+ checkout main
+ merge feature id: "M"
+ checkout release
+ commit id: "C"
+ cherry-pick id:"M" parent: "ZERO" tag:""
+ commit id: "D"
+ cherry-pick id:"M" tag:"" parent: "B"
+ `;
+
+ await parser.parse(str);
+ const commits = db.getCommits();
+ const cherryPickCommitID = [...commits.keys()][5];
+ const cherryPickCommitID2 = [...commits.keys()][7];
+ expect(commits.get(cherryPickCommitID)?.tags).toStrictEqual([]);
+ expect(commits.get(cherryPickCommitID2)?.tags).toStrictEqual([]);
+ expect(commits.get(cherryPickCommitID)?.branch).toBe('release');
+ });
+
+ it('should fail cherry-picking of merge commits if the parent of merge commits is not specified', async () => {
+ await expect(
+ parser.parse(
+ `gitGraph
+ commit id: "ZERO"
+ branch feature
+ branch release
+ checkout feature
+ commit id: "A"
+ commit id: "B"
+ checkout main
+ merge feature id: "M"
+ checkout release
+ commit id: "C"
+ cherry-pick id:"M"
+ `
+ )
+ ).rejects.toThrow(
+ 'Incorrect usage of cherry-pick: If the source commit is a merge commit, an immediate parent commit must be specified.'
+ );
+ });
+
+ it('should fail cherry-picking of merge commits when the parent provided is not an immediate parent of cherry picked commit', async () => {
+ await expect(
+ parser.parse(
+ `gitGraph
+ commit id: "ZERO"
+ branch feature
+ branch release
+ checkout feature
+ commit id: "A"
+ commit id: "B"
+ checkout main
+ merge feature id: "M"
+ checkout release
+ commit id: "C"
+ cherry-pick id:"M" parent: "A"
+ `
+ )
+ ).rejects.toThrow(
+ 'Invalid operation: The specified parent commit is not an immediate parent of the cherry-picked commit.'
+ );
+ });
+
+ it('should throw error when try to branch existing branch: main', async () => {
+ const str = `gitGraph
+ commit
+ branch testBranch
+ commit
+ branch main
+ commit
+ checkout main
+ merge testBranch
+ `;
+
+ try {
+ await parser.parse(str);
+ expect(true).toBe(false);
+ } catch (e: any) {
+ expect(e.message).toBe(
+ 'Trying to create an existing branch. (Help: Either use a new name if you want create a new branch or try using "checkout main")'
+ );
+ }
+ });
+ it('should throw error when try to branch existing branch: testBranch', async () => {
+ const str = `gitGraph
+ commit
+ branch testBranch
+ commit
+ branch testBranch
+ commit
+ checkout main
+ merge testBranch
+ `;
+
+ try {
+ await parser.parse(str);
+ // Fail test if above expression doesn't throw anything.
+ expect(true).toBe(false);
+ } catch (e: any) {
+ expect(e.message).toBe(
+ 'Trying to create an existing branch. (Help: Either use a new name if you want create a new branch or try using "checkout testBranch")'
+ );
+ }
+ });
+ it('should throw error when try to checkout unknown branch: testBranch', async () => {
+ const str = `gitGraph
+ commit
+ checkout testBranch
+ commit
+ branch testBranch
+ commit
+ checkout main
+ merge testBranch
+ `;
+
+ try {
+ await parser.parse(str);
+ // Fail test if above expression doesn't throw anything.
+ expect(true).toBe(false);
+ } catch (e: any) {
+ expect(e.message).toBe(
+ 'Trying to checkout branch which is not yet created. (Help try using "branch testBranch")'
+ );
+ }
+ });
+ it('should throw error when trying to merge, when current branch has no commits', async () => {
+ const str = `gitGraph
+ merge testBranch
+ commit
+ checkout testBranch
+ commit
+ branch testBranch
+ commit
+ checkout main
+ merge testBranch
+ `;
+
+ try {
+ await parser.parse(str);
+ // Fail test if above expression doesn't throw anything.
+ expect(true).toBe(false);
+ } catch (e: any) {
+ expect(e.message).toBe('Incorrect usage of "merge". Current branch (main)has no commits');
+ }
+ });
+ it('should throw error when trying to merge unknown branch', async () => {
+ const str = `gitGraph
+ commit
+ merge testBranch
+ commit
+ checkout testBranch
+ commit
+ branch testBranch
+ commit
+ checkout main
+ merge testBranch
+ `;
+
+ try {
+ await parser.parse(str);
+ expect(true).toBe(false);
+ } catch (e: any) {
+ expect(e.message).toBe(
+ 'Incorrect usage of "merge". Branch to be merged (testBranch) does not exist'
+ );
+ }
+ });
+ it('should throw error when trying to merge branch to itself', async () => {
+ const str = `gitGraph
+ commit
+ branch testBranch
+ merge testBranch
+ `;
+
+ try {
+ await parser.parse(str);
+ // Fail test if above expression doesn't throw anything.
+ expect(true).toBe(false);
+ } catch (e: any) {
+ expect(e.message).toBe('Incorrect usage of "merge". Cannot merge a branch to itself');
+ }
+ });
+
+ it('should throw error when using existing id as merge ID', async () => {
+ const str = `gitGraph
+ commit id: "1-111"
+ branch testBranch
+ commit id: "2-222"
+ commit id: "3-333"
+ checkout main
+ merge testBranch id: "1-111"
+ `;
+
+ try {
+ await parser.parse(str);
+ // Fail test if above expression doesn't throw anything.
+ expect(true).toBe(false);
+ } catch (e: any) {
+ expect(e.message).toBe(
+ 'Incorrect usage of "merge". Commit with id:1-111 already exists, use different custom Id'
+ );
+ }
+ });
+ it('should throw error when trying to merge branches having same heads', async () => {
+ const str = `gitGraph
+ commit
+ branch testBranch
+ checkout main
+ merge testBranch
+ `;
+
+ try {
+ await parser.parse(str);
+ // Fail test if above expression doesn't throw anything.
+ expect(true).toBe(false);
+ } catch (e: any) {
+ expect(e.message).toBe('Incorrect usage of "merge". Both branches have same head');
+ }
+ });
+ it('should throw error when trying to merge branch which has no commits', async () => {
+ const str = `gitGraph
+ branch test1
+
+ checkout main
+ commit
+ merge test1
+ `;
+
+ try {
+ await parser.parse(str);
+ // Fail test if above expression doesn't throw anything.
+ expect(true).toBe(false);
+ } catch (e: any) {
+ expect(e.message).toBe(
+ 'Incorrect usage of "merge". Branch to be merged (test1) has no commits'
+ );
+ }
+ });
+ describe('accessibility', () => {
+ it('should handle a title and a description (accDescr)', async () => {
+ const str = `gitGraph:
+ accTitle: This is a title
+ accDescr: This is a description
+ commit
+ `;
+ await parser.parse(str);
+ expect(db.getAccTitle()).toBe('This is a title');
+ expect(db.getAccDescription()).toBe('This is a description');
+ });
+ it('should handle a title and a multiline description (accDescr)', async () => {
+ const str = `gitGraph:
+ accTitle: This is a title
+ accDescr {
+ This is a description
+ using multiple lines
+ }
+ commit
+ `;
+ await parser.parse(str);
+ expect(db.getAccTitle()).toBe('This is a title');
+ expect(db.getAccDescription()).toBe('This is a description\nusing multiple lines');
+ });
+ });
+
+ describe('unsafe properties', () => {
+ for (const prop of ['__proto__', 'constructor']) {
+ it(`should work with custom commit id or branch name ${prop}`, async () => {
+ const str = `gitGraph
+ commit id:"${prop}"
+ branch ${prop}
+ checkout ${prop}
+ commit
+ checkout main
+ merge ${prop}
+ `;
+ await parser.parse(str);
+ const commits = db.getCommits();
+ expect(commits.size).toBe(3);
+ expect(commits.keys().next().value).toBe(prop);
+ expect(db.getCurrentBranch()).toBe('main');
+ expect(db.getBranches().size).toBe(2);
+ expect(db.getBranchesAsObjArray()[1].name).toBe(prop);
+ });
+ }
+ });
+ });
+});
diff --git a/packages/mermaid/src/diagrams/git/gitGraphAst.js b/packages/mermaid/src/diagrams/git/gitGraphAst.js
deleted file mode 100644
index cebc4fc3e..000000000
--- a/packages/mermaid/src/diagrams/git/gitGraphAst.js
+++ /dev/null
@@ -1,535 +0,0 @@
-import { log } from '../../logger.js';
-import { random } from '../../utils.js';
-import { getConfig } from '../../diagram-api/diagramAPI.js';
-import common from '../common/common.js';
-import {
- setAccTitle,
- getAccTitle,
- getAccDescription,
- setAccDescription,
- clear as commonClear,
- setDiagramTitle,
- getDiagramTitle,
-} from '../common/commonDb.js';
-
-let { mainBranchName, mainBranchOrder } = getConfig().gitGraph;
-let commits = new Map();
-let head = null;
-let branchesConfig = new Map();
-branchesConfig.set(mainBranchName, { name: mainBranchName, order: mainBranchOrder });
-let branches = new Map();
-branches.set(mainBranchName, head);
-let curBranch = mainBranchName;
-let direction = 'LR';
-let seq = 0;
-
-/**
- *
- */
-function getId() {
- return random({ length: 7 });
-}
-
-// /**
-// * @param currentCommit
-// * @param otherCommit
-// */
-
-// function isFastForwardable(currentCommit, otherCommit) {
-// log.debug('Entering isFastForwardable:', currentCommit.id, otherCommit.id);
-// let cnt = 0;
-// while (currentCommit.seq <= otherCommit.seq && currentCommit !== otherCommit && cnt < 1000) {
-// cnt++;
-// // only if other branch has more commits
-// if (otherCommit.parent == null) break;
-// if (Array.isArray(otherCommit.parent)) {
-// log.debug('In merge commit:', otherCommit.parent);
-// return (
-// isFastForwardable(currentCommit, commits.get(otherCommit.parent[0])) ||
-// isFastForwardable(currentCommit, commits.get(otherCommit.parent[1]))
-// );
-// } else {
-// otherCommit = commits.get(otherCommit.parent);
-// }
-// }
-// log.debug(currentCommit.id, otherCommit.id);
-// return currentCommit.id === otherCommit.id;
-// }
-
-/**
- * @param currentCommit
- * @param otherCommit
- */
-// function isReachableFrom(currentCommit, otherCommit) {
-// const currentSeq = currentCommit.seq;
-// const otherSeq = otherCommit.seq;
-// if (currentSeq > otherSeq) return isFastForwardable(otherCommit, currentCommit);
-// return false;
-// }
-
-/**
- * @param list
- * @param fn
- */
-function uniqBy(list, fn) {
- const recordMap = Object.create(null);
- return list.reduce((out, item) => {
- const key = fn(item);
- if (!recordMap[key]) {
- recordMap[key] = true;
- out.push(item);
- }
- return out;
- }, []);
-}
-
-export const setDirection = function (dir) {
- direction = dir;
-};
-let options = {};
-export const setOptions = function (rawOptString) {
- log.debug('options str', rawOptString);
- rawOptString = rawOptString?.trim();
- rawOptString = rawOptString || '{}';
- try {
- options = JSON.parse(rawOptString);
- } catch (e) {
- log.error('error while parsing gitGraph options', e.message);
- }
-};
-
-export const getOptions = function () {
- return options;
-};
-
-export const commit = function (msg, id, type, tags) {
- log.debug('Entering commit:', msg, id, type, tags);
- const config = getConfig();
- id = common.sanitizeText(id, config);
- msg = common.sanitizeText(msg, config);
- tags = tags?.map((tag) => common.sanitizeText(tag, config));
- const commit = {
- id: id ? id : seq + '-' + getId(),
- message: msg,
- seq: seq++,
- type: type ? type : commitType.NORMAL,
- tags: tags ?? [],
- parents: head == null ? [] : [head.id],
- branch: curBranch,
- };
- head = commit;
- commits.set(commit.id, commit);
- branches.set(curBranch, commit.id);
- log.debug('in pushCommit ' + commit.id);
-};
-
-export const branch = function (name, order) {
- name = common.sanitizeText(name, getConfig());
- if (!branches.has(name)) {
- branches.set(name, head != null ? head.id : null);
- branchesConfig.set(name, { name, order: order ? parseInt(order, 10) : null });
- checkout(name);
- log.debug('in createBranch');
- } else {
- let error = new Error(
- 'Trying to create an existing branch. (Help: Either use a new name if you want create a new branch or try using "checkout ' +
- name +
- '")'
- );
- error.hash = {
- text: 'branch ' + name,
- token: 'branch ' + name,
- line: '1',
- loc: { first_line: 1, last_line: 1, first_column: 1, last_column: 1 },
- expected: ['"checkout ' + name + '"'],
- };
- throw error;
- }
-};
-
-export const merge = function (otherBranch, custom_id, override_type, custom_tags) {
- const config = getConfig();
- otherBranch = common.sanitizeText(otherBranch, config);
- custom_id = common.sanitizeText(custom_id, config);
-
- const currentCommit = commits.get(branches.get(curBranch));
- const otherCommit = commits.get(branches.get(otherBranch));
- if (curBranch === otherBranch) {
- let error = new Error('Incorrect usage of "merge". Cannot merge a branch to itself');
- error.hash = {
- text: 'merge ' + otherBranch,
- token: 'merge ' + otherBranch,
- line: '1',
- loc: { first_line: 1, last_line: 1, first_column: 1, last_column: 1 },
- expected: ['branch abc'],
- };
- throw error;
- } else if (currentCommit === undefined || !currentCommit) {
- let error = new Error(
- 'Incorrect usage of "merge". Current branch (' + curBranch + ')has no commits'
- );
- error.hash = {
- text: 'merge ' + otherBranch,
- token: 'merge ' + otherBranch,
- line: '1',
- loc: { first_line: 1, last_line: 1, first_column: 1, last_column: 1 },
- expected: ['commit'],
- };
- throw error;
- } else if (!branches.has(otherBranch)) {
- let error = new Error(
- 'Incorrect usage of "merge". Branch to be merged (' + otherBranch + ') does not exist'
- );
- error.hash = {
- text: 'merge ' + otherBranch,
- token: 'merge ' + otherBranch,
- line: '1',
- loc: { first_line: 1, last_line: 1, first_column: 1, last_column: 1 },
- expected: ['branch ' + otherBranch],
- };
- throw error;
- } else if (otherCommit === undefined || !otherCommit) {
- let error = new Error(
- 'Incorrect usage of "merge". Branch to be merged (' + otherBranch + ') has no commits'
- );
- error.hash = {
- text: 'merge ' + otherBranch,
- token: 'merge ' + otherBranch,
- line: '1',
- loc: { first_line: 1, last_line: 1, first_column: 1, last_column: 1 },
- expected: ['"commit"'],
- };
- throw error;
- } else if (currentCommit === otherCommit) {
- let error = new Error('Incorrect usage of "merge". Both branches have same head');
- error.hash = {
- text: 'merge ' + otherBranch,
- token: 'merge ' + otherBranch,
- line: '1',
- loc: { first_line: 1, last_line: 1, first_column: 1, last_column: 1 },
- expected: ['branch abc'],
- };
- throw error;
- } else if (custom_id && commits.has(custom_id)) {
- let error = new Error(
- 'Incorrect usage of "merge". Commit with id:' +
- custom_id +
- ' already exists, use different custom Id'
- );
- error.hash = {
- text: 'merge ' + otherBranch + custom_id + override_type + custom_tags?.join(','),
- token: 'merge ' + otherBranch + custom_id + override_type + custom_tags?.join(','),
- line: '1',
- loc: { first_line: 1, last_line: 1, first_column: 1, last_column: 1 },
- expected: [
- `merge ${otherBranch} ${custom_id}_UNIQUE ${override_type} ${custom_tags?.join(',')}`,
- ],
- };
-
- throw error;
- }
- // if (isReachableFrom(currentCommit, otherCommit)) {
- // log.debug('Already merged');
- // return;
- // }
- // if (isFastForwardable(currentCommit, otherCommit)) {
- // branches.set(curBranch, branches.get(otherBranch));
- // head = commits.get(branches.get(curBranch));
- // } else {
- // create merge commit
- const commit = {
- id: custom_id ? custom_id : seq + '-' + getId(),
- message: 'merged branch ' + otherBranch + ' into ' + curBranch,
- seq: seq++,
- parents: [head == null ? null : head.id, branches.get(otherBranch)],
- branch: curBranch,
- type: commitType.MERGE,
- customType: override_type,
- customId: custom_id ? true : false,
- tags: custom_tags ? custom_tags : [],
- };
- head = commit;
- commits.set(commit.id, commit);
- branches.set(curBranch, commit.id);
- // }
- log.debug(branches);
- log.debug('in mergeBranch');
-};
-
-export const cherryPick = function (sourceId, targetId, tags, parentCommitId) {
- log.debug('Entering cherryPick:', sourceId, targetId, tags);
- const config = getConfig();
- sourceId = common.sanitizeText(sourceId, config);
- targetId = common.sanitizeText(targetId, config);
- tags = tags?.map((tag) => common.sanitizeText(tag, config));
- parentCommitId = common.sanitizeText(parentCommitId, config);
-
- if (!sourceId || !commits.has(sourceId)) {
- let error = new Error(
- 'Incorrect usage of "cherryPick". Source commit id should exist and provided'
- );
- error.hash = {
- text: 'cherryPick ' + sourceId + ' ' + targetId,
- token: 'cherryPick ' + sourceId + ' ' + targetId,
- line: '1',
- loc: { first_line: 1, last_line: 1, first_column: 1, last_column: 1 },
- expected: ['cherry-pick abc'],
- };
- throw error;
- }
- let sourceCommit = commits.get(sourceId);
- let sourceCommitBranch = sourceCommit.branch;
- if (
- parentCommitId &&
- !(Array.isArray(sourceCommit.parents) && sourceCommit.parents.includes(parentCommitId))
- ) {
- let error = new Error(
- 'Invalid operation: The specified parent commit is not an immediate parent of the cherry-picked commit.'
- );
- throw error;
- }
- if (sourceCommit.type === commitType.MERGE && !parentCommitId) {
- let error = new Error(
- 'Incorrect usage of cherry-pick: If the source commit is a merge commit, an immediate parent commit must be specified.'
- );
- throw error;
- }
- if (!targetId || !commits.has(targetId)) {
- // cherry-pick source commit to current branch
-
- if (sourceCommitBranch === curBranch) {
- let error = new Error(
- 'Incorrect usage of "cherryPick". Source commit is already on current branch'
- );
- error.hash = {
- text: 'cherryPick ' + sourceId + ' ' + targetId,
- token: 'cherryPick ' + sourceId + ' ' + targetId,
- line: '1',
- loc: { first_line: 1, last_line: 1, first_column: 1, last_column: 1 },
- expected: ['cherry-pick abc'],
- };
- throw error;
- }
- const currentCommit = commits.get(branches.get(curBranch));
- if (currentCommit === undefined || !currentCommit) {
- let error = new Error(
- 'Incorrect usage of "cherry-pick". Current branch (' + curBranch + ')has no commits'
- );
- error.hash = {
- text: 'cherryPick ' + sourceId + ' ' + targetId,
- token: 'cherryPick ' + sourceId + ' ' + targetId,
- line: '1',
- loc: { first_line: 1, last_line: 1, first_column: 1, last_column: 1 },
- expected: ['cherry-pick abc'],
- };
- throw error;
- }
- const commit = {
- id: seq + '-' + getId(),
- message: 'cherry-picked ' + sourceCommit + ' into ' + curBranch,
- seq: seq++,
- parents: [head == null ? null : head.id, sourceCommit.id],
- branch: curBranch,
- type: commitType.CHERRY_PICK,
- tags: tags
- ? tags.filter(Boolean)
- : [
- `cherry-pick:${sourceCommit.id}${
- sourceCommit.type === commitType.MERGE ? `|parent:${parentCommitId}` : ''
- }`,
- ],
- };
- head = commit;
- commits.set(commit.id, commit);
- branches.set(curBranch, commit.id);
- log.debug(branches);
- log.debug('in cherryPick');
- }
-};
-export const checkout = function (branch) {
- branch = common.sanitizeText(branch, getConfig());
- if (!branches.has(branch)) {
- let error = new Error(
- 'Trying to checkout branch which is not yet created. (Help try using "branch ' + branch + '")'
- );
- error.hash = {
- text: 'checkout ' + branch,
- token: 'checkout ' + branch,
- line: '1',
- loc: { first_line: 1, last_line: 1, first_column: 1, last_column: 1 },
- expected: ['"branch ' + branch + '"'],
- };
- throw error;
- } else {
- curBranch = branch;
- const id = branches.get(curBranch);
- head = commits.get(id);
- }
-};
-
-// export const reset = function (commitRef) {
-// log.debug('in reset', commitRef);
-// const ref = commitRef.split(':')[0];
-// let parentCount = parseInt(commitRef.split(':')[1]);
-// let commit = ref === 'HEAD' ? head : commits.get(branches.get(ref));
-// log.debug(commit, parentCount);
-// while (parentCount > 0) {
-// commit = commits.get(commit.parent);
-// parentCount--;
-// if (!commit) {
-// const err = 'Critical error - unique parent commit not found during reset';
-// log.error(err);
-// throw err;
-// }
-// }
-// head = commit;
-// branches[curBranch] = commit.id;
-// };
-
-/**
- * @param arr
- * @param key
- * @param newVal
- */
-function upsert(arr, key, newVal) {
- const index = arr.indexOf(key);
- if (index === -1) {
- arr.push(newVal);
- } else {
- arr.splice(index, 1, newVal);
- }
-}
-
-/** @param commitArr */
-function prettyPrintCommitHistory(commitArr) {
- const commit = commitArr.reduce((out, commit) => {
- if (out.seq > commit.seq) {
- return out;
- }
- return commit;
- }, commitArr[0]);
- let line = '';
- commitArr.forEach(function (c) {
- if (c === commit) {
- line += '\t*';
- } else {
- line += '\t|';
- }
- });
- const label = [line, commit.id, commit.seq];
- for (let branch in branches) {
- if (branches.get(branch) === commit.id) {
- label.push(branch);
- }
- }
- log.debug(label.join(' '));
- if (commit.parents && commit.parents.length == 2) {
- const newCommit = commits.get(commit.parents[0]);
- upsert(commitArr, commit, newCommit);
- commitArr.push(commits.get(commit.parents[1]));
- } else if (commit.parents.length == 0) {
- return;
- } else {
- const nextCommit = commits.get(commit.parents);
- upsert(commitArr, commit, nextCommit);
- }
- commitArr = uniqBy(commitArr, (c) => c.id);
- prettyPrintCommitHistory(commitArr);
-}
-
-export const prettyPrint = function () {
- log.debug(commits);
- const node = getCommitsArray()[0];
- prettyPrintCommitHistory([node]);
-};
-
-export const clear = function () {
- commits = new Map();
- head = null;
- const { mainBranchName, mainBranchOrder } = getConfig().gitGraph;
- branches = new Map();
- branches.set(mainBranchName, null);
- branchesConfig = new Map();
- branchesConfig.set(mainBranchName, { name: mainBranchName, order: mainBranchOrder });
- curBranch = mainBranchName;
- seq = 0;
- commonClear();
-};
-
-export const getBranchesAsObjArray = function () {
- const branchesArray = [...branchesConfig.values()]
- .map((branchConfig, i) => {
- if (branchConfig.order !== null) {
- return branchConfig;
- }
- return {
- ...branchConfig,
- order: parseFloat(`0.${i}`, 10),
- };
- })
- .sort((a, b) => a.order - b.order)
- .map(({ name }) => ({ name }));
-
- return branchesArray;
-};
-
-export const getBranches = function () {
- return branches;
-};
-export const getCommits = function () {
- return commits;
-};
-export const getCommitsArray = function () {
- const commitArr = [...commits.values()];
- commitArr.forEach(function (o) {
- log.debug(o.id);
- });
- commitArr.sort((a, b) => a.seq - b.seq);
- return commitArr;
-};
-export const getCurrentBranch = function () {
- return curBranch;
-};
-export const getDirection = function () {
- return direction;
-};
-export const getHead = function () {
- return head;
-};
-
-export const commitType = {
- NORMAL: 0,
- REVERSE: 1,
- HIGHLIGHT: 2,
- MERGE: 3,
- CHERRY_PICK: 4,
-};
-
-export default {
- getConfig: () => getConfig().gitGraph,
- setDirection,
- setOptions,
- getOptions,
- commit,
- branch,
- merge,
- cherryPick,
- checkout,
- //reset,
- prettyPrint,
- clear,
- getBranchesAsObjArray,
- getBranches,
- getCommits,
- getCommitsArray,
- getCurrentBranch,
- getDirection,
- getHead,
- setAccTitle,
- getAccTitle,
- getAccDescription,
- setAccDescription,
- setDiagramTitle,
- getDiagramTitle,
- commitType,
-};
diff --git a/packages/mermaid/src/diagrams/git/gitGraphAst.ts b/packages/mermaid/src/diagrams/git/gitGraphAst.ts
new file mode 100644
index 000000000..44597e9d7
--- /dev/null
+++ b/packages/mermaid/src/diagrams/git/gitGraphAst.ts
@@ -0,0 +1,522 @@
+import { log } from '../../logger.js';
+import { cleanAndMerge, random } from '../../utils.js';
+import { getConfig as commonGetConfig } from '../../config.js';
+import common from '../common/common.js';
+import {
+ setAccTitle,
+ getAccTitle,
+ getAccDescription,
+ setAccDescription,
+ clear as commonClear,
+ setDiagramTitle,
+ getDiagramTitle,
+} from '../common/commonDb.js';
+import type {
+ DiagramOrientation,
+ Commit,
+ GitGraphDB,
+ CommitDB,
+ MergeDB,
+ BranchDB,
+ CherryPickDB,
+} from './gitGraphTypes.js';
+import { commitType } from './gitGraphTypes.js';
+import { ImperativeState } from '../../utils/imperativeState.js';
+
+import DEFAULT_CONFIG from '../../defaultConfig.js';
+
+import type { GitGraphDiagramConfig } from '../../config.type.js';
+interface GitGraphState {
+ commits: Map;
+ head: Commit | null;
+ branchConfig: Map;
+ branches: Map;
+ currBranch: string;
+ direction: DiagramOrientation;
+ seq: number;
+ options: any;
+}
+
+const DEFAULT_GITGRAPH_CONFIG: Required = DEFAULT_CONFIG.gitGraph;
+const getConfig = (): Required => {
+ const config = cleanAndMerge({
+ ...DEFAULT_GITGRAPH_CONFIG,
+ ...commonGetConfig().gitGraph,
+ });
+ return config;
+};
+
+const state = new ImperativeState(() => {
+ const config = getConfig();
+ const mainBranchName = config.mainBranchName;
+ const mainBranchOrder = config.mainBranchOrder;
+ return {
+ mainBranchName,
+ commits: new Map(),
+ head: null,
+ branchConfig: new Map([[mainBranchName, { name: mainBranchName, order: mainBranchOrder }]]),
+ branches: new Map([[mainBranchName, null]]),
+ currBranch: mainBranchName,
+ direction: 'LR',
+ seq: 0,
+ options: {},
+ };
+});
+
+function getID() {
+ return random({ length: 7 });
+}
+
+/**
+ * @param list - list of items
+ * @param fn - function to get the key
+ */
+function uniqBy(list: any[], fn: (item: any) => any) {
+ const recordMap = Object.create(null);
+ return list.reduce((out, item) => {
+ const key = fn(item);
+ if (!recordMap[key]) {
+ recordMap[key] = true;
+ out.push(item);
+ }
+ return out;
+ }, []);
+}
+
+export const setDirection = function (dir: DiagramOrientation) {
+ state.records.direction = dir;
+};
+
+export const setOptions = function (rawOptString: string) {
+ log.debug('options str', rawOptString);
+ rawOptString = rawOptString?.trim();
+ rawOptString = rawOptString || '{}';
+ try {
+ state.records.options = JSON.parse(rawOptString);
+ } catch (e: any) {
+ log.error('error while parsing gitGraph options', e.message);
+ }
+};
+
+export const getOptions = function () {
+ return state.records.options;
+};
+
+export const commit = function (commitDB: CommitDB) {
+ let msg = commitDB.msg;
+ let id = commitDB.id;
+ const type = commitDB.type;
+ let tags = commitDB.tags;
+
+ log.info('commit', msg, id, type, tags);
+ log.debug('Entering commit:', msg, id, type, tags);
+ const config = getConfig();
+ id = common.sanitizeText(id, config);
+ msg = common.sanitizeText(msg, config);
+ tags = tags?.map((tag) => common.sanitizeText(tag, config));
+ const newCommit: Commit = {
+ id: id ? id : state.records.seq + '-' + getID(),
+ message: msg,
+ seq: state.records.seq++,
+ type: type ?? commitType.NORMAL,
+ tags: tags ?? [],
+ parents: state.records.head == null ? [] : [state.records.head.id],
+ branch: state.records.currBranch,
+ };
+ state.records.head = newCommit;
+ log.info('main branch', config.mainBranchName);
+ state.records.commits.set(newCommit.id, newCommit);
+ state.records.branches.set(state.records.currBranch, newCommit.id);
+ log.debug('in pushCommit ' + newCommit.id);
+};
+
+export const branch = function (branchDB: BranchDB) {
+ let name = branchDB.name;
+ const order = branchDB.order;
+ name = common.sanitizeText(name, getConfig());
+ if (state.records.branches.has(name)) {
+ throw new Error(
+ `Trying to create an existing branch. (Help: Either use a new name if you want create a new branch or try using "checkout ${name}")`
+ );
+ }
+
+ state.records.branches.set(name, state.records.head != null ? state.records.head.id : null);
+ state.records.branchConfig.set(name, { name, order });
+ checkout(name);
+ log.debug('in createBranch');
+};
+
+export const merge = (mergeDB: MergeDB): void => {
+ let otherBranch = mergeDB.branch;
+ let customId = mergeDB.id;
+ const overrideType = mergeDB.type;
+ const customTags = mergeDB.tags;
+ const config = getConfig();
+ otherBranch = common.sanitizeText(otherBranch, config);
+ if (customId) {
+ customId = common.sanitizeText(customId, config);
+ }
+ const currentBranchCheck = state.records.branches.get(state.records.currBranch);
+ const otherBranchCheck = state.records.branches.get(otherBranch);
+ const currentCommit = currentBranchCheck
+ ? state.records.commits.get(currentBranchCheck)
+ : undefined;
+ const otherCommit: Commit | undefined = otherBranchCheck
+ ? state.records.commits.get(otherBranchCheck)
+ : undefined;
+ if (currentCommit && otherCommit && currentCommit.branch === otherBranch) {
+ throw new Error(`Cannot merge branch '${otherBranch}' into itself.`);
+ }
+ if (state.records.currBranch === otherBranch) {
+ const error: any = new Error('Incorrect usage of "merge". Cannot merge a branch to itself');
+ error.hash = {
+ text: `merge ${otherBranch}`,
+ token: `merge ${otherBranch}`,
+ expected: ['branch abc'],
+ };
+ throw error;
+ }
+ if (currentCommit === undefined || !currentCommit) {
+ const error: any = new Error(
+ `Incorrect usage of "merge". Current branch (${state.records.currBranch})has no commits`
+ );
+ error.hash = {
+ text: `merge ${otherBranch}`,
+ token: `merge ${otherBranch}`,
+ expected: ['commit'],
+ };
+ throw error;
+ }
+ if (!state.records.branches.has(otherBranch)) {
+ const error: any = new Error(
+ 'Incorrect usage of "merge". Branch to be merged (' + otherBranch + ') does not exist'
+ );
+ error.hash = {
+ text: `merge ${otherBranch}`,
+ token: `merge ${otherBranch}`,
+ expected: [`branch ${otherBranch}`],
+ };
+ throw error;
+ }
+ if (otherCommit === undefined || !otherCommit) {
+ const error: any = new Error(
+ 'Incorrect usage of "merge". Branch to be merged (' + otherBranch + ') has no commits'
+ );
+ error.hash = {
+ text: `merge ${otherBranch}`,
+ token: `merge ${otherBranch}`,
+ expected: ['"commit"'],
+ };
+ throw error;
+ }
+ if (currentCommit === otherCommit) {
+ const error: any = new Error('Incorrect usage of "merge". Both branches have same head');
+ error.hash = {
+ text: `merge ${otherBranch}`,
+ token: `merge ${otherBranch}`,
+ expected: ['branch abc'],
+ };
+ throw error;
+ }
+ if (customId && state.records.commits.has(customId)) {
+ const error: any = new Error(
+ 'Incorrect usage of "merge". Commit with id:' +
+ customId +
+ ' already exists, use different custom Id'
+ );
+ error.hash = {
+ text: `merge ${otherBranch} ${customId} ${overrideType} ${customTags?.join(' ')}`,
+ token: `merge ${otherBranch} ${customId} ${overrideType} ${customTags?.join(' ')}`,
+ expected: [
+ `merge ${otherBranch} ${customId}_UNIQUE ${overrideType} ${customTags?.join(' ')}`,
+ ],
+ };
+
+ throw error;
+ }
+
+ const verifiedBranch: string = otherBranchCheck ? otherBranchCheck : ''; //figure out a cleaner way to do this
+
+ const commit = {
+ id: customId || `${state.records.seq}-${getID()}`,
+ message: `merged branch ${otherBranch} into ${state.records.currBranch}`,
+ seq: state.records.seq++,
+ parents: state.records.head == null ? [] : [state.records.head.id, verifiedBranch],
+ branch: state.records.currBranch,
+ type: commitType.MERGE,
+ customType: overrideType,
+ customId: customId ? true : false,
+ tags: customTags ?? [],
+ } satisfies Commit;
+ state.records.head = commit;
+ state.records.commits.set(commit.id, commit);
+ state.records.branches.set(state.records.currBranch, commit.id);
+ log.debug(state.records.branches);
+ log.debug('in mergeBranch');
+};
+
+export const cherryPick = function (cherryPickDB: CherryPickDB) {
+ let sourceId = cherryPickDB.id;
+ let targetId = cherryPickDB.targetId;
+ let tags = cherryPickDB.tags;
+ let parentCommitId = cherryPickDB.parent;
+ log.debug('Entering cherryPick:', sourceId, targetId, tags);
+ const config = getConfig();
+ sourceId = common.sanitizeText(sourceId, config);
+ targetId = common.sanitizeText(targetId, config);
+
+ tags = tags?.map((tag) => common.sanitizeText(tag, config));
+
+ parentCommitId = common.sanitizeText(parentCommitId, config);
+
+ if (!sourceId || !state.records.commits.has(sourceId)) {
+ const error: any = new Error(
+ 'Incorrect usage of "cherryPick". Source commit id should exist and provided'
+ );
+ error.hash = {
+ text: `cherryPick ${sourceId} ${targetId}`,
+ token: `cherryPick ${sourceId} ${targetId}`,
+ expected: ['cherry-pick abc'],
+ };
+ throw error;
+ }
+
+ const sourceCommit = state.records.commits.get(sourceId);
+ if (sourceCommit === undefined || !sourceCommit) {
+ throw new Error('Incorrect usage of "cherryPick". Source commit id should exist and provided');
+ }
+ if (
+ parentCommitId &&
+ !(Array.isArray(sourceCommit.parents) && sourceCommit.parents.includes(parentCommitId))
+ ) {
+ const error = new Error(
+ 'Invalid operation: The specified parent commit is not an immediate parent of the cherry-picked commit.'
+ );
+ throw error;
+ }
+ const sourceCommitBranch = sourceCommit.branch;
+ if (sourceCommit.type === commitType.MERGE && !parentCommitId) {
+ const error = new Error(
+ 'Incorrect usage of cherry-pick: If the source commit is a merge commit, an immediate parent commit must be specified.'
+ );
+ throw error;
+ }
+ if (!targetId || !state.records.commits.has(targetId)) {
+ // cherry-pick source commit to current branch
+
+ if (sourceCommitBranch === state.records.currBranch) {
+ const error: any = new Error(
+ 'Incorrect usage of "cherryPick". Source commit is already on current branch'
+ );
+ error.hash = {
+ text: `cherryPick ${sourceId} ${targetId}`,
+ token: `cherryPick ${sourceId} ${targetId}`,
+ expected: ['cherry-pick abc'],
+ };
+ throw error;
+ }
+ const currentCommitId = state.records.branches.get(state.records.currBranch);
+ if (currentCommitId === undefined || !currentCommitId) {
+ const error: any = new Error(
+ `Incorrect usage of "cherry-pick". Current branch (${state.records.currBranch})has no commits`
+ );
+ error.hash = {
+ text: `cherryPick ${sourceId} ${targetId}`,
+ token: `cherryPick ${sourceId} ${targetId}`,
+ expected: ['cherry-pick abc'],
+ };
+ throw error;
+ }
+
+ const currentCommit = state.records.commits.get(currentCommitId);
+ if (currentCommit === undefined || !currentCommit) {
+ const error: any = new Error(
+ `Incorrect usage of "cherry-pick". Current branch (${state.records.currBranch})has no commits`
+ );
+ error.hash = {
+ text: `cherryPick ${sourceId} ${targetId}`,
+ token: `cherryPick ${sourceId} ${targetId}`,
+ expected: ['cherry-pick abc'],
+ };
+ throw error;
+ }
+ const commit = {
+ id: state.records.seq + '-' + getID(),
+ message: `cherry-picked ${sourceCommit?.message} into ${state.records.currBranch}`,
+ seq: state.records.seq++,
+ parents: state.records.head == null ? [] : [state.records.head.id, sourceCommit.id],
+ branch: state.records.currBranch,
+ type: commitType.CHERRY_PICK,
+ tags: tags
+ ? tags.filter(Boolean)
+ : [
+ `cherry-pick:${sourceCommit.id}${
+ sourceCommit.type === commitType.MERGE ? `|parent:${parentCommitId}` : ''
+ }`,
+ ],
+ };
+
+ state.records.head = commit;
+ state.records.commits.set(commit.id, commit);
+ state.records.branches.set(state.records.currBranch, commit.id);
+ log.debug(state.records.branches);
+ log.debug('in cherryPick');
+ }
+};
+export const checkout = function (branch: string) {
+ branch = common.sanitizeText(branch, getConfig());
+ if (!state.records.branches.has(branch)) {
+ const error: any = new Error(
+ `Trying to checkout branch which is not yet created. (Help try using "branch ${branch}")`
+ );
+ error.hash = {
+ text: `checkout ${branch}`,
+ token: `checkout ${branch}`,
+ expected: [`branch ${branch}`],
+ };
+ throw error;
+ } else {
+ state.records.currBranch = branch;
+ const id = state.records.branches.get(state.records.currBranch);
+ if (id === undefined || !id) {
+ state.records.head = null;
+ } else {
+ state.records.head = state.records.commits.get(id) ?? null;
+ }
+ }
+};
+
+/**
+ * @param arr - array
+ * @param key - key
+ * @param newVal - new value
+ */
+function upsert(arr: any[], key: any, newVal: any) {
+ const index = arr.indexOf(key);
+ if (index === -1) {
+ arr.push(newVal);
+ } else {
+ arr.splice(index, 1, newVal);
+ }
+}
+
+function prettyPrintCommitHistory(commitArr: Commit[]) {
+ const commit = commitArr.reduce((out, commit) => {
+ if (out.seq > commit.seq) {
+ return out;
+ }
+ return commit;
+ }, commitArr[0]);
+ let line = '';
+ commitArr.forEach(function (c) {
+ if (c === commit) {
+ line += '\t*';
+ } else {
+ line += '\t|';
+ }
+ });
+ const label = [line, commit.id, commit.seq];
+ for (const branch in state.records.branches) {
+ if (state.records.branches.get(branch) === commit.id) {
+ label.push(branch);
+ }
+ }
+ log.debug(label.join(' '));
+ if (commit.parents && commit.parents.length == 2 && commit.parents[0] && commit.parents[1]) {
+ const newCommit = state.records.commits.get(commit.parents[0]);
+ upsert(commitArr, commit, newCommit);
+ if (commit.parents[1]) {
+ commitArr.push(state.records.commits.get(commit.parents[1])!);
+ }
+ } else if (commit.parents.length == 0) {
+ return;
+ } else {
+ if (commit.parents[0]) {
+ const newCommit = state.records.commits.get(commit.parents[0]);
+ upsert(commitArr, commit, newCommit);
+ }
+ }
+ commitArr = uniqBy(commitArr, (c) => c.id);
+ prettyPrintCommitHistory(commitArr);
+}
+
+export const prettyPrint = function () {
+ log.debug(state.records.commits);
+ const node = getCommitsArray()[0];
+ prettyPrintCommitHistory([node]);
+};
+
+export const clear = function () {
+ state.reset();
+ commonClear();
+};
+
+export const getBranchesAsObjArray = function () {
+ const branchesArray = [...state.records.branchConfig.values()]
+ .map((branchConfig, i) => {
+ if (branchConfig.order !== null && branchConfig.order !== undefined) {
+ return branchConfig;
+ }
+ return {
+ ...branchConfig,
+ order: parseFloat(`0.${i}`),
+ };
+ })
+ .sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
+ .map(({ name }) => ({ name }));
+
+ return branchesArray;
+};
+
+export const getBranches = function () {
+ return state.records.branches;
+};
+export const getCommits = function () {
+ return state.records.commits;
+};
+export const getCommitsArray = function () {
+ const commitArr = [...state.records.commits.values()];
+ commitArr.forEach(function (o) {
+ log.debug(o.id);
+ });
+ commitArr.sort((a, b) => a.seq - b.seq);
+ return commitArr;
+};
+export const getCurrentBranch = function () {
+ return state.records.currBranch;
+};
+export const getDirection = function () {
+ return state.records.direction;
+};
+export const getHead = function () {
+ return state.records.head;
+};
+
+export const db: GitGraphDB = {
+ commitType,
+ getConfig,
+ setDirection,
+ setOptions,
+ getOptions,
+ commit,
+ branch,
+ merge,
+ cherryPick,
+ checkout,
+ //reset,
+ prettyPrint,
+ clear,
+ getBranchesAsObjArray,
+ getBranches,
+ getCommits,
+ getCommitsArray,
+ getCurrentBranch,
+ getDirection,
+ getHead,
+ setAccTitle,
+ getAccTitle,
+ getAccDescription,
+ setAccDescription,
+ setDiagramTitle,
+ getDiagramTitle,
+};
diff --git a/packages/mermaid/src/diagrams/git/gitGraphDiagram.ts b/packages/mermaid/src/diagrams/git/gitGraphDiagram.ts
index 2a9efdb59..d6e8a0613 100644
--- a/packages/mermaid/src/diagrams/git/gitGraphDiagram.ts
+++ b/packages/mermaid/src/diagrams/git/gitGraphDiagram.ts
@@ -1,13 +1,13 @@
// @ts-ignore: JISON doesn't support types
-import gitGraphParser from './parser/gitGraph.jison';
-import gitGraphDb from './gitGraphAst.js';
+import { parser } from './gitGraphParser.js';
+import { db } from './gitGraphAst.js';
import gitGraphRenderer from './gitGraphRenderer.js';
import gitGraphStyles from './styles.js';
import type { DiagramDefinition } from '../../diagram-api/types.js';
export const diagram: DiagramDefinition = {
- parser: gitGraphParser,
- db: gitGraphDb,
+ parser,
+ db,
renderer: gitGraphRenderer,
styles: gitGraphStyles,
};
diff --git a/packages/mermaid/src/diagrams/git/gitGraphParser.spec.js b/packages/mermaid/src/diagrams/git/gitGraphParser.spec.js
deleted file mode 100644
index d498577fe..000000000
--- a/packages/mermaid/src/diagrams/git/gitGraphParser.spec.js
+++ /dev/null
@@ -1,272 +0,0 @@
-import gitGraphAst from './gitGraphAst.js';
-import { parser } from './parser/gitGraph.jison';
-
-describe('when parsing a gitGraph', function () {
- beforeEach(function () {
- parser.yy = gitGraphAst;
- parser.yy.clear();
- });
- it('should handle a gitGraph definition', function () {
- const str = 'gitGraph:\n' + 'commit\n';
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
-
- expect(commits.size).toBe(1);
- expect(parser.yy.getCurrentBranch()).toBe('main');
- expect(parser.yy.getDirection()).toBe('LR');
- expect(parser.yy.getBranches().size).toBe(1);
- });
-
- it('should handle a gitGraph definition with empty options', function () {
- const str = 'gitGraph:\n' + 'options\n' + ' end\n' + 'commit\n';
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
-
- expect(parser.yy.getOptions()).toEqual({});
- expect(commits.size).toBe(1);
- expect(parser.yy.getCurrentBranch()).toBe('main');
- expect(parser.yy.getDirection()).toBe('LR');
- expect(parser.yy.getBranches().size).toBe(1);
- });
-
- it('should handle a gitGraph definition with valid options', function () {
- const str = 'gitGraph:\n' + 'options\n' + '{"key": "value"}\n' + 'end\n' + 'commit\n';
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- expect(parser.yy.getOptions().key).toBe('value');
- expect(commits.size).toBe(1);
- expect(parser.yy.getCurrentBranch()).toBe('main');
- expect(parser.yy.getDirection()).toBe('LR');
- expect(parser.yy.getBranches().size).toBe(1);
- });
-
- it('should not fail on a gitGraph with malformed json', function () {
- const str = 'gitGraph:\n' + 'options\n' + '{"key": "value"\n' + 'end\n' + 'commit\n';
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(1);
- expect(parser.yy.getCurrentBranch()).toBe('main');
- expect(parser.yy.getDirection()).toBe('LR');
- expect(parser.yy.getBranches().size).toBe(1);
- });
-
- it('should handle set direction top to bottom', function () {
- const str = 'gitGraph TB:\n' + 'commit\n';
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
-
- expect(commits.size).toBe(1);
- expect(parser.yy.getCurrentBranch()).toBe('main');
- expect(parser.yy.getDirection()).toBe('TB');
- expect(parser.yy.getBranches().size).toBe(1);
- });
-
- it('should handle set direction bottom to top', function () {
- const str = 'gitGraph BT:\n' + 'commit\n';
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
-
- expect(commits.size).toBe(1);
- expect(parser.yy.getCurrentBranch()).toBe('main');
- expect(parser.yy.getDirection()).toBe('BT');
- expect(parser.yy.getBranches().size).toBe(1);
- });
-
- it('should checkout a branch', function () {
- const str = 'gitGraph:\n' + 'branch new\n' + 'checkout new\n';
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
-
- expect(commits.size).toBe(0);
- expect(parser.yy.getCurrentBranch()).toBe('new');
- });
-
- it('should switch a branch', function () {
- const str = 'gitGraph:\n' + 'branch new\n' + 'switch new\n';
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
-
- expect(commits.size).toBe(0);
- expect(parser.yy.getCurrentBranch()).toBe('new');
- });
-
- it('should add commits to checked out branch', function () {
- const str = 'gitGraph:\n' + 'branch new\n' + 'checkout new\n' + 'commit\n' + 'commit\n';
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
-
- expect(commits.size).toBe(2);
- expect(parser.yy.getCurrentBranch()).toBe('new');
- const branchCommit = parser.yy.getBranches().get('new');
- expect(branchCommit).not.toBeNull();
- expect(commits.get(branchCommit).parent).not.toBeNull();
- });
- it('should handle commit with args', function () {
- const str = 'gitGraph:\n' + 'commit "a commit"\n';
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
-
- expect(commits.size).toBe(1);
- const key = commits.keys().next().value;
- expect(commits.get(key).message).toBe('a commit');
- expect(parser.yy.getCurrentBranch()).toBe('main');
- });
-
- // Reset has been commented out in JISON
- it.skip('should reset a branch', function () {
- const str =
- 'gitGraph:\n' +
- 'commit\n' +
- 'commit\n' +
- 'branch newbranch\n' +
- 'checkout newbranch\n' +
- 'commit\n' +
- 'reset main\n';
-
- parser.parse(str);
-
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(3);
- expect(parser.yy.getCurrentBranch()).toBe('newbranch');
- expect(parser.yy.getBranches().get('newbranch')).toEqual(parser.yy.getBranches().get('main'));
- expect(parser.yy.getHead().id).toEqual(parser.yy.getBranches().get('newbranch'));
- });
-
- it.skip('reset can take an argument', function () {
- const str =
- 'gitGraph:\n' +
- 'commit\n' +
- 'commit\n' +
- 'branch newbranch\n' +
- 'checkout newbranch\n' +
- 'commit\n' +
- 'reset main^\n';
-
- parser.parse(str);
-
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(3);
- expect(parser.yy.getCurrentBranch()).toBe('newbranch');
- const main = commits.get(parser.yy.getBranches().get('main'));
- expect(parser.yy.getHead().id).toEqual(main.parent);
- });
-
- it.skip('should handle fast forwardable merges', function () {
- const str =
- 'gitGraph:\n' +
- 'commit\n' +
- 'branch newbranch\n' +
- 'checkout newbranch\n' +
- 'commit\n' +
- 'commit\n' +
- 'checkout main\n' +
- 'merge newbranch\n';
-
- parser.parse(str);
-
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(4);
- expect(parser.yy.getCurrentBranch()).toBe('main');
- expect(parser.yy.getBranches().get('newbranch')).toEqual(parser.yy.getBranches().get('main'));
- expect(parser.yy.getHead().id).toEqual(parser.yy.getBranches().get('newbranch'));
- });
-
- it('should handle cases when merge is a noop', function () {
- const str =
- 'gitGraph:\n' +
- 'commit\n' +
- 'branch newbranch\n' +
- 'checkout newbranch\n' +
- 'commit\n' +
- 'commit\n' +
- 'merge main\n';
-
- parser.parse(str);
-
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(4);
- expect(parser.yy.getCurrentBranch()).toBe('newbranch');
- expect(parser.yy.getBranches().get('newbranch')).not.toEqual(
- parser.yy.getBranches().get('main')
- );
- expect(parser.yy.getHead().id).toEqual(parser.yy.getBranches().get('newbranch'));
- });
-
- it('should handle merge with 2 parents', function () {
- const str =
- 'gitGraph:\n' +
- 'commit\n' +
- 'branch newbranch\n' +
- 'checkout newbranch\n' +
- 'commit\n' +
- 'commit\n' +
- 'checkout main\n' +
- 'commit\n' +
- 'merge newbranch\n';
-
- parser.parse(str);
-
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(5);
- expect(parser.yy.getCurrentBranch()).toBe('main');
- expect(parser.yy.getBranches().get('newbranch')).not.toEqual(
- parser.yy.getBranches().get('main')
- );
- expect(parser.yy.getHead().id).toEqual(parser.yy.getBranches().get('main'));
- });
-
- it.skip('should handle ff merge when history walk has two parents (merge commit)', function () {
- const str =
- 'gitGraph:\n' +
- 'commit\n' +
- 'branch newbranch\n' +
- 'checkout newbranch\n' +
- 'commit\n' +
- 'commit\n' +
- 'checkout main\n' +
- 'commit\n' +
- 'merge newbranch\n' +
- 'commit\n' +
- 'checkout newbranch\n' +
- 'merge main\n';
-
- parser.parse(str);
-
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(7);
- expect(parser.yy.getCurrentBranch()).toBe('newbranch');
- expect(parser.yy.getBranches().get('newbranch')).toEqual(parser.yy.getBranches().get('main'));
- expect(parser.yy.getHead().id).toEqual(parser.yy.getBranches().get('main'));
-
- parser.yy.prettyPrint();
- });
-
- it('should generate an array of known branches', function () {
- const str =
- 'gitGraph:\n' +
- 'commit\n' +
- 'branch b1\n' +
- 'checkout b1\n' +
- 'commit\n' +
- 'commit\n' +
- 'branch b2\n';
-
- parser.parse(str);
- const branches = gitGraphAst.getBranchesAsObjArray();
-
- expect(branches).toHaveLength(3);
- expect(branches[0]).toHaveProperty('name', 'main');
- expect(branches[1]).toHaveProperty('name', 'b1');
- expect(branches[2]).toHaveProperty('name', 'b2');
- });
-});
diff --git a/packages/mermaid/src/diagrams/git/gitGraphParser.ts b/packages/mermaid/src/diagrams/git/gitGraphParser.ts
new file mode 100644
index 000000000..c56bc6f44
--- /dev/null
+++ b/packages/mermaid/src/diagrams/git/gitGraphParser.ts
@@ -0,0 +1,243 @@
+import type { GitGraph } from '@mermaid-js/parser';
+import { parse } from '@mermaid-js/parser';
+import type { ParserDefinition } from '../../diagram-api/types.js';
+import { log } from '../../logger.js';
+import { populateCommonDb } from '../common/populateCommonDb.js';
+import { db } from './gitGraphAst.js';
+import { commitType } from './gitGraphTypes.js';
+import type {
+ CheckoutAst,
+ CherryPickingAst,
+ MergeAst,
+ CommitAst,
+ BranchAst,
+ GitGraphDBParseProvider,
+ CommitDB,
+ BranchDB,
+ MergeDB,
+ CherryPickDB,
+} from './gitGraphTypes.js';
+
+const populate = (ast: GitGraph, db: GitGraphDBParseProvider) => {
+ populateCommonDb(ast, db);
+ // @ts-ignore: this wont exist if the direction is not specified
+ if (ast.dir) {
+ // @ts-ignore: this wont exist if the direction is not specified
+ db.setDirection(ast.dir);
+ }
+ for (const statement of ast.statements) {
+ parseStatement(statement, db);
+ }
+};
+
+const parseStatement = (statement: any, db: GitGraphDBParseProvider) => {
+ const parsers: Record void> = {
+ Commit: (stmt) => db.commit(parseCommit(stmt)),
+ Branch: (stmt) => db.branch(parseBranch(stmt)),
+ Merge: (stmt) => db.merge(parseMerge(stmt)),
+ Checkout: (stmt) => db.checkout(parseCheckout(stmt)),
+ CherryPicking: (stmt) => db.cherryPick(parseCherryPicking(stmt)),
+ };
+
+ const parser = parsers[statement.$type];
+ if (parser) {
+ parser(statement);
+ } else {
+ log.error(`Unknown statement type: ${statement.$type}`);
+ }
+};
+
+const parseCommit = (commit: CommitAst): CommitDB => {
+ const commitDB: CommitDB = {
+ id: commit.id,
+ msg: commit.message ?? '',
+ type: commit.type !== undefined ? commitType[commit.type] : commitType.NORMAL,
+ tags: commit.tags ?? undefined,
+ };
+ return commitDB;
+};
+
+const parseBranch = (branch: BranchAst): BranchDB => {
+ const branchDB: BranchDB = {
+ name: branch.name,
+ order: branch.order ?? 0,
+ };
+ return branchDB;
+};
+
+const parseMerge = (merge: MergeAst): MergeDB => {
+ const mergeDB: MergeDB = {
+ branch: merge.branch,
+ id: merge.id ?? '',
+ type: merge.type !== undefined ? commitType[merge.type] : undefined,
+ tags: merge.tags ?? undefined,
+ };
+ return mergeDB;
+};
+
+const parseCheckout = (checkout: CheckoutAst): string => {
+ const branch = checkout.branch;
+ return branch;
+};
+
+const parseCherryPicking = (cherryPicking: CherryPickingAst): CherryPickDB => {
+ const cherryPickDB: CherryPickDB = {
+ id: cherryPicking.id,
+ targetId: '',
+ tags: cherryPicking.tags?.length === 0 ? undefined : cherryPicking.tags,
+ parent: cherryPicking.parent,
+ };
+ return cherryPickDB;
+};
+
+export const parser: ParserDefinition = {
+ parse: async (input: string): Promise => {
+ const ast: GitGraph = await parse('gitGraph', input);
+ log.debug(ast);
+ populate(ast, db);
+ },
+};
+
+if (import.meta.vitest) {
+ const { it, expect, describe } = import.meta.vitest;
+
+ const mockDB: GitGraphDBParseProvider = {
+ commitType: commitType,
+ setDirection: vi.fn(),
+ commit: vi.fn(),
+ branch: vi.fn(),
+ merge: vi.fn(),
+ cherryPick: vi.fn(),
+ checkout: vi.fn(),
+ };
+
+ describe('GitGraph Parser', () => {
+ it('should parse a commit statement', () => {
+ const commit = {
+ $type: 'Commit',
+ id: '1',
+ message: 'test',
+ tags: ['tag1', 'tag2'],
+ type: 'NORMAL',
+ };
+ parseStatement(commit, mockDB);
+ expect(mockDB.commit).toHaveBeenCalledWith({
+ id: '1',
+ msg: 'test',
+ tags: ['tag1', 'tag2'],
+ type: 0,
+ });
+ });
+ it('should parse a branch statement', () => {
+ const branch = {
+ $type: 'Branch',
+ name: 'newBranch',
+ order: 1,
+ };
+ parseStatement(branch, mockDB);
+ expect(mockDB.branch).toHaveBeenCalledWith({ name: 'newBranch', order: 1 });
+ });
+ it('should parse a checkout statement', () => {
+ const checkout = {
+ $type: 'Checkout',
+ branch: 'newBranch',
+ };
+ parseStatement(checkout, mockDB);
+ expect(mockDB.checkout).toHaveBeenCalledWith('newBranch');
+ });
+ it('should parse a merge statement', () => {
+ const merge = {
+ $type: 'Merge',
+ branch: 'newBranch',
+ id: '1',
+ tags: ['tag1', 'tag2'],
+ type: 'NORMAL',
+ };
+ parseStatement(merge, mockDB);
+ expect(mockDB.merge).toHaveBeenCalledWith({
+ branch: 'newBranch',
+ id: '1',
+ tags: ['tag1', 'tag2'],
+ type: 0,
+ });
+ });
+ it('should parse a cherry picking statement', () => {
+ const cherryPick = {
+ $type: 'CherryPicking',
+ id: '1',
+ tags: ['tag1', 'tag2'],
+ parent: '2',
+ };
+ parseStatement(cherryPick, mockDB);
+ expect(mockDB.cherryPick).toHaveBeenCalledWith({
+ id: '1',
+ targetId: '',
+ parent: '2',
+ tags: ['tag1', 'tag2'],
+ });
+ });
+
+ it('should parse a langium generated gitGraph ast', () => {
+ const dummy: GitGraph = {
+ $type: 'GitGraph',
+ statements: [],
+ };
+ const gitGraphAst: GitGraph = {
+ $type: 'GitGraph',
+ statements: [
+ {
+ $container: dummy,
+ $type: 'Commit',
+ id: '1',
+ message: 'test',
+ tags: ['tag1', 'tag2'],
+ type: 'NORMAL',
+ },
+ {
+ $container: dummy,
+ $type: 'Branch',
+ name: 'newBranch',
+ order: 1,
+ },
+ {
+ $container: dummy,
+ $type: 'Merge',
+ branch: 'newBranch',
+ id: '1',
+ tags: ['tag1', 'tag2'],
+ type: 'NORMAL',
+ },
+ {
+ $container: dummy,
+ $type: 'Checkout',
+ branch: 'newBranch',
+ },
+ {
+ $container: dummy,
+ $type: 'CherryPicking',
+ id: '1',
+ tags: ['tag1', 'tag2'],
+ parent: '2',
+ },
+ ],
+ };
+
+ populate(gitGraphAst, mockDB);
+
+ expect(mockDB.commit).toHaveBeenCalledWith({
+ id: '1',
+ msg: 'test',
+ tags: ['tag1', 'tag2'],
+ type: 0,
+ });
+ expect(mockDB.branch).toHaveBeenCalledWith({ name: 'newBranch', order: 1 });
+ expect(mockDB.merge).toHaveBeenCalledWith({
+ branch: 'newBranch',
+ id: '1',
+ tags: ['tag1', 'tag2'],
+ type: 0,
+ });
+ expect(mockDB.checkout).toHaveBeenCalledWith('newBranch');
+ });
+ });
+}
diff --git a/packages/mermaid/src/diagrams/git/gitGraphParserV2.spec.js b/packages/mermaid/src/diagrams/git/gitGraphParserV2.spec.js
deleted file mode 100644
index 1fb64a5c4..000000000
--- a/packages/mermaid/src/diagrams/git/gitGraphParserV2.spec.js
+++ /dev/null
@@ -1,1107 +0,0 @@
-import gitGraphAst from './gitGraphAst.js';
-import { parser } from './parser/gitGraph.jison';
-
-describe('when parsing a gitGraph', function () {
- beforeEach(function () {
- parser.yy = gitGraphAst;
- parser.yy.clear();
- });
- it('should handle a gitGraph commit with NO pararms, get auto-generated reandom ID', function () {
- const str = `gitGraph:
- commit
- `;
- parser.parse(str);
- const commits = parser.yy.getCommits();
- //console.info(commits);
- expect(commits.size).toBe(1);
- expect(parser.yy.getCurrentBranch()).toBe('main');
- expect(parser.yy.getDirection()).toBe('LR');
- expect(parser.yy.getBranches().size).toBe(1);
- const key = commits.keys().next().value;
- expect(commits.get(key).message).toBe('');
- expect(commits.get(key).id).not.toBeNull();
- expect(commits.get(key).tags).toStrictEqual([]);
- expect(commits.get(key).type).toBe(0);
- });
-
- it('should handle a gitGraph commit with custom commit id only', function () {
- const str = `gitGraph:
- commit id:"1111"
- `;
- parser.parse(str);
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(1);
- expect(parser.yy.getCurrentBranch()).toBe('main');
- expect(parser.yy.getDirection()).toBe('LR');
- expect(parser.yy.getBranches().size).toBe(1);
- const key = commits.keys().next().value;
- expect(commits.get(key).message).toBe('');
- expect(commits.get(key).id).toBe('1111');
- expect(commits.get(key).tags).toStrictEqual([]);
- expect(commits.get(key).type).toBe(0);
- });
-
- it('should handle a gitGraph commit with custom commit tag only', function () {
- const str = `gitGraph:
- commit tag:"test"
- `;
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(1);
- expect(parser.yy.getCurrentBranch()).toBe('main');
- expect(parser.yy.getDirection()).toBe('LR');
- expect(parser.yy.getBranches().size).toBe(1);
- const key = commits.keys().next().value;
- expect(commits.get(key).message).toBe('');
- expect(commits.get(key).id).not.toBeNull();
- expect(commits.get(key).tags).toStrictEqual(['test']);
- expect(commits.get(key).type).toBe(0);
- });
-
- it('should handle a gitGraph commit with custom commit type HIGHLIGHT only', function () {
- const str = `gitGraph:
- commit type: HIGHLIGHT
- `;
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(1);
- expect(parser.yy.getCurrentBranch()).toBe('main');
- expect(parser.yy.getDirection()).toBe('LR');
- expect(parser.yy.getBranches().size).toBe(1);
- const key = commits.keys().next().value;
- expect(commits.get(key).message).toBe('');
- expect(commits.get(key).id).not.toBeNull();
- expect(commits.get(key).tags).toStrictEqual([]);
- expect(commits.get(key).type).toBe(2);
- });
-
- it('should handle a gitGraph commit with custom commit type REVERSE only', function () {
- const str = `gitGraph:
- commit type: REVERSE
- `;
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(1);
- expect(parser.yy.getCurrentBranch()).toBe('main');
- expect(parser.yy.getDirection()).toBe('LR');
- expect(parser.yy.getBranches().size).toBe(1);
- const key = commits.keys().next().value;
- expect(commits.get(key).message).toBe('');
- expect(commits.get(key).id).not.toBeNull();
- expect(commits.get(key).tags).toStrictEqual([]);
- expect(commits.get(key).type).toBe(1);
- });
-
- it('should handle a gitGraph commit with custom commit type NORMAL only', function () {
- const str = `gitGraph:
- commit type: NORMAL
- `;
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(1);
- expect(parser.yy.getCurrentBranch()).toBe('main');
- expect(parser.yy.getDirection()).toBe('LR');
- expect(parser.yy.getBranches().size).toBe(1);
- const key = commits.keys().next().value;
- expect(commits.get(key).message).toBe('');
- expect(commits.get(key).id).not.toBeNull();
- expect(commits.get(key).tags).toStrictEqual([]);
- expect(commits.get(key).type).toBe(0);
- });
-
- it('should handle a gitGraph commit with custom commit msg only', function () {
- const str = `gitGraph:
- commit "test commit"
- `;
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(1);
- expect(parser.yy.getCurrentBranch()).toBe('main');
- expect(parser.yy.getDirection()).toBe('LR');
- expect(parser.yy.getBranches().size).toBe(1);
- const key = commits.keys().next().value;
- expect(commits.get(key).message).toBe('test commit');
- expect(commits.get(key).id).not.toBeNull();
- expect(commits.get(key).tags).toStrictEqual([]);
- expect(commits.get(key).type).toBe(0);
- });
-
- it('should handle a gitGraph commit with custom commit "msg:" key only', function () {
- const str = `gitGraph:
- commit msg: "test commit"
- `;
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(1);
- expect(parser.yy.getCurrentBranch()).toBe('main');
- expect(parser.yy.getDirection()).toBe('LR');
- expect(parser.yy.getBranches().size).toBe(1);
- const key = commits.keys().next().value;
- expect(commits.get(key).message).toBe('test commit');
- expect(commits.get(key).id).not.toBeNull();
- expect(commits.get(key).tags).toStrictEqual([]);
- expect(commits.get(key).type).toBe(0);
- });
-
- it('should handle a gitGraph commit with custom commit id, tag only', function () {
- const str = `gitGraph:
- commit id:"1111" tag: "test tag"
- `;
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(1);
- expect(parser.yy.getCurrentBranch()).toBe('main');
- expect(parser.yy.getDirection()).toBe('LR');
- expect(parser.yy.getBranches().size).toBe(1);
- const key = commits.keys().next().value;
- expect(commits.get(key).message).toBe('');
- expect(commits.get(key).id).toBe('1111');
- expect(commits.get(key).tags).toStrictEqual(['test tag']);
- expect(commits.get(key).type).toBe(0);
- });
-
- it('should handle a gitGraph commit with custom commit type, tag only', function () {
- const str = `gitGraph:
- commit type:HIGHLIGHT tag: "test tag"
- `;
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(1);
- expect(parser.yy.getCurrentBranch()).toBe('main');
- expect(parser.yy.getDirection()).toBe('LR');
- expect(parser.yy.getBranches().size).toBe(1);
- const key = commits.keys().next().value;
- expect(commits.get(key).message).toBe('');
- expect(commits.get(key).id).not.toBeNull();
- expect(commits.get(key).tags).toStrictEqual(['test tag']);
- expect(commits.get(key).type).toBe(2);
- });
-
- it('should handle a gitGraph commit with custom commit tag and type only', function () {
- const str = `gitGraph:
- commit tag: "test tag" type:HIGHLIGHT
- `;
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(1);
- expect(parser.yy.getCurrentBranch()).toBe('main');
- expect(parser.yy.getDirection()).toBe('LR');
- expect(parser.yy.getBranches().size).toBe(1);
- const key = commits.keys().next().value;
- expect(commits.get(key).message).toBe('');
- expect(commits.get(key).id).not.toBeNull();
- expect(commits.get(key).tags).toStrictEqual(['test tag']);
- expect(commits.get(key).type).toBe(2);
- });
-
- it('should handle a gitGraph commit with custom commit id, type and tag only', function () {
- const str = `gitGraph:
- commit id:"1111" type:REVERSE tag: "test tag"
- `;
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(1);
- expect(parser.yy.getCurrentBranch()).toBe('main');
- expect(parser.yy.getDirection()).toBe('LR');
- expect(parser.yy.getBranches().size).toBe(1);
- const key = commits.keys().next().value;
- expect(commits.get(key).message).toBe('');
- expect(commits.get(key).id).toBe('1111');
- expect(commits.get(key).tags).toStrictEqual(['test tag']);
- expect(commits.get(key).type).toBe(1);
- });
-
- it('should handle a gitGraph commit with custom commit id, type, tag and msg', function () {
- const str = `gitGraph:
- commit id:"1111" type:REVERSE tag: "test tag" msg:"test msg"
- `;
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(1);
- expect(parser.yy.getCurrentBranch()).toBe('main');
- expect(parser.yy.getDirection()).toBe('LR');
- expect(parser.yy.getBranches().size).toBe(1);
- const key = commits.keys().next().value;
- expect(commits.get(key).message).toBe('test msg');
- expect(commits.get(key).id).toBe('1111');
- expect(commits.get(key).tags).toStrictEqual(['test tag']);
- expect(commits.get(key).type).toBe(1);
- });
-
- it('should handle a gitGraph commit with custom type,tag, msg, commit id,', function () {
- const str = `gitGraph:
- commit type:REVERSE tag: "test tag" msg: "test msg" id: "1111"
-
- `;
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(1);
- expect(parser.yy.getCurrentBranch()).toBe('main');
- expect(parser.yy.getDirection()).toBe('LR');
- expect(parser.yy.getBranches().size).toBe(1);
- const key = commits.keys().next().value;
- expect(commits.get(key).message).toBe('test msg');
- expect(commits.get(key).id).toBe('1111');
- expect(commits.get(key).tags).toStrictEqual(['test tag']);
- expect(commits.get(key).type).toBe(1);
- });
-
- it('should handle a gitGraph commit with custom tag, msg, commit id, type,', function () {
- const str = `gitGraph:
- commit tag: "test tag" msg:"test msg" id:"1111" type:REVERSE
- `;
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(1);
- expect(parser.yy.getCurrentBranch()).toBe('main');
- expect(parser.yy.getDirection()).toBe('LR');
- expect(parser.yy.getBranches().size).toBe(1);
- const key = commits.keys().next().value;
- expect(commits.get(key).message).toBe('test msg');
- expect(commits.get(key).id).toBe('1111');
- expect(commits.get(key).tags).toStrictEqual(['test tag']);
- expect(commits.get(key).type).toBe(1);
- });
-
- it('should handle a gitGraph commit with custom msg, commit id, type,tag', function () {
- const str = `gitGraph:
- commit msg:"test msg" id:"1111" type:REVERSE tag: "test tag"
- `;
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(1);
- expect(parser.yy.getCurrentBranch()).toBe('main');
- expect(parser.yy.getDirection()).toBe('LR');
- expect(parser.yy.getBranches().size).toBe(1);
- const key = commits.keys().next().value;
- expect(commits.get(key).message).toBe('test msg');
- expect(commits.get(key).id).toBe('1111');
- expect(commits.get(key).tags).toStrictEqual(['test tag']);
- expect(commits.get(key).type).toBe(1);
- });
-
- it('should handle 3 straight commits', function () {
- const str = `gitGraph:
- commit
- commit
- commit
- `;
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(3);
- expect(parser.yy.getCurrentBranch()).toBe('main');
- expect(parser.yy.getDirection()).toBe('LR');
- expect(parser.yy.getBranches().size).toBe(1);
- });
-
- it('should handle new branch creation', function () {
- const str = `gitGraph:
- commit
- branch testBranch
- `;
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(1);
- expect(parser.yy.getCurrentBranch()).toBe('testBranch');
- expect(parser.yy.getDirection()).toBe('LR');
- expect(parser.yy.getBranches().size).toBe(2);
- });
-
- it('should allow quoted branch names', function () {
- const str = `gitGraph:
- commit
- branch "branch"
- checkout "branch"
- commit
- checkout main
- merge "branch"
- `;
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(3);
- expect(parser.yy.getCurrentBranch()).toBe('main');
- expect(parser.yy.getDirection()).toBe('LR');
- expect(parser.yy.getBranches().size).toBe(2);
- const [commit1, commit2, commit3] = commits.keys();
- expect(commits.get(commit1).branch).toBe('main');
- expect(commits.get(commit2).branch).toBe('branch');
- expect(commits.get(commit3).branch).toBe('main');
- expect(parser.yy.getBranchesAsObjArray()).toStrictEqual([{ name: 'main' }, { name: 'branch' }]);
- });
-
- it('should allow _-./ characters in branch names', function () {
- const str = `gitGraph:
- commit
- branch azAZ_-./test
- `;
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(1);
- expect(parser.yy.getCurrentBranch()).toBe('azAZ_-./test');
- expect(parser.yy.getDirection()).toBe('LR');
- expect(parser.yy.getBranches().size).toBe(2);
- });
-
- it('should allow branch names starting with numbers', function () {
- const str = `gitGraph:
- commit
- %% branch names starting with numbers are not recommended, but are supported by git
- branch 1.0.1
- `;
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(1);
- expect(parser.yy.getCurrentBranch()).toBe('1.0.1');
- expect(parser.yy.getDirection()).toBe('LR');
- expect(parser.yy.getBranches().size).toBe(2);
- });
-
- it('should allow branch names starting with unusual prefixes', function () {
- const str = `gitGraph:
- commit
- %% branch names starting with numbers are not recommended, but are supported by git
- branch branch01
- branch checkout02
- branch cherry-pick03
- branch branch/example-branch
- branch merge/test_merge
- %% single character branch name
- branch A
- `;
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(1);
- expect(parser.yy.getCurrentBranch()).toBe('A');
- expect(parser.yy.getDirection()).toBe('LR');
- expect(parser.yy.getBranches().size).toBe(7);
- expect([...parser.yy.getBranches().keys()]).toEqual(
- expect.arrayContaining([
- 'branch01',
- 'checkout02',
- 'cherry-pick03',
- 'branch/example-branch',
- 'merge/test_merge',
- 'A',
- ])
- );
- });
-
- it('should handle new branch checkout', function () {
- const str = `gitGraph:
- commit
- branch testBranch
- checkout testBranch
- `;
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(1);
- expect(parser.yy.getCurrentBranch()).toBe('testBranch');
- expect(parser.yy.getDirection()).toBe('LR');
- expect(parser.yy.getBranches().size).toBe(2);
- });
- it('should handle new branch checkout with order', function () {
- const str = `gitGraph:
- commit
- branch test1 order: 3
- branch test2 order: 2
- branch test3 order: 1
- `;
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(1);
- expect(parser.yy.getCurrentBranch()).toBe('test3');
- expect(parser.yy.getBranches().size).toBe(4);
- expect(parser.yy.getBranchesAsObjArray()).toStrictEqual([
- { name: 'main' },
- { name: 'test3' },
- { name: 'test2' },
- { name: 'test1' },
- ]);
- });
- it('should handle new branch checkout with and without order', function () {
- const str = `gitGraph:
- commit
- branch test1 order: 1
- branch test2
- branch test3
- `;
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(1);
- expect(parser.yy.getCurrentBranch()).toBe('test3');
- expect(parser.yy.getBranches().size).toBe(4);
- expect(parser.yy.getBranchesAsObjArray()).toStrictEqual([
- { name: 'main' },
- { name: 'test2' },
- { name: 'test3' },
- { name: 'test1' },
- ]);
- });
-
- it('should handle new branch checkout & commit', function () {
- const str = `gitGraph:
- commit
- branch testBranch
- checkout testBranch
- commit
- `;
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(2);
- expect(parser.yy.getCurrentBranch()).toBe('testBranch');
- expect(parser.yy.getDirection()).toBe('LR');
- expect(parser.yy.getBranches().size).toBe(2);
- const [commit1, commit2] = commits.keys();
- expect(commits.get(commit1).branch).toBe('main');
- expect(commits.get(commit1).parents).toStrictEqual([]);
- expect(commits.get(commit2).branch).toBe('testBranch');
- expect(commits.get(commit2).parents).toStrictEqual([commit1]);
- });
-
- it('should handle new branch checkout & commit and merge', function () {
- const str = `gitGraph:
- commit
- branch testBranch
- checkout testBranch
- commit
- commit
- checkout main
- merge testBranch
- `;
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(4);
- expect(parser.yy.getCurrentBranch()).toBe('main');
- expect(parser.yy.getDirection()).toBe('LR');
- expect(parser.yy.getBranches().size).toBe(2);
- const [commit1, commit2, commit3, commit4] = commits.keys();
- expect(commits.get(commit1).branch).toBe('main');
- expect(commits.get(commit1).parents).toStrictEqual([]);
- expect(commits.get(commit2).branch).toBe('testBranch');
- expect(commits.get(commit2).parents).toStrictEqual([commits.get(commit1).id]);
- expect(commits.get(commit3).branch).toBe('testBranch');
- expect(commits.get(commit3).parents).toStrictEqual([commits.get(commit2).id]);
- expect(commits.get(commit4).branch).toBe('main');
- expect(commits.get(commit4).parents).toStrictEqual([
- commits.get(commit1).id,
- commits.get(commit3).id,
- ]);
- expect(parser.yy.getBranchesAsObjArray()).toStrictEqual([
- { name: 'main' },
- { name: 'testBranch' },
- ]);
- });
-
- it('should handle new branch switch', function () {
- const str = `gitGraph:
- commit
- branch testBranch
- switch testBranch
- `;
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(1);
- expect(parser.yy.getCurrentBranch()).toBe('testBranch');
- expect(parser.yy.getDirection()).toBe('LR');
- expect(parser.yy.getBranches().size).toBe(2);
- });
-
- it('should handle new branch switch & commit', function () {
- const str = `gitGraph:
- commit
- branch testBranch
- switch testBranch
- commit
- `;
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(2);
- expect(parser.yy.getCurrentBranch()).toBe('testBranch');
- expect(parser.yy.getDirection()).toBe('LR');
- expect(parser.yy.getBranches().size).toBe(2);
- const [commit1, commit2] = commits.keys();
- expect(commits.get(commit1).branch).toBe('main');
- expect(commits.get(commit1).parents).toStrictEqual([]);
- expect(commits.get(commit2).branch).toBe('testBranch');
- expect(commits.get(commit2).parents).toStrictEqual([commit1]);
- });
-
- it('should handle new branch switch & commit and merge', function () {
- const str = `gitGraph:
- commit
- branch testBranch
- switch testBranch
- commit
- commit
- switch main
- merge testBranch
- `;
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(4);
- expect(parser.yy.getCurrentBranch()).toBe('main');
- expect(parser.yy.getDirection()).toBe('LR');
- expect(parser.yy.getBranches().size).toBe(2);
- const [commit1, commit2, commit3, commit4] = commits.keys();
- expect(commits.get(commit1).branch).toBe('main');
- expect(commits.get(commit1).parents).toStrictEqual([]);
- expect(commits.get(commit2).branch).toBe('testBranch');
- expect(commits.get(commit2).parents).toStrictEqual([commits.get(commit1).id]);
- expect(commits.get(commit3).branch).toBe('testBranch');
- expect(commits.get(commit3).parents).toStrictEqual([commits.get(commit2).id]);
- expect(commits.get(commit4).branch).toBe('main');
- expect(commits.get(commit4).parents).toStrictEqual([
- commits.get(commit1).id,
- commits.get(commit3).id,
- ]);
- expect(parser.yy.getBranchesAsObjArray()).toStrictEqual([
- { name: 'main' },
- { name: 'testBranch' },
- ]);
- });
-
- it('should handle merge tags', function () {
- const str = `gitGraph:
- commit
- branch testBranch
- checkout testBranch
- commit
- checkout main
- merge testBranch tag: "merge-tag"
- `;
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(3);
- expect(parser.yy.getCurrentBranch()).toBe('main');
- expect(parser.yy.getDirection()).toBe('LR');
- expect(parser.yy.getBranches().size).toBe(2);
- const [commit1, commit2, commit3] = commits.keys();
- expect(commits.get(commit1).branch).toBe('main');
- expect(commits.get(commit1).parents).toStrictEqual([]);
-
- expect(commits.get(commit2).branch).toBe('testBranch');
- expect(commits.get(commit2).parents).toStrictEqual([commits.get(commit1).id]);
-
- expect(commits.get(commit3).branch).toBe('main');
- expect(commits.get(commit3).parents).toStrictEqual([
- commits.get(commit1).id,
- commits.get(commit2).id,
- ]);
- expect(commits.get(commit3).tags).toStrictEqual(['merge-tag']);
- expect(parser.yy.getBranchesAsObjArray()).toStrictEqual([
- { name: 'main' },
- { name: 'testBranch' },
- ]);
- });
-
- it('should handle merge with custom ids, tags and typr', function () {
- const str = `gitGraph:
- commit
- branch testBranch
- checkout testBranch
- commit
- checkout main
- %% Merge Tag and ID
- merge testBranch tag: "merge-tag" id: "2-222"
- branch testBranch2
- checkout testBranch2
- commit
- checkout main
- %% Merge ID and Tag (reverse order)
- merge testBranch2 id: "4-444" tag: "merge-tag2" type:HIGHLIGHT
- branch testBranch3
- checkout testBranch3
- commit
- checkout main
- %% just Merge ID
- merge testBranch3 id: "6-666"
- `;
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(7);
- expect(parser.yy.getCurrentBranch()).toBe('main');
- expect(parser.yy.getDirection()).toBe('LR');
-
- // The order of these commits is in alphabetical order of IDs
- const [
- mainCommit,
- testBranchCommit,
- testBranchMerge,
- testBranch2Commit,
- testBranch2Merge,
- testBranch3Commit,
- testBranch3Merge,
- ] = [...commits.values()];
-
- expect(mainCommit.branch).toBe('main');
- expect(mainCommit.parents).toStrictEqual([]);
-
- expect(testBranchCommit.branch).toBe('testBranch');
- expect(testBranchCommit.parents).toStrictEqual([mainCommit.id]);
-
- expect(testBranchMerge.branch).toBe('main');
- expect(testBranchMerge.parents).toStrictEqual([mainCommit.id, testBranchCommit.id]);
- expect(testBranchMerge.tags).toStrictEqual(['merge-tag']);
- expect(testBranchMerge.id).toBe('2-222');
-
- expect(testBranch2Merge.branch).toBe('main');
- expect(testBranch2Merge.parents).toStrictEqual([testBranchMerge.id, testBranch2Commit.id]);
- expect(testBranch2Merge.tags).toStrictEqual(['merge-tag2']);
- expect(testBranch2Merge.id).toBe('4-444');
- expect(testBranch2Merge.customType).toBe(2);
- expect(testBranch2Merge.customId).toBe(true);
-
- expect(testBranch3Merge.branch).toBe('main');
- expect(testBranch3Merge.parents).toStrictEqual([testBranch2Merge.id, testBranch3Commit.id]);
- expect(testBranch3Merge.id).toBe('6-666');
-
- expect(parser.yy.getBranchesAsObjArray()).toStrictEqual([
- { name: 'main' },
- { name: 'testBranch' },
- { name: 'testBranch2' },
- { name: 'testBranch3' },
- ]);
- });
-
- it('should support cherry-picking commits', function () {
- const str = `gitGraph
- commit id: "ZERO"
- branch develop
- commit id:"A"
- checkout main
- cherry-pick id:"A"
- `;
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- const cherryPickCommitID = [...commits.keys()][2];
- expect(commits.get(cherryPickCommitID).tags).toStrictEqual(['cherry-pick:A']);
- expect(commits.get(cherryPickCommitID).branch).toBe('main');
- });
-
- it('should support cherry-picking commits with custom tag', function () {
- const str = `gitGraph
- commit id: "ZERO"
- branch develop
- commit id:"A"
- checkout main
- cherry-pick id:"A" tag:"MyTag"
- `;
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- const cherryPickCommitID = [...commits.keys()][2];
- expect(commits.get(cherryPickCommitID).tags).toStrictEqual(['MyTag']);
- expect(commits.get(cherryPickCommitID).branch).toBe('main');
- });
-
- it('should support cherry-picking commits with no tag', function () {
- const str = `gitGraph
- commit id: "ZERO"
- branch develop
- commit id:"A"
- checkout main
- cherry-pick id:"A" tag:""
- `;
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- const cherryPickCommitID = [...commits.keys()][2];
- expect(commits.get(cherryPickCommitID).tags).toStrictEqual([]);
- expect(commits.get(cherryPickCommitID).branch).toBe('main');
- });
-
- it('should support cherry-picking of merge commits', function () {
- const str = `gitGraph
- commit id: "ZERO"
- branch feature
- branch release
- checkout feature
- commit id: "A"
- commit id: "B"
- checkout main
- merge feature id: "M"
- checkout release
- cherry-pick id: "M" parent:"B"
- `;
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- const cherryPickCommitID = [...commits.keys()][4];
- expect(commits.get(cherryPickCommitID).tags).toStrictEqual(['cherry-pick:M|parent:B']);
- expect(commits.get(cherryPickCommitID).branch).toBe('release');
- });
-
- it('should support cherry-picking of merge commits with tag', function () {
- const str = `gitGraph
- commit id: "ZERO"
- branch feature
- branch release
- checkout feature
- commit id: "A"
- commit id: "B"
- checkout main
- merge feature id: "M"
- checkout release
- cherry-pick id: "M" parent:"ZERO" tag: "v1.0"
- `;
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- const cherryPickCommitID = [...commits.keys()][4];
- expect(commits.get(cherryPickCommitID).tags).toStrictEqual(['v1.0']);
- expect(commits.get(cherryPickCommitID).branch).toBe('release');
- });
-
- it('should support cherry-picking of merge commits with additional commit', function () {
- const str = `gitGraph
- commit id: "ZERO"
- branch feature
- branch release
- checkout feature
- commit id: "A"
- commit id: "B"
- checkout main
- merge feature id: "M"
- checkout release
- commit id: "C"
- cherry-pick id: "M" tag: "v2.1:ZERO" parent:"ZERO"
- commit id: "D"
- `;
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- const cherryPickCommitID = [...commits.keys()][5];
- expect(commits.get(cherryPickCommitID).tags).toStrictEqual(['v2.1:ZERO']);
- expect(commits.get(cherryPickCommitID).branch).toBe('release');
- });
-
- it('should support cherry-picking of merge commits with empty tag', function () {
- const str = `gitGraph
- commit id: "ZERO"
- branch feature
- branch release
- checkout feature
- commit id: "A"
- commit id: "B"
- checkout main
- merge feature id: "M"
- checkout release
- commit id: "C"
- cherry-pick id:"M" parent: "ZERO" tag:""
- commit id: "D"
- cherry-pick id:"M" tag:"" parent: "B"
- `;
-
- parser.parse(str);
- const commits = parser.yy.getCommits();
- const cherryPickCommitID = [...commits.keys()][5];
- const cherryPickCommitID2 = [...commits.keys()][7];
- expect(commits.get(cherryPickCommitID).tags).toStrictEqual([]);
- expect(commits.get(cherryPickCommitID2).tags).toStrictEqual([]);
- expect(commits.get(cherryPickCommitID).branch).toBe('release');
- });
-
- it('should fail cherry-picking of merge commits if the parent of merge commits is not specified', function () {
- expect(() =>
- parser
- .parse(
- `gitGraph
- commit id: "ZERO"
- branch feature
- branch release
- checkout feature
- commit id: "A"
- commit id: "B"
- checkout main
- merge feature id: "M"
- checkout release
- commit id: "C"
- cherry-pick id:"M"
- `
- )
- .toThrow(
- 'Incorrect usage of cherry-pick: If the source commit is a merge commit, an immediate parent commit must be specified.'
- )
- );
- });
-
- it('should fail cherry-picking of merge commits when the parent provided is not an immediate parent of cherry picked commit', function () {
- expect(() =>
- parser
- .parse(
- `gitGraph
- commit id: "ZERO"
- branch feature
- branch release
- checkout feature
- commit id: "A"
- commit id: "B"
- checkout main
- merge feature id: "M"
- checkout release
- commit id: "C"
- cherry-pick id:"M" parent: "A"
- `
- )
- .toThrow(
- 'Invalid operation: The specified parent commit is not an immediate parent of the cherry-picked commit.'
- )
- );
- });
-
- it('should throw error when try to branch existing branch: main', function () {
- const str = `gitGraph
- commit
- branch testBranch
- commit
- branch main
- commit
- checkout main
- merge testBranch
- `;
-
- try {
- parser.parse(str);
- // Fail test if above expression doesn't throw anything.
- expect(true).toBe(false);
- } catch (e) {
- expect(e.message).toBe(
- 'Trying to create an existing branch. (Help: Either use a new name if you want create a new branch or try using "checkout main")'
- );
- }
- });
- it('should throw error when try to branch existing branch: testBranch', function () {
- const str = `gitGraph
- commit
- branch testBranch
- commit
- branch testBranch
- commit
- checkout main
- merge testBranch
- `;
-
- try {
- parser.parse(str);
- // Fail test if above expression doesn't throw anything.
- expect(true).toBe(false);
- } catch (e) {
- expect(e.message).toBe(
- 'Trying to create an existing branch. (Help: Either use a new name if you want create a new branch or try using "checkout testBranch")'
- );
- }
- });
- it('should throw error when try to checkout unknown branch: testBranch', function () {
- const str = `gitGraph
- commit
- checkout testBranch
- commit
- branch testBranch
- commit
- checkout main
- merge testBranch
- `;
-
- try {
- parser.parse(str);
- // Fail test if above expression doesn't throw anything.
- expect(true).toBe(false);
- } catch (e) {
- expect(e.message).toBe(
- 'Trying to checkout branch which is not yet created. (Help try using "branch testBranch")'
- );
- }
- });
- it('should throw error when trying to merge, when current branch has no commits', function () {
- const str = `gitGraph
- merge testBranch
- commit
- checkout testBranch
- commit
- branch testBranch
- commit
- checkout main
- merge testBranch
- `;
-
- try {
- parser.parse(str);
- // Fail test if above expression doesn't throw anything.
- expect(true).toBe(false);
- } catch (e) {
- expect(e.message).toBe('Incorrect usage of "merge". Current branch (main)has no commits');
- }
- });
- it('should throw error when trying to merge unknown branch', function () {
- const str = `gitGraph
- commit
- merge testBranch
- commit
- checkout testBranch
- commit
- branch testBranch
- commit
- checkout main
- merge testBranch
- `;
-
- try {
- parser.parse(str);
- // Fail test if above expression doesn't throw anything.
- expect(true).toBe(false);
- } catch (e) {
- expect(e.message).toBe(
- 'Incorrect usage of "merge". Branch to be merged (testBranch) does not exist'
- );
- }
- });
- it('should throw error when trying to merge branch to itself', function () {
- const str = `gitGraph
- commit
- branch testBranch
- merge testBranch
- `;
-
- try {
- parser.parse(str);
- // Fail test if above expression doesn't throw anything.
- expect(true).toBe(false);
- } catch (e) {
- expect(e.message).toBe('Incorrect usage of "merge". Cannot merge a branch to itself');
- }
- });
-
- it('should throw error when using existing id as merge ID', function () {
- const str = `gitGraph
- commit id: "1-111"
- branch testBranch
- commit id: "2-222"
- commit id: "3-333"
- checkout main
- merge testBranch id: "1-111"
- `;
-
- try {
- parser.parse(str);
- // Fail test if above expression doesn't throw anything.
- expect(true).toBe(false);
- } catch (e) {
- expect(e.message).toBe(
- 'Incorrect usage of "merge". Commit with id:1-111 already exists, use different custom Id'
- );
- }
- });
- it('should throw error when trying to merge branches having same heads', function () {
- const str = `gitGraph
- commit
- branch testBranch
- checkout main
- merge testBranch
- `;
-
- try {
- parser.parse(str);
- // Fail test if above expression doesn't throw anything.
- expect(true).toBe(false);
- } catch (e) {
- expect(e.message).toBe('Incorrect usage of "merge". Both branches have same head');
- }
- });
- it('should throw error when trying to merge branch which has no commits', function () {
- const str = `gitGraph
- branch test1
-
- checkout main
- commit
- merge test1
- `;
-
- try {
- parser.parse(str);
- // Fail test if above expression doesn't throw anything.
- expect(true).toBe(false);
- } catch (e) {
- expect(e.message).toBe(
- 'Incorrect usage of "merge". Branch to be merged (test1) has no commits'
- );
- }
- });
- describe('accessibility', () => {
- it('should handle a title and a description (accDescr)', () => {
- const str = `gitGraph:
- accTitle: This is a title
- accDescr: This is a description
- commit
- `;
- parser.parse(str);
- expect(parser.yy.getAccTitle()).toBe('This is a title');
- expect(parser.yy.getAccDescription()).toBe('This is a description');
- });
- it('should handle a title and a multiline description (accDescr)', () => {
- const str = `gitGraph:
- accTitle: This is a title
- accDescr {
- This is a description
- using multiple lines
- }
- commit
- `;
- parser.parse(str);
- expect(parser.yy.getAccTitle()).toBe('This is a title');
- expect(parser.yy.getAccDescription()).toBe('This is a description\nusing multiple lines');
- });
- });
-
- describe('unsafe properties', () => {
- for (const prop of ['__proto__', 'constructor']) {
- it(`should work with custom commit id or branch name ${prop}`, () => {
- const str = `gitGraph
- commit id:"${prop}"
- branch ${prop}
- checkout ${prop}
- commit
- checkout main
- merge ${prop}
- `;
- parser.parse(str);
- const commits = parser.yy.getCommits();
- expect(commits.size).toBe(3);
- expect(commits.keys().next().value).toBe(prop);
- expect(parser.yy.getCurrentBranch()).toBe('main');
- expect(parser.yy.getBranches().size).toBe(2);
- expect(parser.yy.getBranchesAsObjArray()[1].name).toBe(prop);
- });
- }
- });
-});
diff --git a/packages/mermaid/src/diagrams/git/gitGraphRenderer.js b/packages/mermaid/src/diagrams/git/gitGraphRenderer.js
deleted file mode 100644
index b8b13e089..000000000
--- a/packages/mermaid/src/diagrams/git/gitGraphRenderer.js
+++ /dev/null
@@ -1,893 +0,0 @@
-import { select } from 'd3';
-import { getConfig, setupGraphViewbox } from '../../diagram-api/diagramAPI.js';
-import { log } from '../../logger.js';
-import utils from '../../utils.js';
-
-/**
- * @typedef {Map} CommitMap
- */
-
-/** @type {CommitMap} */
-let allCommitsDict = new Map();
-
-const commitType = {
- NORMAL: 0,
- REVERSE: 1,
- HIGHLIGHT: 2,
- MERGE: 3,
- CHERRY_PICK: 4,
-};
-
-const THEME_COLOR_LIMIT = 8;
-
-let branchPos = {};
-let commitPos = {};
-let lanes = [];
-let maxPos = 0;
-let dir = 'LR';
-let defaultPos = 30;
-const clear = () => {
- branchPos = new Map();
- commitPos = new Map();
- allCommitsDict = new Map();
- maxPos = 0;
- lanes = [];
- dir = 'LR';
-};
-
-/**
- * Draws a text, used for labels of the branches
- *
- * @param {string} txt The text
- * @returns {SVGElement}
- */
-const drawText = (txt) => {
- const svgLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text');
- let rows = [];
-
- // Handling of new lines in the label
- if (typeof txt === 'string') {
- rows = txt.split(/\\n|\n|
/gi);
- } else if (Array.isArray(txt)) {
- rows = txt;
- } else {
- rows = [];
- }
-
- for (const row of rows) {
- const tspan = document.createElementNS('http://www.w3.org/2000/svg', 'tspan');
- tspan.setAttributeNS('http://www.w3.org/XML/1998/namespace', 'xml:space', 'preserve');
- tspan.setAttribute('dy', '1em');
- tspan.setAttribute('x', '0');
- tspan.setAttribute('class', 'row');
- tspan.textContent = row.trim();
- svgLabel.appendChild(tspan);
- }
- /**
- * @param svg
- * @param selector
- */
- return svgLabel;
-};
-
-/**
- * Searches for the closest parent from the parents list passed as argument.
- * The parents list comes from an individual commit. The closest parent is actually
- * the one farther down the graph, since that means it is closer to its child.
- *
- * @param {string[]} parents
- * @returns {string | undefined}
- */
-const findClosestParent = (parents) => {
- let closestParent = '';
- let maxPosition = 0;
-
- parents.forEach((parent) => {
- const parentPosition =
- dir === 'TB' || dir === 'BT' ? commitPos.get(parent).y : commitPos.get(parent).x;
- if (parentPosition >= maxPosition) {
- closestParent = parent;
- maxPosition = parentPosition;
- }
- });
-
- return closestParent || undefined;
-};
-
-/**
- * Searches for the closest parent from the parents list passed as argument for Bottom-to-Top orientation.
- * The parents list comes from an individual commit. The closest parent is actually
- * the one farther down the graph, since that means it is closer to its child.
- *
- * @param {string[]} parents
- * @returns {string | undefined}
- */
-const findClosestParentBT = (parents) => {
- let closestParent = '';
- let maxPosition = Infinity;
-
- parents.forEach((parent) => {
- const parentPosition = commitPos.get(parent).y;
- if (parentPosition <= maxPosition) {
- closestParent = parent;
- maxPosition = parentPosition;
- }
- });
-
- return closestParent || undefined;
-};
-
-/**
- * Sets the position of the commit elements when the orientation is set to BT-Parallel.
- * This is needed to render the chart in Bottom-to-Top mode while keeping the parallel
- * commits in the correct position. First, it finds the correct position of the root commit
- * using the findClosestParent method. Then, it uses the findClosestParentBT to set the position
- * of the remaining commits.
- *
- * @param {any} sortedKeys
- * @param {CommitMap} commits
- * @param {any} defaultPos
- * @param {any} commitStep
- * @param {any} layoutOffset
- */
-const setParallelBTPos = (sortedKeys, commits, defaultPos, commitStep, layoutOffset) => {
- let curPos = defaultPos;
- let maxPosition = defaultPos;
- let roots = [];
- sortedKeys.forEach((key) => {
- const commit = commits.get(key);
- if (commit.parents.length) {
- const closestParent = findClosestParent(commit.parents);
- curPos = commitPos.get(closestParent).y + commitStep;
- if (curPos >= maxPosition) {
- maxPosition = curPos;
- }
- } else {
- roots.push(commit);
- }
- const x = branchPos.get(commit.branch).pos;
- const y = curPos + layoutOffset;
- commitPos.set(commit.id, { x: x, y: y });
- });
- curPos = maxPosition;
- roots.forEach((commit) => {
- const posWithOffset = curPos + defaultPos;
- const y = posWithOffset;
- const x = branchPos.get(commit.branch).pos;
- commitPos.set(commit.id, { x: x, y: y });
- });
- sortedKeys.forEach((key) => {
- const commit = commits.get(key);
- if (commit.parents.length) {
- const closestParent = findClosestParentBT(commit.parents);
- curPos = commitPos.get(closestParent).y - commitStep;
- if (curPos <= maxPosition) {
- maxPosition = curPos;
- }
- const x = branchPos.get(commit.branch).pos;
- const y = curPos - layoutOffset;
- commitPos.set(commit.id, { x: x, y: y });
- }
- });
-};
-
-/**
- * Draws the commits with its symbol and labels. The function has two modes, one which only
- * calculates the positions and one that does the actual drawing. This for a simple way getting the
- * vertical layering correct in the graph.
- *
- * @param {any} svg
- * @param {CommitMap} commits
- * @param {any} modifyGraph
- */
-const drawCommits = (svg, commits, modifyGraph) => {
- const gitGraphConfig = getConfig().gitGraph;
- const gBullets = svg.append('g').attr('class', 'commit-bullets');
- const gLabels = svg.append('g').attr('class', 'commit-labels');
- let pos = 0;
-
- if (dir === 'TB' || dir === 'BT') {
- pos = defaultPos;
- }
- const keys = [...commits.keys()];
- const isParallelCommits = gitGraphConfig.parallelCommits;
- const layoutOffset = 10;
- const commitStep = 40;
- let sortedKeys =
- dir !== 'BT' || (dir === 'BT' && isParallelCommits)
- ? keys.sort((a, b) => {
- return commits.get(a).seq - commits.get(b).seq;
- })
- : keys
- .sort((a, b) => {
- return commits.get(a).seq - commits.get(b).seq;
- })
- .reverse();
-
- if (dir === 'BT' && isParallelCommits) {
- setParallelBTPos(sortedKeys, commits, pos, commitStep, layoutOffset);
- sortedKeys = sortedKeys.reverse();
- }
- sortedKeys.forEach((key) => {
- const commit = commits.get(key);
- if (isParallelCommits) {
- if (commit.parents.length) {
- const closestParent =
- dir === 'BT' ? findClosestParentBT(commit.parents) : findClosestParent(commit.parents);
- if (dir === 'TB') {
- pos = commitPos.get(closestParent).y + commitStep;
- } else if (dir === 'BT') {
- pos = commitPos.get(key).y - commitStep;
- } else {
- pos = commitPos.get(closestParent).x + commitStep;
- }
- } else {
- if (dir === 'TB') {
- pos = defaultPos;
- } else if (dir === 'BT') {
- pos = commitPos.get(key).y - commitStep;
- } else {
- pos = 0;
- }
- }
- }
- const posWithOffset = dir === 'BT' && isParallelCommits ? pos : pos + layoutOffset;
- const y = dir === 'TB' || dir === 'BT' ? posWithOffset : branchPos.get(commit.branch).pos;
- const x = dir === 'TB' || dir === 'BT' ? branchPos.get(commit.branch).pos : posWithOffset;
-
- // Don't draw the commits now but calculate the positioning which is used by the branch lines etc.
- if (modifyGraph) {
- let typeClass;
- let commitSymbolType =
- commit.customType !== undefined && commit.customType !== ''
- ? commit.customType
- : commit.type;
- switch (commitSymbolType) {
- case commitType.NORMAL:
- typeClass = 'commit-normal';
- break;
- case commitType.REVERSE:
- typeClass = 'commit-reverse';
- break;
- case commitType.HIGHLIGHT:
- typeClass = 'commit-highlight';
- break;
- case commitType.MERGE:
- typeClass = 'commit-merge';
- break;
- case commitType.CHERRY_PICK:
- typeClass = 'commit-cherry-pick';
- break;
- default:
- typeClass = 'commit-normal';
- }
-
- if (commitSymbolType === commitType.HIGHLIGHT) {
- const circle = gBullets.append('rect');
- circle.attr('x', x - 10);
- circle.attr('y', y - 10);
- circle.attr('height', 20);
- circle.attr('width', 20);
- circle.attr(
- 'class',
- `commit ${commit.id} commit-highlight${
- branchPos.get(commit.branch).index % THEME_COLOR_LIMIT
- } ${typeClass}-outer`
- );
- gBullets
- .append('rect')
- .attr('x', x - 6)
- .attr('y', y - 6)
- .attr('height', 12)
- .attr('width', 12)
- .attr(
- 'class',
- `commit ${commit.id} commit${
- branchPos.get(commit.branch).index % THEME_COLOR_LIMIT
- } ${typeClass}-inner`
- );
- } else if (commitSymbolType === commitType.CHERRY_PICK) {
- gBullets
- .append('circle')
- .attr('cx', x)
- .attr('cy', y)
- .attr('r', 10)
- .attr('class', `commit ${commit.id} ${typeClass}`);
- gBullets
- .append('circle')
- .attr('cx', x - 3)
- .attr('cy', y + 2)
- .attr('r', 2.75)
- .attr('fill', '#fff')
- .attr('class', `commit ${commit.id} ${typeClass}`);
- gBullets
- .append('circle')
- .attr('cx', x + 3)
- .attr('cy', y + 2)
- .attr('r', 2.75)
- .attr('fill', '#fff')
- .attr('class', `commit ${commit.id} ${typeClass}`);
- gBullets
- .append('line')
- .attr('x1', x + 3)
- .attr('y1', y + 1)
- .attr('x2', x)
- .attr('y2', y - 5)
- .attr('stroke', '#fff')
- .attr('class', `commit ${commit.id} ${typeClass}`);
- gBullets
- .append('line')
- .attr('x1', x - 3)
- .attr('y1', y + 1)
- .attr('x2', x)
- .attr('y2', y - 5)
- .attr('stroke', '#fff')
- .attr('class', `commit ${commit.id} ${typeClass}`);
- } else {
- const circle = gBullets.append('circle');
- circle.attr('cx', x);
- circle.attr('cy', y);
- circle.attr('r', commit.type === commitType.MERGE ? 9 : 10);
- circle.attr(
- 'class',
- `commit ${commit.id} commit${branchPos.get(commit.branch).index % THEME_COLOR_LIMIT}`
- );
- if (commitSymbolType === commitType.MERGE) {
- const circle2 = gBullets.append('circle');
- circle2.attr('cx', x);
- circle2.attr('cy', y);
- circle2.attr('r', 6);
- circle2.attr(
- 'class',
- `commit ${typeClass} ${commit.id} commit${
- branchPos.get(commit.branch).index % THEME_COLOR_LIMIT
- }`
- );
- }
- if (commitSymbolType === commitType.REVERSE) {
- const cross = gBullets.append('path');
- cross
- .attr('d', `M ${x - 5},${y - 5}L${x + 5},${y + 5}M${x - 5},${y + 5}L${x + 5},${y - 5}`)
- .attr(
- 'class',
- `commit ${typeClass} ${commit.id} commit${
- branchPos.get(commit.branch).index % THEME_COLOR_LIMIT
- }`
- );
- }
- }
- }
- if (dir === 'TB' || dir === 'BT') {
- commitPos.set(commit.id, { x: x, y: posWithOffset });
- } else {
- commitPos.set(commit.id, { x: posWithOffset, y: y });
- }
-
- // The first iteration over the commits are for positioning purposes, this
- // is required for drawing the lines. The circles and labels is drawn after the labels
- // placing them on top of the lines.
- if (modifyGraph) {
- const px = 4;
- const py = 2;
- // Draw the commit label
- if (
- commit.type !== commitType.CHERRY_PICK &&
- ((commit.customId && commit.type === commitType.MERGE) ||
- commit.type !== commitType.MERGE) &&
- gitGraphConfig.showCommitLabel
- ) {
- const wrapper = gLabels.append('g');
- const labelBkg = wrapper.insert('rect').attr('class', 'commit-label-bkg');
-
- const text = wrapper
- .append('text')
- .attr('x', pos)
- .attr('y', y + 25)
- .attr('class', 'commit-label')
- .text(commit.id);
- let bbox = text.node().getBBox();
-
- // Now we have the label, lets position the background
- labelBkg
- .attr('x', posWithOffset - bbox.width / 2 - py)
- .attr('y', y + 13.5)
- .attr('width', bbox.width + 2 * py)
- .attr('height', bbox.height + 2 * py);
-
- if (dir === 'TB' || dir === 'BT') {
- labelBkg.attr('x', x - (bbox.width + 4 * px + 5)).attr('y', y - 12);
- text.attr('x', x - (bbox.width + 4 * px)).attr('y', y + bbox.height - 12);
- } else {
- text.attr('x', posWithOffset - bbox.width / 2);
- }
- if (gitGraphConfig.rotateCommitLabel) {
- if (dir === 'TB' || dir === 'BT') {
- text.attr('transform', 'rotate(' + -45 + ', ' + x + ', ' + y + ')');
- labelBkg.attr('transform', 'rotate(' + -45 + ', ' + x + ', ' + y + ')');
- } else {
- let r_x = -7.5 - ((bbox.width + 10) / 25) * 9.5;
- let r_y = 10 + (bbox.width / 25) * 8.5;
- wrapper.attr(
- 'transform',
- 'translate(' + r_x + ', ' + r_y + ') rotate(' + -45 + ', ' + pos + ', ' + y + ')'
- );
- }
- }
- }
- if (commit.tags.length > 0) {
- let yOffset = 0;
- let maxTagBboxWidth = 0;
- let maxTagBboxHeight = 0;
- const tagElements = [];
-
- for (const tagValue of commit.tags.reverse()) {
- const rect = gLabels.insert('polygon');
- const hole = gLabels.append('circle');
- const tag = gLabels
- .append('text')
- // Note that we are delaying setting the x position until we know the width of the text
- .attr('y', y - 16 - yOffset)
- .attr('class', 'tag-label')
- .text(tagValue);
- let tagBbox = tag.node().getBBox();
- maxTagBboxWidth = Math.max(maxTagBboxWidth, tagBbox.width);
- maxTagBboxHeight = Math.max(maxTagBboxHeight, tagBbox.height);
-
- // We don't use the max over here to center the text within the tags
- tag.attr('x', posWithOffset - tagBbox.width / 2);
-
- tagElements.push({
- tag,
- hole,
- rect,
- yOffset,
- });
-
- yOffset += 20;
- }
-
- for (const { tag, hole, rect, yOffset } of tagElements) {
- const h2 = maxTagBboxHeight / 2;
- const ly = y - 19.2 - yOffset;
- rect.attr('class', 'tag-label-bkg').attr(
- 'points',
- `
- ${pos - maxTagBboxWidth / 2 - px / 2},${ly + py}
- ${pos - maxTagBboxWidth / 2 - px / 2},${ly - py}
- ${posWithOffset - maxTagBboxWidth / 2 - px},${ly - h2 - py}
- ${posWithOffset + maxTagBboxWidth / 2 + px},${ly - h2 - py}
- ${posWithOffset + maxTagBboxWidth / 2 + px},${ly + h2 + py}
- ${posWithOffset - maxTagBboxWidth / 2 - px},${ly + h2 + py}`
- );
-
- hole
- .attr('cy', ly)
- .attr('cx', pos - maxTagBboxWidth / 2 + px / 2)
- .attr('r', 1.5)
- .attr('class', 'tag-hole');
-
- if (dir === 'TB' || dir === 'BT') {
- const yOrigin = pos + yOffset;
-
- rect
- .attr('class', 'tag-label-bkg')
- .attr(
- 'points',
- `
- ${x},${yOrigin + py}
- ${x},${yOrigin - py}
- ${x + layoutOffset},${yOrigin - h2 - py}
- ${x + layoutOffset + maxTagBboxWidth + px},${yOrigin - h2 - py}
- ${x + layoutOffset + maxTagBboxWidth + px},${yOrigin + h2 + py}
- ${x + layoutOffset},${yOrigin + h2 + py}`
- )
- .attr('transform', 'translate(12,12) rotate(45, ' + x + ',' + pos + ')');
- hole
- .attr('cx', x + px / 2)
- .attr('cy', yOrigin)
- .attr('transform', 'translate(12,12) rotate(45, ' + x + ',' + pos + ')');
- tag
- .attr('x', x + 5)
- .attr('y', yOrigin + 3)
- .attr('transform', 'translate(14,14) rotate(45, ' + x + ',' + pos + ')');
- }
- }
- }
- }
- pos = dir === 'BT' && isParallelCommits ? pos + commitStep : pos + commitStep + layoutOffset;
- if (pos > maxPos) {
- maxPos = pos;
- }
- });
-};
-
-/**
- * Detect if there are commits
- * between commitA's x-position
- * and commitB's x-position on the
- * same branch as commitA, where
- * commitA isn't main
- *
- * @param {any} commitA
- * @param {any} commitB
- * @param p1
- * @param p2
- * @param {CommitMap} allCommits
- * @returns {boolean}
- * If there are commits between
- * commitA's x-position
- * and commitB's x-position
- * on the source branch, where
- * source branch is not main
- * return true
- */
-const shouldRerouteArrow = (commitA, commitB, p1, p2, allCommits) => {
- const commitBIsFurthest = dir === 'TB' || dir === 'BT' ? p1.x < p2.x : p1.y < p2.y;
- const branchToGetCurve = commitBIsFurthest ? commitB.branch : commitA.branch;
- const isOnBranchToGetCurve = (x) => x.branch === branchToGetCurve;
- const isBetweenCommits = (x) => x.seq > commitA.seq && x.seq < commitB.seq;
- return [...allCommits.values()].some((commitX) => {
- return isBetweenCommits(commitX) && isOnBranchToGetCurve(commitX);
- });
-};
-
-/**
- * This function find a lane in the y-axis that is not overlapping with any other lanes. This is
- * used for drawing the lines between commits.
- *
- * @param {any} y1
- * @param {any} y2
- * @param {any} depth
- * @returns {number} Y value between y1 and y2
- */
-const findLane = (y1, y2, depth = 0) => {
- const candidate = y1 + Math.abs(y1 - y2) / 2;
- if (depth > 5) {
- return candidate;
- }
-
- let ok = lanes.every((lane) => Math.abs(lane - candidate) >= 10);
- if (ok) {
- lanes.push(candidate);
- return candidate;
- }
- const diff = Math.abs(y1 - y2);
- return findLane(y1, y2 - diff / 5, depth + 1);
-};
-
-/**
- * Draw the lines between the commits. They were arrows initially.
- *
- * @param {any} svg
- * @param {any} commitA
- * @param {any} commitB
- * @param {CommitMap} allCommits
- */
-const drawArrow = (svg, commitA, commitB, allCommits) => {
- const p1 = commitPos.get(commitA.id); // arrowStart
- const p2 = commitPos.get(commitB.id); // arrowEnd
- const arrowNeedsRerouting = shouldRerouteArrow(commitA, commitB, p1, p2, allCommits);
- // log.debug('drawArrow', p1, p2, arrowNeedsRerouting, commitA.id, commitB.id);
-
- // Lower-right quadrant logic; top-left is 0,0
-
- let arc = '';
- let arc2 = '';
- let radius = 0;
- let offset = 0;
- let colorClassNum = branchPos.get(commitB.branch).index;
- if (commitB.type === commitType.MERGE && commitA.id !== commitB.parents[0]) {
- colorClassNum = branchPos.get(commitA.branch).index;
- }
-
- let lineDef;
- if (arrowNeedsRerouting) {
- arc = 'A 10 10, 0, 0, 0,';
- arc2 = 'A 10 10, 0, 0, 1,';
- radius = 10;
- offset = 10;
-
- const lineY = p1.y < p2.y ? findLane(p1.y, p2.y) : findLane(p2.y, p1.y);
- const lineX = p1.x < p2.x ? findLane(p1.x, p2.x) : findLane(p2.x, p1.x);
-
- if (dir === 'TB') {
- if (p1.x < p2.x) {
- // Source commit is on branch position left of destination commit
- // so render arrow rightward with colour of destination branch
- lineDef = `M ${p1.x} ${p1.y} L ${lineX - radius} ${p1.y} ${arc2} ${lineX} ${
- p1.y + offset
- } L ${lineX} ${p2.y - radius} ${arc} ${lineX + offset} ${p2.y} L ${p2.x} ${p2.y}`;
- } else {
- // Source commit is on branch position right of destination commit
- // so render arrow leftward with colour of source branch
- colorClassNum = branchPos.get(commitA.branch).index;
- lineDef = `M ${p1.x} ${p1.y} L ${lineX + radius} ${p1.y} ${arc} ${lineX} ${
- p1.y + offset
- } L ${lineX} ${p2.y - radius} ${arc2} ${lineX - offset} ${p2.y} L ${p2.x} ${p2.y}`;
- }
- } else if (dir === 'BT') {
- if (p1.x < p2.x) {
- // Source commit is on branch position left of destination commit
- // so render arrow rightward with colour of destination branch
- lineDef = `M ${p1.x} ${p1.y} L ${lineX - radius} ${p1.y} ${arc} ${lineX} ${
- p1.y - offset
- } L ${lineX} ${p2.y + radius} ${arc2} ${lineX + offset} ${p2.y} L ${p2.x} ${p2.y}`;
- } else {
- // Source commit is on branch position right of destination commit
- // so render arrow leftward with colour of source branch
- colorClassNum = branchPos.get(commitA.branch).index;
- lineDef = `M ${p1.x} ${p1.y} L ${lineX + radius} ${p1.y} ${arc2} ${lineX} ${
- p1.y - offset
- } L ${lineX} ${p2.y + radius} ${arc} ${lineX - offset} ${p2.y} L ${p2.x} ${p2.y}`;
- }
- } else {
- if (p1.y < p2.y) {
- // Source commit is on branch positioned above destination commit
- // so render arrow downward with colour of destination branch
- lineDef = `M ${p1.x} ${p1.y} L ${p1.x} ${lineY - radius} ${arc} ${
- p1.x + offset
- } ${lineY} L ${p2.x - radius} ${lineY} ${arc2} ${p2.x} ${lineY + offset} L ${p2.x} ${p2.y}`;
- } else {
- // Source commit is on branch positioned below destination commit
- // so render arrow upward with colour of source branch
- colorClassNum = branchPos.get(commitA.branch).index;
- lineDef = `M ${p1.x} ${p1.y} L ${p1.x} ${lineY + radius} ${arc2} ${
- p1.x + offset
- } ${lineY} L ${p2.x - radius} ${lineY} ${arc} ${p2.x} ${lineY - offset} L ${p2.x} ${p2.y}`;
- }
- }
- } else {
- arc = 'A 20 20, 0, 0, 0,';
- arc2 = 'A 20 20, 0, 0, 1,';
- radius = 20;
- offset = 20;
-
- if (dir === 'TB') {
- if (p1.x < p2.x) {
- if (commitB.type === commitType.MERGE && commitA.id !== commitB.parents[0]) {
- lineDef = `M ${p1.x} ${p1.y} L ${p1.x} ${p2.y - radius} ${arc} ${p1.x + offset} ${
- p2.y
- } L ${p2.x} ${p2.y}`;
- } else {
- lineDef = `M ${p1.x} ${p1.y} L ${p2.x - radius} ${p1.y} ${arc2} ${p2.x} ${
- p1.y + offset
- } L ${p2.x} ${p2.y}`;
- }
- }
- if (p1.x > p2.x) {
- arc = 'A 20 20, 0, 0, 0,';
- arc2 = 'A 20 20, 0, 0, 1,';
- radius = 20;
- offset = 20;
- if (commitB.type === commitType.MERGE && commitA.id !== commitB.parents[0]) {
- lineDef = `M ${p1.x} ${p1.y} L ${p1.x} ${p2.y - radius} ${arc2} ${p1.x - offset} ${
- p2.y
- } L ${p2.x} ${p2.y}`;
- } else {
- lineDef = `M ${p1.x} ${p1.y} L ${p2.x + radius} ${p1.y} ${arc} ${p2.x} ${
- p1.y + offset
- } L ${p2.x} ${p2.y}`;
- }
- }
-
- if (p1.x === p2.x) {
- lineDef = `M ${p1.x} ${p1.y} L ${p2.x} ${p2.y}`;
- }
- } else if (dir === 'BT') {
- if (p1.x < p2.x) {
- if (commitB.type === commitType.MERGE && commitA.id !== commitB.parents[0]) {
- lineDef = `M ${p1.x} ${p1.y} L ${p1.x} ${p2.y + radius} ${arc2} ${p1.x + offset} ${
- p2.y
- } L ${p2.x} ${p2.y}`;
- } else {
- lineDef = `M ${p1.x} ${p1.y} L ${p2.x - radius} ${p1.y} ${arc} ${p2.x} ${
- p1.y - offset
- } L ${p2.x} ${p2.y}`;
- }
- }
- if (p1.x > p2.x) {
- arc = 'A 20 20, 0, 0, 0,';
- arc2 = 'A 20 20, 0, 0, 1,';
- radius = 20;
- offset = 20;
-
- if (commitB.type === commitType.MERGE && commitA.id !== commitB.parents[0]) {
- lineDef = `M ${p1.x} ${p1.y} L ${p1.x} ${p2.y + radius} ${arc} ${p1.x - offset} ${
- p2.y
- } L ${p2.x} ${p2.y}`;
- } else {
- lineDef = `M ${p1.x} ${p1.y} L ${p2.x - radius} ${p1.y} ${arc} ${p2.x} ${
- p1.y - offset
- } L ${p2.x} ${p2.y}`;
- }
- }
-
- if (p1.x === p2.x) {
- lineDef = `M ${p1.x} ${p1.y} L ${p2.x} ${p2.y}`;
- }
- } else {
- if (p1.y < p2.y) {
- if (commitB.type === commitType.MERGE && commitA.id !== commitB.parents[0]) {
- lineDef = `M ${p1.x} ${p1.y} L ${p2.x - radius} ${p1.y} ${arc2} ${p2.x} ${
- p1.y + offset
- } L ${p2.x} ${p2.y}`;
- } else {
- lineDef = `M ${p1.x} ${p1.y} L ${p1.x} ${p2.y - radius} ${arc} ${p1.x + offset} ${
- p2.y
- } L ${p2.x} ${p2.y}`;
- }
- }
- if (p1.y > p2.y) {
- if (commitB.type === commitType.MERGE && commitA.id !== commitB.parents[0]) {
- lineDef = `M ${p1.x} ${p1.y} L ${p2.x - radius} ${p1.y} ${arc} ${p2.x} ${
- p1.y - offset
- } L ${p2.x} ${p2.y}`;
- } else {
- lineDef = `M ${p1.x} ${p1.y} L ${p1.x} ${p2.y + radius} ${arc2} ${p1.x + offset} ${
- p2.y
- } L ${p2.x} ${p2.y}`;
- }
- }
-
- if (p1.y === p2.y) {
- lineDef = `M ${p1.x} ${p1.y} L ${p2.x} ${p2.y}`;
- }
- }
- }
- svg
- .append('path')
- .attr('d', lineDef)
- .attr('class', 'arrow arrow' + (colorClassNum % THEME_COLOR_LIMIT));
-};
-
-/**
- * @param {*} svg
- * @param {CommitMap} commits
- */
-const drawArrows = (svg, commits) => {
- const gArrows = svg.append('g').attr('class', 'commit-arrows');
- [...commits.keys()].forEach((key) => {
- const commit = commits.get(key);
- if (commit.parents && commit.parents.length > 0) {
- commit.parents.forEach((parent) => {
- drawArrow(gArrows, commits.get(parent), commit, commits);
- });
- }
- });
-};
-
-/**
- * Adds the branches and the branches' labels to the svg.
- *
- * @param svg
- * @param branches
- */
-const drawBranches = (svg, branches) => {
- const gitGraphConfig = getConfig().gitGraph;
- const g = svg.append('g');
- branches.forEach((branch, index) => {
- const adjustIndexForTheme = index % THEME_COLOR_LIMIT;
-
- const pos = branchPos.get(branch.name).pos;
- const line = g.append('line');
- line.attr('x1', 0);
- line.attr('y1', pos);
- line.attr('x2', maxPos);
- line.attr('y2', pos);
- line.attr('class', 'branch branch' + adjustIndexForTheme);
-
- if (dir === 'TB') {
- line.attr('y1', defaultPos);
- line.attr('x1', pos);
- line.attr('y2', maxPos);
- line.attr('x2', pos);
- } else if (dir === 'BT') {
- line.attr('y1', maxPos);
- line.attr('x1', pos);
- line.attr('y2', defaultPos);
- line.attr('x2', pos);
- }
- lanes.push(pos);
-
- let name = branch.name;
-
- // Create the actual text element
- const labelElement = drawText(name);
- // Create outer g, edgeLabel, this will be positioned after graph layout
- const bkg = g.insert('rect');
- const branchLabel = g.insert('g').attr('class', 'branchLabel');
-
- // Create inner g, label, this will be positioned now for centering the text
- const label = branchLabel.insert('g').attr('class', 'label branch-label' + adjustIndexForTheme);
- label.node().appendChild(labelElement);
- let bbox = labelElement.getBBox();
- bkg
- .attr('class', 'branchLabelBkg label' + adjustIndexForTheme)
- .attr('rx', 4)
- .attr('ry', 4)
- .attr('x', -bbox.width - 4 - (gitGraphConfig.rotateCommitLabel === true ? 30 : 0))
- .attr('y', -bbox.height / 2 + 8)
- .attr('width', bbox.width + 18)
- .attr('height', bbox.height + 4);
- label.attr(
- 'transform',
- 'translate(' +
- (-bbox.width - 14 - (gitGraphConfig.rotateCommitLabel === true ? 30 : 0)) +
- ', ' +
- (pos - bbox.height / 2 - 1) +
- ')'
- );
- if (dir === 'TB') {
- bkg.attr('x', pos - bbox.width / 2 - 10).attr('y', 0);
- label.attr('transform', 'translate(' + (pos - bbox.width / 2 - 5) + ', ' + 0 + ')');
- } else if (dir === 'BT') {
- bkg.attr('x', pos - bbox.width / 2 - 10).attr('y', maxPos);
- label.attr('transform', 'translate(' + (pos - bbox.width / 2 - 5) + ', ' + maxPos + ')');
- } else {
- bkg.attr('transform', 'translate(' + -19 + ', ' + (pos - bbox.height / 2) + ')');
- }
- });
-};
-
-/**
- * @param txt
- * @param id
- * @param ver
- * @param diagObj
- */
-export const draw = function (txt, id, ver, diagObj) {
- clear();
- const conf = getConfig();
- const gitGraphConfig = conf.gitGraph;
- // try {
- log.debug('in gitgraph renderer', txt + '\n', 'id:', id, ver);
-
- allCommitsDict = diagObj.db.getCommits();
- const branches = diagObj.db.getBranchesAsObjArray();
- dir = diagObj.db.getDirection();
- const diagram = select(`[id="${id}"]`);
- // Position branches
- let pos = 0;
- branches.forEach((branch, index) => {
- const labelElement = drawText(branch.name);
- const g = diagram.append('g');
- const branchLabel = g.insert('g').attr('class', 'branchLabel');
- const label = branchLabel.insert('g').attr('class', 'label branch-label');
- label.node().appendChild(labelElement);
- let bbox = labelElement.getBBox();
-
- branchPos.set(branch.name, { pos, index });
- pos +=
- 50 +
- (gitGraphConfig.rotateCommitLabel ? 40 : 0) +
- (dir === 'TB' || dir === 'BT' ? bbox.width / 2 : 0);
- label.remove();
- branchLabel.remove();
- g.remove();
- });
-
- drawCommits(diagram, allCommitsDict, false);
- if (gitGraphConfig.showBranches) {
- drawBranches(diagram, branches);
- }
- drawArrows(diagram, allCommitsDict);
- drawCommits(diagram, allCommitsDict, true);
- utils.insertTitle(
- diagram,
- 'gitTitleText',
- gitGraphConfig.titleTopMargin,
- diagObj.db.getDiagramTitle()
- );
-
- // Setup the view box and size of the svg element
- setupGraphViewbox(
- undefined,
- diagram,
- gitGraphConfig.diagramPadding,
- gitGraphConfig.useMaxWidth ?? conf.useMaxWidth
- );
-};
-
-export default {
- draw,
-};
diff --git a/packages/mermaid/src/diagrams/git/gitGraphRenderer.ts b/packages/mermaid/src/diagrams/git/gitGraphRenderer.ts
new file mode 100644
index 000000000..39a64a623
--- /dev/null
+++ b/packages/mermaid/src/diagrams/git/gitGraphRenderer.ts
@@ -0,0 +1,1350 @@
+import { select } from 'd3';
+import { getConfig, setupGraphViewbox } from '../../diagram-api/diagramAPI.js';
+import { log } from '../../logger.js';
+import utils from '../../utils.js';
+import type { DrawDefinition } from '../../diagram-api/types.js';
+import type d3 from 'd3';
+import type { Commit, GitGraphDBRenderProvider, DiagramOrientation } from './gitGraphTypes.js';
+import { commitType } from './gitGraphTypes.js';
+
+interface BranchPosition {
+ pos: number;
+ index: number;
+}
+
+interface CommitPosition {
+ x: number;
+ y: number;
+}
+
+interface CommitPositionOffset extends CommitPosition {
+ posWithOffset: number;
+}
+
+const DEFAULT_CONFIG = getConfig();
+const DEFAULT_GITGRAPH_CONFIG = DEFAULT_CONFIG?.gitGraph;
+const LAYOUT_OFFSET = 10;
+const COMMIT_STEP = 40;
+const PX = 4;
+const PY = 2;
+
+const THEME_COLOR_LIMIT = 8;
+const branchPos = new Map();
+const commitPos = new Map();
+const defaultPos = 30;
+
+let allCommitsDict = new Map();
+let lanes: number[] = [];
+let maxPos = 0;
+let dir: DiagramOrientation = 'LR';
+
+const clear = () => {
+ branchPos.clear();
+ commitPos.clear();
+ allCommitsDict.clear();
+ maxPos = 0;
+ lanes = [];
+ dir = 'LR';
+};
+
+const drawText = (txt: string | string[]) => {
+ const svgLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text');
+ const rows = typeof txt === 'string' ? txt.split(/\\n|\n|
/gi) : txt;
+
+ rows.forEach((row) => {
+ const tspan = document.createElementNS('http://www.w3.org/2000/svg', 'tspan');
+ tspan.setAttributeNS('http://www.w3.org/XML/1998/namespace', 'xml:space', 'preserve');
+ tspan.setAttribute('dy', '1em');
+ tspan.setAttribute('x', '0');
+ tspan.setAttribute('class', 'row');
+ tspan.textContent = row.trim();
+ svgLabel.appendChild(tspan);
+ });
+
+ return svgLabel;
+};
+
+const findClosestParent = (parents: string[]): string | undefined => {
+ let closestParent: string | undefined;
+ let comparisonFunc;
+ let targetPosition: number;
+ if (dir === 'BT') {
+ comparisonFunc = (a: number, b: number) => a <= b;
+ targetPosition = Infinity;
+ } else {
+ comparisonFunc = (a: number, b: number) => a >= b;
+ targetPosition = 0;
+ }
+
+ parents.forEach((parent) => {
+ const parentPosition =
+ dir === 'TB' || dir == 'BT' ? commitPos.get(parent)?.y : commitPos.get(parent)?.x;
+
+ if (parentPosition !== undefined && comparisonFunc(parentPosition, targetPosition)) {
+ closestParent = parent;
+ targetPosition = parentPosition;
+ }
+ });
+
+ return closestParent;
+};
+
+const findClosestParentBT = (parents: string[]) => {
+ let closestParent = '';
+ let maxPosition = Infinity;
+
+ parents.forEach((parent) => {
+ const parentPosition = commitPos.get(parent)!.y;
+ if (parentPosition <= maxPosition) {
+ closestParent = parent;
+ maxPosition = parentPosition;
+ }
+ });
+ return closestParent || undefined;
+};
+
+const setParallelBTPos = (
+ sortedKeys: string[],
+ commits: Map,
+ defaultPos: number
+) => {
+ let curPos = defaultPos;
+ let maxPosition = defaultPos;
+ const roots: Commit[] = [];
+
+ sortedKeys.forEach((key) => {
+ const commit = commits.get(key);
+ if (!commit) {
+ throw new Error(`Commit not found for key ${key}`);
+ }
+
+ if (commit.parents.length) {
+ curPos = calculateCommitPosition(commit);
+ maxPosition = Math.max(curPos, maxPosition);
+ } else {
+ roots.push(commit);
+ }
+ setCommitPosition(commit, curPos);
+ });
+
+ curPos = maxPosition;
+ roots.forEach((commit) => {
+ setRootPosition(commit, curPos, defaultPos);
+ });
+ sortedKeys.forEach((key) => {
+ const commit = commits.get(key);
+
+ if (commit?.parents.length) {
+ const closestParent = findClosestParentBT(commit.parents)!;
+ curPos = commitPos.get(closestParent)!.y - COMMIT_STEP;
+ if (curPos <= maxPosition) {
+ maxPosition = curPos;
+ }
+ const x = branchPos.get(commit.branch)!.pos;
+ const y = curPos - LAYOUT_OFFSET;
+ commitPos.set(commit.id, { x: x, y: y });
+ }
+ });
+};
+
+const findClosestParentPos = (commit: Commit): number => {
+ const closestParent = findClosestParent(commit.parents.filter((p) => p !== null));
+ if (!closestParent) {
+ throw new Error(`Closest parent not found for commit ${commit.id}`);
+ }
+
+ const closestParentPos = commitPos.get(closestParent)?.y;
+ if (closestParentPos === undefined) {
+ throw new Error(`Closest parent position not found for commit ${commit.id}`);
+ }
+ return closestParentPos;
+};
+
+const calculateCommitPosition = (commit: Commit): number => {
+ const closestParentPos = findClosestParentPos(commit);
+ return closestParentPos + COMMIT_STEP;
+};
+
+const setCommitPosition = (commit: Commit, curPos: number): CommitPosition => {
+ const branch = branchPos.get(commit.branch);
+
+ if (!branch) {
+ throw new Error(`Branch not found for commit ${commit.id}`);
+ }
+
+ const x = branch.pos;
+ const y = curPos + LAYOUT_OFFSET;
+ commitPos.set(commit.id, { x, y });
+ return { x, y };
+};
+
+const setRootPosition = (commit: Commit, curPos: number, defaultPos: number) => {
+ const branch = branchPos.get(commit.branch);
+ if (!branch) {
+ throw new Error(`Branch not found for commit ${commit.id}`);
+ }
+
+ const y = curPos + defaultPos;
+ const x = branch.pos;
+ commitPos.set(commit.id, { x, y });
+};
+
+const drawCommitBullet = (
+ gBullets: d3.Selection,
+ commit: Commit,
+ commitPosition: CommitPositionOffset,
+ typeClass: string,
+ branchIndex: number,
+ commitSymbolType: number
+) => {
+ if (commitSymbolType === commitType.HIGHLIGHT) {
+ gBullets
+ .append('rect')
+ .attr('x', commitPosition.x - 10)
+ .attr('y', commitPosition.y - 10)
+ .attr('width', 20)
+ .attr('height', 20)
+ .attr(
+ 'class',
+ `commit ${commit.id} commit-highlight${branchIndex % THEME_COLOR_LIMIT} ${typeClass}-outer`
+ );
+ gBullets
+ .append('rect')
+ .attr('x', commitPosition.x - 6)
+ .attr('y', commitPosition.y - 6)
+ .attr('width', 12)
+ .attr('height', 12)
+ .attr(
+ 'class',
+ `commit ${commit.id} commit${branchIndex % THEME_COLOR_LIMIT} ${typeClass}-inner`
+ );
+ } else if (commitSymbolType === commitType.CHERRY_PICK) {
+ gBullets
+ .append('circle')
+ .attr('cx', commitPosition.x)
+ .attr('cy', commitPosition.y)
+ .attr('r', 10)
+ .attr('class', `commit ${commit.id} ${typeClass}`);
+ gBullets
+ .append('circle')
+ .attr('cx', commitPosition.x - 3)
+ .attr('cy', commitPosition.y + 2)
+ .attr('r', 2.75)
+ .attr('fill', '#fff')
+ .attr('class', `commit ${commit.id} ${typeClass}`);
+ gBullets
+ .append('circle')
+ .attr('cx', commitPosition.x + 3)
+ .attr('cy', commitPosition.y + 2)
+ .attr('r', 2.75)
+ .attr('fill', '#fff')
+ .attr('class', `commit ${commit.id} ${typeClass}`);
+ gBullets
+ .append('line')
+ .attr('x1', commitPosition.x + 3)
+ .attr('y1', commitPosition.y + 1)
+ .attr('x2', commitPosition.x)
+ .attr('y2', commitPosition.y - 5)
+ .attr('stroke', '#fff')
+ .attr('class', `commit ${commit.id} ${typeClass}`);
+ gBullets
+ .append('line')
+ .attr('x1', commitPosition.x - 3)
+ .attr('y1', commitPosition.y + 1)
+ .attr('x2', commitPosition.x)
+ .attr('y2', commitPosition.y - 5)
+ .attr('stroke', '#fff')
+ .attr('class', `commit ${commit.id} ${typeClass}`);
+ } else {
+ const circle = gBullets.append('circle');
+ circle.attr('cx', commitPosition.x);
+ circle.attr('cy', commitPosition.y);
+ circle.attr('r', commit.type === commitType.MERGE ? 9 : 10);
+ circle.attr('class', `commit ${commit.id} commit${branchIndex % THEME_COLOR_LIMIT}`);
+ if (commitSymbolType === commitType.MERGE) {
+ const circle2 = gBullets.append('circle');
+ circle2.attr('cx', commitPosition.x);
+ circle2.attr('cy', commitPosition.y);
+ circle2.attr('r', 6);
+ circle2.attr(
+ 'class',
+ `commit ${typeClass} ${commit.id} commit${branchIndex % THEME_COLOR_LIMIT}`
+ );
+ }
+ if (commitSymbolType === commitType.REVERSE) {
+ const cross = gBullets.append('path');
+ cross
+ .attr(
+ 'd',
+ `M ${commitPosition.x - 5},${commitPosition.y - 5}L${commitPosition.x + 5},${commitPosition.y + 5}M${commitPosition.x - 5},${commitPosition.y + 5}L${commitPosition.x + 5},${commitPosition.y - 5}`
+ )
+ .attr('class', `commit ${typeClass} ${commit.id} commit${branchIndex % THEME_COLOR_LIMIT}`);
+ }
+ }
+};
+
+const drawCommitLabel = (
+ gLabels: d3.Selection,
+ commit: Commit,
+ commitPosition: CommitPositionOffset,
+ pos: number
+) => {
+ if (
+ commit.type !== commitType.CHERRY_PICK &&
+ ((commit.customId && commit.type === commitType.MERGE) || commit.type !== commitType.MERGE) &&
+ DEFAULT_GITGRAPH_CONFIG?.showCommitLabel
+ ) {
+ const wrapper = gLabels.append('g');
+ const labelBkg = wrapper.insert('rect').attr('class', 'commit-label-bkg');
+ const text = wrapper
+ .append('text')
+ .attr('x', pos)
+ .attr('y', commitPosition.y + 25)
+ .attr('class', 'commit-label')
+ .text(commit.id);
+ const bbox = text.node()?.getBBox();
+
+ if (bbox) {
+ labelBkg
+ .attr('x', commitPosition.posWithOffset - bbox.width / 2 - PY)
+ .attr('y', commitPosition.y + 13.5)
+ .attr('width', bbox.width + 2 * PY)
+ .attr('height', bbox.height + 2 * PY);
+
+ if (dir === 'TB' || dir === 'BT') {
+ labelBkg
+ .attr('x', commitPosition.x - (bbox.width + 4 * PX + 5))
+ .attr('y', commitPosition.y - 12);
+ text
+ .attr('x', commitPosition.x - (bbox.width + 4 * PX))
+ .attr('y', commitPosition.y + bbox.height - 12);
+ } else {
+ text.attr('x', commitPosition.posWithOffset - bbox.width / 2);
+ }
+
+ if (DEFAULT_GITGRAPH_CONFIG.rotateCommitLabel) {
+ if (dir === 'TB' || dir === 'BT') {
+ text.attr(
+ 'transform',
+ 'rotate(' + -45 + ', ' + commitPosition.x + ', ' + commitPosition.y + ')'
+ );
+ labelBkg.attr(
+ 'transform',
+ 'rotate(' + -45 + ', ' + commitPosition.x + ', ' + commitPosition.y + ')'
+ );
+ } else {
+ const r_x = -7.5 - ((bbox.width + 10) / 25) * 9.5;
+ const r_y = 10 + (bbox.width / 25) * 8.5;
+ wrapper.attr(
+ 'transform',
+ 'translate(' +
+ r_x +
+ ', ' +
+ r_y +
+ ') rotate(' +
+ -45 +
+ ', ' +
+ pos +
+ ', ' +
+ commitPosition.y +
+ ')'
+ );
+ }
+ }
+ }
+ }
+};
+
+const drawCommitTags = (
+ gLabels: d3.Selection,
+ commit: Commit,
+ commitPosition: CommitPositionOffset,
+ pos: number
+) => {
+ if (commit.tags.length > 0) {
+ let yOffset = 0;
+ let maxTagBboxWidth = 0;
+ let maxTagBboxHeight = 0;
+ const tagElements = [];
+
+ for (const tagValue of commit.tags.reverse()) {
+ const rect = gLabels.insert('polygon');
+ const hole = gLabels.append('circle');
+ const tag = gLabels
+ .append('text')
+ .attr('y', commitPosition.y - 16 - yOffset)
+ .attr('class', 'tag-label')
+ .text(tagValue);
+ const tagBbox = tag.node()?.getBBox();
+ if (!tagBbox) {
+ throw new Error('Tag bbox not found');
+ }
+
+ maxTagBboxWidth = Math.max(maxTagBboxWidth, tagBbox.width);
+ maxTagBboxHeight = Math.max(maxTagBboxHeight, tagBbox.height);
+
+ tag.attr('x', commitPosition.posWithOffset - tagBbox.width / 2);
+
+ tagElements.push({
+ tag,
+ hole,
+ rect,
+ yOffset,
+ });
+
+ yOffset += 20;
+ }
+
+ for (const { tag, hole, rect, yOffset } of tagElements) {
+ const h2 = maxTagBboxHeight / 2;
+ const ly = commitPosition.y - 19.2 - yOffset;
+ rect.attr('class', 'tag-label-bkg').attr(
+ 'points',
+ `
+ ${pos - maxTagBboxWidth / 2 - PX / 2},${ly + PY}
+ ${pos - maxTagBboxWidth / 2 - PX / 2},${ly - PY}
+ ${commitPosition.posWithOffset - maxTagBboxWidth / 2 - PX},${ly - h2 - PY}
+ ${commitPosition.posWithOffset + maxTagBboxWidth / 2 + PX},${ly - h2 - PY}
+ ${commitPosition.posWithOffset + maxTagBboxWidth / 2 + PX},${ly + h2 + PY}
+ ${commitPosition.posWithOffset - maxTagBboxWidth / 2 - PX},${ly + h2 + PY}`
+ );
+
+ hole
+ .attr('cy', ly)
+ .attr('cx', pos - maxTagBboxWidth / 2 + PX / 2)
+ .attr('r', 1.5)
+ .attr('class', 'tag-hole');
+
+ if (dir === 'TB' || dir === 'BT') {
+ const yOrigin = pos + yOffset;
+
+ rect
+ .attr('class', 'tag-label-bkg')
+ .attr(
+ 'points',
+ `
+ ${commitPosition.x},${yOrigin + 2}
+ ${commitPosition.x},${yOrigin - 2}
+ ${commitPosition.x + LAYOUT_OFFSET},${yOrigin - h2 - 2}
+ ${commitPosition.x + LAYOUT_OFFSET + maxTagBboxWidth + 4},${yOrigin - h2 - 2}
+ ${commitPosition.x + LAYOUT_OFFSET + maxTagBboxWidth + 4},${yOrigin + h2 + 2}
+ ${commitPosition.x + LAYOUT_OFFSET},${yOrigin + h2 + 2}`
+ )
+ .attr('transform', 'translate(12,12) rotate(45, ' + commitPosition.x + ',' + pos + ')');
+ hole
+ .attr('cx', commitPosition.x + PX / 2)
+ .attr('cy', yOrigin)
+ .attr('transform', 'translate(12,12) rotate(45, ' + commitPosition.x + ',' + pos + ')');
+ tag
+ .attr('x', commitPosition.x + 5)
+ .attr('y', yOrigin + 3)
+ .attr('transform', 'translate(14,14) rotate(45, ' + commitPosition.x + ',' + pos + ')');
+ }
+ }
+ }
+};
+
+const getCommitClassType = (commit: Commit): string => {
+ const commitSymbolType = commit.customType ?? commit.type;
+ switch (commitSymbolType) {
+ case commitType.NORMAL:
+ return 'commit-normal';
+ case commitType.REVERSE:
+ return 'commit-reverse';
+ case commitType.HIGHLIGHT:
+ return 'commit-highlight';
+ case commitType.MERGE:
+ return 'commit-merge';
+ case commitType.CHERRY_PICK:
+ return 'commit-cherry-pick';
+ default:
+ return 'commit-normal';
+ }
+};
+
+const calculatePosition = (
+ commit: Commit,
+ dir: string,
+ pos: number,
+ commitPos: Map
+): number => {
+ const defaultCommitPosition = { x: 0, y: 0 }; // Default position if commit is not found
+
+ if (commit.parents.length > 0) {
+ const closestParent = findClosestParent(commit.parents);
+ if (closestParent) {
+ const parentPosition = commitPos.get(closestParent) ?? defaultCommitPosition;
+
+ if (dir === 'TB') {
+ return parentPosition.y + COMMIT_STEP;
+ } else if (dir === 'BT') {
+ const currentPosition = commitPos.get(commit.id) ?? defaultCommitPosition;
+ return currentPosition.y - COMMIT_STEP;
+ } else {
+ return parentPosition.x + COMMIT_STEP;
+ }
+ }
+ } else {
+ if (dir === 'TB') {
+ return defaultPos;
+ } else if (dir === 'BT') {
+ const currentPosition = commitPos.get(commit.id) ?? defaultCommitPosition;
+ return currentPosition.y - COMMIT_STEP;
+ } else {
+ return 0;
+ }
+ }
+ return 0;
+};
+
+const getCommitPosition = (
+ commit: Commit,
+ pos: number,
+ isParallelCommits: boolean
+): CommitPositionOffset => {
+ const posWithOffset = dir === 'BT' && isParallelCommits ? pos : pos + LAYOUT_OFFSET;
+ const y = dir === 'TB' || dir === 'BT' ? posWithOffset : branchPos.get(commit.branch)?.pos;
+ const x = dir === 'TB' || dir === 'BT' ? branchPos.get(commit.branch)?.pos : posWithOffset;
+ if (x === undefined || y === undefined) {
+ throw new Error(`Position were undefined for commit ${commit.id}`);
+ }
+ return { x, y, posWithOffset };
+};
+
+const drawCommits = (
+ svg: d3.Selection,
+ commits: Map,
+ modifyGraph: boolean
+) => {
+ if (!DEFAULT_GITGRAPH_CONFIG) {
+ throw new Error('GitGraph config not found');
+ }
+ const gBullets = svg.append('g').attr('class', 'commit-bullets');
+ const gLabels = svg.append('g').attr('class', 'commit-labels');
+ let pos = dir === 'TB' || dir === 'BT' ? defaultPos : 0;
+ const keys = [...commits.keys()];
+ const isParallelCommits = DEFAULT_GITGRAPH_CONFIG?.parallelCommits ?? false;
+
+ const sortKeys = (a: string, b: string) => {
+ const seqA = commits.get(a)?.seq;
+ const seqB = commits.get(b)?.seq;
+ return seqA !== undefined && seqB !== undefined ? seqA - seqB : 0;
+ };
+
+ let sortedKeys = keys.sort(sortKeys);
+ if (dir === 'BT') {
+ if (isParallelCommits) {
+ setParallelBTPos(sortedKeys, commits, pos);
+ }
+ sortedKeys = sortedKeys.reverse();
+ }
+
+ sortedKeys.forEach((key) => {
+ const commit = commits.get(key);
+ if (!commit) {
+ throw new Error(`Commit not found for key ${key}`);
+ }
+ if (isParallelCommits) {
+ pos = calculatePosition(commit, dir, pos, commitPos);
+ }
+
+ const commitPosition = getCommitPosition(commit, pos, isParallelCommits);
+ // Don't draw the commits now but calculate the positioning which is used by the branch lines etc.
+ if (modifyGraph) {
+ const typeClass = getCommitClassType(commit);
+ const commitSymbolType = commit.customType ?? commit.type;
+ const branchIndex = branchPos.get(commit.branch)?.index ?? 0;
+ drawCommitBullet(gBullets, commit, commitPosition, typeClass, branchIndex, commitSymbolType);
+ drawCommitLabel(gLabels, commit, commitPosition, pos);
+ drawCommitTags(gLabels, commit, commitPosition, pos);
+ }
+ if (dir === 'TB' || dir === 'BT') {
+ commitPos.set(commit.id, { x: commitPosition.x, y: commitPosition.posWithOffset });
+ } else {
+ commitPos.set(commit.id, { x: commitPosition.posWithOffset, y: commitPosition.y });
+ }
+ pos = dir === 'BT' && isParallelCommits ? pos + COMMIT_STEP : pos + COMMIT_STEP + LAYOUT_OFFSET;
+ if (pos > maxPos) {
+ maxPos = pos;
+ }
+ });
+};
+
+const shouldRerouteArrow = (
+ commitA: Commit,
+ commitB: Commit,
+ p1: CommitPosition,
+ p2: CommitPosition,
+ allCommits: Map
+) => {
+ const commitBIsFurthest = dir === 'TB' || dir === 'BT' ? p1.x < p2.x : p1.y < p2.y;
+ const branchToGetCurve = commitBIsFurthest ? commitB.branch : commitA.branch;
+ const isOnBranchToGetCurve = (x: Commit) => x.branch === branchToGetCurve;
+ const isBetweenCommits = (x: Commit) => x.seq > commitA.seq && x.seq < commitB.seq;
+ return [...allCommits.values()].some((commitX) => {
+ return isBetweenCommits(commitX) && isOnBranchToGetCurve(commitX);
+ });
+};
+
+const findLane = (y1: number, y2: number, depth = 0): number => {
+ const candidate = y1 + Math.abs(y1 - y2) / 2;
+ if (depth > 5) {
+ return candidate;
+ }
+
+ const ok = lanes.every((lane) => Math.abs(lane - candidate) >= 10);
+ if (ok) {
+ lanes.push(candidate);
+ return candidate;
+ }
+ const diff = Math.abs(y1 - y2);
+ return findLane(y1, y2 - diff / 5, depth + 1);
+};
+
+const drawArrow = (
+ svg: d3.Selection,
+ commitA: Commit,
+ commitB: Commit,
+ allCommits: Map
+) => {
+ const p1 = commitPos.get(commitA.id); // arrowStart
+ const p2 = commitPos.get(commitB.id); // arrowEnd
+ if (p1 === undefined || p2 === undefined) {
+ throw new Error(`Commit positions not found for commits ${commitA.id} and ${commitB.id}`);
+ }
+ const arrowNeedsRerouting = shouldRerouteArrow(commitA, commitB, p1, p2, allCommits);
+ // log.debug('drawArrow', p1, p2, arrowNeedsRerouting, commitA.id, commitB.id);
+
+ // Lower-right quadrant logic; top-left is 0,0
+
+ let arc = '';
+ let arc2 = '';
+ let radius = 0;
+ let offset = 0;
+
+ let colorClassNum = branchPos.get(commitB.branch)?.index;
+ if (commitB.type === commitType.MERGE && commitA.id !== commitB.parents[0]) {
+ colorClassNum = branchPos.get(commitA.branch)?.index;
+ }
+
+ let lineDef;
+ if (arrowNeedsRerouting) {
+ arc = 'A 10 10, 0, 0, 0,';
+ arc2 = 'A 10 10, 0, 0, 1,';
+ radius = 10;
+ offset = 10;
+
+ const lineY = p1.y < p2.y ? findLane(p1.y, p2.y) : findLane(p2.y, p1.y);
+
+ const lineX = p1.x < p2.x ? findLane(p1.x, p2.x) : findLane(p2.x, p1.x);
+
+ if (dir === 'TB') {
+ if (p1.x < p2.x) {
+ // Source commit is on branch position left of destination commit
+ // so render arrow rightward with colour of destination branch
+
+ lineDef = `M ${p1.x} ${p1.y} L ${lineX - radius} ${p1.y} ${arc2} ${lineX} ${
+ p1.y + offset
+ } L ${lineX} ${p2.y - radius} ${arc} ${lineX + offset} ${p2.y} L ${p2.x} ${p2.y}`;
+ } else {
+ // Source commit is on branch position right of destination commit
+ // so render arrow leftward with colour of source branch
+
+ colorClassNum = branchPos.get(commitA.branch)?.index;
+
+ lineDef = `M ${p1.x} ${p1.y} L ${lineX + radius} ${p1.y} ${arc} ${lineX} ${p1.y + offset} L ${lineX} ${p2.y - radius} ${arc2} ${lineX - offset} ${p2.y} L ${p2.x} ${p2.y}`;
+ }
+ } else if (dir === 'BT') {
+ if (p1.x < p2.x) {
+ // Source commit is on branch position left of destination commit
+ // so render arrow rightward with colour of destination branch
+
+ lineDef = `M ${p1.x} ${p1.y} L ${lineX - radius} ${p1.y} ${arc} ${lineX} ${p1.y - offset} L ${lineX} ${p2.y + radius} ${arc2} ${lineX + offset} ${p2.y} L ${p2.x} ${p2.y}`;
+ } else {
+ // Source commit is on branch position right of destination commit
+ // so render arrow leftward with colour of source branch
+
+ colorClassNum = branchPos.get(commitA.branch)?.index;
+
+ lineDef = `M ${p1.x} ${p1.y} L ${lineX + radius} ${p1.y} ${arc2} ${lineX} ${p1.y - offset} L ${lineX} ${p2.y + radius} ${arc} ${lineX - offset} ${p2.y} L ${p2.x} ${p2.y}`;
+ }
+ } else {
+ if (p1.y < p2.y) {
+ // Source commit is on branch positioned above destination commit
+ // so render arrow downward with colour of destination branch
+
+ lineDef = `M ${p1.x} ${p1.y} L ${p1.x} ${lineY - radius} ${arc} ${
+ p1.x + offset
+ } ${lineY} L ${p2.x - radius} ${lineY} ${arc2} ${p2.x} ${lineY + offset} L ${p2.x} ${p2.y}`;
+ } else {
+ // Source commit is on branch positioned below destination commit
+ // so render arrow upward with colour of source branch
+
+ colorClassNum = branchPos.get(commitA.branch)?.index;
+
+ lineDef = `M ${p1.x} ${p1.y} L ${p1.x} ${lineY + radius} ${arc2} ${
+ p1.x + offset
+ } ${lineY} L ${p2.x - radius} ${lineY} ${arc} ${p2.x} ${lineY - offset} L ${p2.x} ${p2.y}`;
+ }
+ }
+ } else {
+ arc = 'A 20 20, 0, 0, 0,';
+ arc2 = 'A 20 20, 0, 0, 1,';
+ radius = 20;
+ offset = 20;
+
+ if (dir === 'TB') {
+ if (p1.x < p2.x) {
+ if (commitB.type === commitType.MERGE && commitA.id !== commitB.parents[0]) {
+ lineDef = `M ${p1.x} ${p1.y} L ${p1.x} ${p2.y - radius} ${arc} ${p1.x + offset} ${
+ p2.y
+ } L ${p2.x} ${p2.y}`;
+ } else {
+ lineDef = `M ${p1.x} ${p1.y} L ${p2.x - radius} ${p1.y} ${arc2} ${p2.x} ${
+ p1.y + offset
+ } L ${p2.x} ${p2.y}`;
+ }
+ }
+
+ if (p1.x > p2.x) {
+ arc = 'A 20 20, 0, 0, 0,';
+ arc2 = 'A 20 20, 0, 0, 1,';
+ radius = 20;
+ offset = 20;
+ if (commitB.type === commitType.MERGE && commitA.id !== commitB.parents[0]) {
+ lineDef = `M ${p1.x} ${p1.y} L ${p1.x} ${p2.y - radius} ${arc2} ${p1.x - offset} ${
+ p2.y
+ } L ${p2.x} ${p2.y}`;
+ } else {
+ lineDef = `M ${p1.x} ${p1.y} L ${p2.x + radius} ${p1.y} ${arc} ${p2.x} ${
+ p1.y + offset
+ } L ${p2.x} ${p2.y}`;
+ }
+ }
+ if (p1.x === p2.x) {
+ lineDef = `M ${p1.x} ${p1.y} L ${p2.x} ${p2.y}`;
+ }
+ } else if (dir === 'BT') {
+ if (p1.x < p2.x) {
+ if (commitB.type === commitType.MERGE && commitA.id !== commitB.parents[0]) {
+ lineDef = `M ${p1.x} ${p1.y} L ${p1.x} ${p2.y + radius} ${arc2} ${p1.x + offset} ${
+ p2.y
+ } L ${p2.x} ${p2.y}`;
+ } else {
+ lineDef = `M ${p1.x} ${p1.y} L ${p2.x - radius} ${p1.y} ${arc} ${p2.x} ${
+ p1.y - offset
+ } L ${p2.x} ${p2.y}`;
+ }
+ }
+ if (p1.x > p2.x) {
+ arc = 'A 20 20, 0, 0, 0,';
+ arc2 = 'A 20 20, 0, 0, 1,';
+ radius = 20;
+ offset = 20;
+
+ if (commitB.type === commitType.MERGE && commitA.id !== commitB.parents[0]) {
+ lineDef = `M ${p1.x} ${p1.y} L ${p1.x} ${p2.y + radius} ${arc} ${p1.x - offset} ${
+ p2.y
+ } L ${p2.x} ${p2.y}`;
+ } else {
+ lineDef = `M ${p1.x} ${p1.y} L ${p2.x - radius} ${p1.y} ${arc} ${p2.x} ${
+ p1.y - offset
+ } L ${p2.x} ${p2.y}`;
+ }
+ }
+
+ if (p1.x === p2.x) {
+ lineDef = `M ${p1.x} ${p1.y} L ${p2.x} ${p2.y}`;
+ }
+ } else {
+ if (p1.y < p2.y) {
+ if (commitB.type === commitType.MERGE && commitA.id !== commitB.parents[0]) {
+ lineDef = `M ${p1.x} ${p1.y} L ${p2.x - radius} ${p1.y} ${arc2} ${p2.x} ${
+ p1.y + offset
+ } L ${p2.x} ${p2.y}`;
+ } else {
+ lineDef = `M ${p1.x} ${p1.y} L ${p1.x} ${p2.y - radius} ${arc} ${p1.x + offset} ${
+ p2.y
+ } L ${p2.x} ${p2.y}`;
+ }
+ }
+ if (p1.y > p2.y) {
+ if (commitB.type === commitType.MERGE && commitA.id !== commitB.parents[0]) {
+ lineDef = `M ${p1.x} ${p1.y} L ${p2.x - radius} ${p1.y} ${arc} ${p2.x} ${
+ p1.y - offset
+ } L ${p2.x} ${p2.y}`;
+ } else {
+ lineDef = `M ${p1.x} ${p1.y} L ${p1.x} ${p2.y + radius} ${arc2} ${p1.x + offset} ${
+ p2.y
+ } L ${p2.x} ${p2.y}`;
+ }
+ }
+
+ if (p1.y === p2.y) {
+ lineDef = `M ${p1.x} ${p1.y} L ${p2.x} ${p2.y}`;
+ }
+ }
+ }
+ if (lineDef === undefined) {
+ throw new Error('Line definition not found');
+ }
+ svg
+ .append('path')
+ .attr('d', lineDef)
+ .attr('class', 'arrow arrow' + (colorClassNum! % THEME_COLOR_LIMIT));
+};
+
+const drawArrows = (
+ svg: d3.Selection,
+ commits: Map
+) => {
+ const gArrows = svg.append('g').attr('class', 'commit-arrows');
+ [...commits.keys()].forEach((key) => {
+ const commit = commits.get(key);
+
+ if (commit!.parents && commit!.parents.length > 0) {
+ commit!.parents.forEach((parent) => {
+ drawArrow(gArrows, commits.get(parent)!, commit!, commits);
+ });
+ }
+ });
+};
+
+const drawBranches = (
+ svg: d3.Selection,
+ branches: { name: string }[]
+) => {
+ const g = svg.append('g');
+ branches.forEach((branch, index) => {
+ const adjustIndexForTheme = index % THEME_COLOR_LIMIT;
+
+ const pos = branchPos.get(branch.name)?.pos;
+ if (pos === undefined) {
+ throw new Error(`Position not found for branch ${branch.name}`);
+ }
+ const line = g.append('line');
+ line.attr('x1', 0);
+ line.attr('y1', pos);
+ line.attr('x2', maxPos);
+ line.attr('y2', pos);
+ line.attr('class', 'branch branch' + adjustIndexForTheme);
+
+ if (dir === 'TB') {
+ line.attr('y1', defaultPos);
+ line.attr('x1', pos);
+ line.attr('y2', maxPos);
+ line.attr('x2', pos);
+ } else if (dir === 'BT') {
+ line.attr('y1', maxPos);
+ line.attr('x1', pos);
+ line.attr('y2', defaultPos);
+ line.attr('x2', pos);
+ }
+ lanes.push(pos);
+
+ const name = branch.name;
+
+ // Create the actual text element
+ const labelElement = drawText(name);
+ // Create outer g, edgeLabel, this will be positioned after graph layout
+ const bkg = g.insert('rect');
+ const branchLabel = g.insert('g').attr('class', 'branchLabel');
+
+ // Create inner g, label, this will be positioned now for centering the text
+ const label = branchLabel.insert('g').attr('class', 'label branch-label' + adjustIndexForTheme);
+
+ label.node()!.appendChild(labelElement);
+ const bbox = labelElement.getBBox();
+ bkg
+ .attr('class', 'branchLabelBkg label' + adjustIndexForTheme)
+ .attr('rx', 4)
+ .attr('ry', 4)
+ .attr('x', -bbox.width - 4 - (DEFAULT_GITGRAPH_CONFIG?.rotateCommitLabel === true ? 30 : 0))
+ .attr('y', -bbox.height / 2 + 8)
+ .attr('width', bbox.width + 18)
+ .attr('height', bbox.height + 4);
+ label.attr(
+ 'transform',
+ 'translate(' +
+ (-bbox.width - 14 - (DEFAULT_GITGRAPH_CONFIG?.rotateCommitLabel === true ? 30 : 0)) +
+ ', ' +
+ (pos - bbox.height / 2 - 1) +
+ ')'
+ );
+ if (dir === 'TB') {
+ bkg.attr('x', pos - bbox.width / 2 - 10).attr('y', 0);
+ label.attr('transform', 'translate(' + (pos - bbox.width / 2 - 5) + ', ' + 0 + ')');
+ } else if (dir === 'BT') {
+ bkg.attr('x', pos - bbox.width / 2 - 10).attr('y', maxPos);
+ label.attr('transform', 'translate(' + (pos - bbox.width / 2 - 5) + ', ' + maxPos + ')');
+ } else {
+ bkg.attr('transform', 'translate(' + -19 + ', ' + (pos - bbox.height / 2) + ')');
+ }
+ });
+};
+
+const setBranchPosition = function (
+ name: string,
+ pos: number,
+ index: number,
+ bbox: DOMRect,
+ rotateCommitLabel: boolean
+): number {
+ branchPos.set(name, { pos, index });
+ pos += 50 + (rotateCommitLabel ? 40 : 0) + (dir === 'TB' || dir === 'BT' ? bbox.width / 2 : 0);
+ return pos;
+};
+
+export const draw: DrawDefinition = function (txt, id, ver, diagObj) {
+ clear();
+
+ log.debug('in gitgraph renderer', txt + '\n', 'id:', id, ver);
+ if (!DEFAULT_GITGRAPH_CONFIG) {
+ throw new Error('GitGraph config not found');
+ }
+ const rotateCommitLabel = DEFAULT_GITGRAPH_CONFIG.rotateCommitLabel ?? false;
+ const db = diagObj.db as GitGraphDBRenderProvider;
+ allCommitsDict = db.getCommits();
+ const branches = db.getBranchesAsObjArray();
+ dir = db.getDirection();
+ const diagram = select(`[id="${id}"]`);
+ let pos = 0;
+
+ branches.forEach((branch, index) => {
+ const labelElement = drawText(branch.name);
+ const g = diagram.append('g');
+ const branchLabel = g.insert('g').attr('class', 'branchLabel');
+ const label = branchLabel.insert('g').attr('class', 'label branch-label');
+ label.node()?.appendChild(labelElement);
+ const bbox = labelElement.getBBox();
+
+ pos = setBranchPosition(branch.name, pos, index, bbox, rotateCommitLabel);
+ label.remove();
+ branchLabel.remove();
+ g.remove();
+ });
+
+ drawCommits(diagram, allCommitsDict, false);
+ if (DEFAULT_GITGRAPH_CONFIG.showBranches) {
+ drawBranches(diagram, branches);
+ }
+ drawArrows(diagram, allCommitsDict);
+ drawCommits(diagram, allCommitsDict, true);
+
+ utils.insertTitle(
+ diagram,
+ 'gitTitleText',
+ DEFAULT_GITGRAPH_CONFIG.titleTopMargin ?? 0,
+ db.getDiagramTitle()
+ );
+
+ // Setup the view box and size of the svg element
+ setupGraphViewbox(
+ undefined,
+ diagram,
+ DEFAULT_GITGRAPH_CONFIG.diagramPadding,
+ DEFAULT_GITGRAPH_CONFIG.useMaxWidth
+ );
+};
+
+export default {
+ draw,
+};
+
+if (import.meta.vitest) {
+ const { it, expect, describe } = import.meta.vitest;
+
+ describe('drawText', () => {
+ it('should drawText', () => {
+ const svgLabel = drawText('main');
+ expect(svgLabel).toBeDefined();
+ expect(svgLabel.children[0].innerHTML).toBe('main');
+ });
+ });
+
+ describe('branchPosition', () => {
+ const bbox: DOMRect = {
+ x: 0,
+ y: 0,
+ width: 10,
+ height: 10,
+ top: 0,
+ right: 0,
+ bottom: 0,
+ left: 0,
+ toJSON: () => '',
+ };
+
+ it('should setBranchPositions LR with two branches', () => {
+ dir = 'LR';
+
+ const pos = setBranchPosition('main', 0, 0, bbox, true);
+ expect(pos).toBe(90);
+ expect(branchPos.get('main')).toEqual({ pos: 0, index: 0 });
+ const posNext = setBranchPosition('develop', pos, 1, bbox, true);
+ expect(posNext).toBe(180);
+ expect(branchPos.get('develop')).toEqual({ pos: pos, index: 1 });
+ });
+
+ it('should setBranchPositions TB with two branches', () => {
+ dir = 'TB';
+ bbox.width = 34.9921875;
+
+ const pos = setBranchPosition('main', 0, 0, bbox, true);
+ expect(pos).toBe(107.49609375);
+ expect(branchPos.get('main')).toEqual({ pos: 0, index: 0 });
+
+ bbox.width = 56.421875;
+ const posNext = setBranchPosition('develop', pos, 1, bbox, true);
+ expect(posNext).toBe(225.70703125);
+ expect(branchPos.get('develop')).toEqual({ pos: pos, index: 1 });
+ });
+ });
+
+ describe('commitPosition', () => {
+ const commits = new Map([
+ [
+ 'commitZero',
+ {
+ id: 'ZERO',
+ message: '',
+ seq: 0,
+ type: commitType.NORMAL,
+ tags: [],
+ parents: [],
+ branch: 'main',
+ },
+ ],
+ [
+ 'commitA',
+ {
+ id: 'A',
+ message: '',
+ seq: 1,
+ type: commitType.NORMAL,
+ tags: [],
+ parents: ['ZERO'],
+ branch: 'feature',
+ },
+ ],
+ [
+ 'commitB',
+ {
+ id: 'B',
+ message: '',
+ seq: 2,
+ type: commitType.NORMAL,
+ tags: [],
+ parents: ['A'],
+ branch: 'feature',
+ },
+ ],
+ [
+ 'commitM',
+ {
+ id: 'M',
+ message: 'merged branch feature into main',
+ seq: 3,
+ type: commitType.MERGE,
+ tags: [],
+ parents: ['ZERO', 'B'],
+ branch: 'main',
+ customId: true,
+ },
+ ],
+ [
+ 'commitC',
+ {
+ id: 'C',
+ message: '',
+ seq: 4,
+ type: commitType.NORMAL,
+ tags: [],
+ parents: ['ZERO'],
+ branch: 'release',
+ },
+ ],
+ [
+ 'commit5_8928ea0',
+ {
+ id: '5-8928ea0',
+ message: 'cherry-picked [object Object] into release',
+ seq: 5,
+ type: commitType.CHERRY_PICK,
+ tags: [],
+ parents: ['C', 'M'],
+ branch: 'release',
+ },
+ ],
+ [
+ 'commitD',
+ {
+ id: 'D',
+ message: '',
+ seq: 6,
+ type: commitType.NORMAL,
+ tags: [],
+ parents: ['5-8928ea0'],
+ branch: 'release',
+ },
+ ],
+ [
+ 'commit7_ed848ba',
+ {
+ id: '7-ed848ba',
+ message: 'cherry-picked [object Object] into release',
+ seq: 7,
+ type: commitType.CHERRY_PICK,
+ tags: [],
+ parents: ['D', 'M'],
+ branch: 'release',
+ },
+ ],
+ ]);
+ let pos = 0;
+ branchPos.set('main', { pos: 0, index: 0 });
+ branchPos.set('feature', { pos: 107.49609375, index: 1 });
+ branchPos.set('release', { pos: 224.03515625, index: 2 });
+
+ describe('TB', () => {
+ pos = 30;
+ dir = 'TB';
+ const expectedCommitPositionTB = new Map([
+ ['commitZero', { x: 0, y: 40, posWithOffset: 40 }],
+ ['commitA', { x: 107.49609375, y: 90, posWithOffset: 90 }],
+ ['commitB', { x: 107.49609375, y: 140, posWithOffset: 140 }],
+ ['commitM', { x: 0, y: 190, posWithOffset: 190 }],
+ ['commitC', { x: 224.03515625, y: 240, posWithOffset: 240 }],
+ ['commit5_8928ea0', { x: 224.03515625, y: 290, posWithOffset: 290 }],
+ ['commitD', { x: 224.03515625, y: 340, posWithOffset: 340 }],
+ ['commit7_ed848ba', { x: 224.03515625, y: 390, posWithOffset: 390 }],
+ ]);
+ commits.forEach((commit, key) => {
+ it(`should give the correct position for commit ${key}`, () => {
+ const position = getCommitPosition(commit, pos, false);
+ expect(position).toEqual(expectedCommitPositionTB.get(key));
+ pos += 50;
+ });
+ });
+ });
+ describe('LR', () => {
+ let pos = 30;
+ dir = 'LR';
+ const expectedCommitPositionLR = new Map([
+ ['commitZero', { x: 0, y: 40, posWithOffset: 40 }],
+ ['commitA', { x: 107.49609375, y: 90, posWithOffset: 90 }],
+ ['commitB', { x: 107.49609375, y: 140, posWithOffset: 140 }],
+ ['commitM', { x: 0, y: 190, posWithOffset: 190 }],
+ ['commitC', { x: 224.03515625, y: 240, posWithOffset: 240 }],
+ ['commit5_8928ea0', { x: 224.03515625, y: 290, posWithOffset: 290 }],
+ ['commitD', { x: 224.03515625, y: 340, posWithOffset: 340 }],
+ ['commit7_ed848ba', { x: 224.03515625, y: 390, posWithOffset: 390 }],
+ ]);
+ commits.forEach((commit, key) => {
+ it(`should give the correct position for commit ${key}`, () => {
+ const position = getCommitPosition(commit, pos, false);
+ expect(position).toEqual(expectedCommitPositionLR.get(key));
+ pos += 50;
+ });
+ });
+ });
+ describe('getCommitClassType', () => {
+ const expectedCommitClassType = new Map([
+ ['commitZero', 'commit-normal'],
+ ['commitA', 'commit-normal'],
+ ['commitB', 'commit-normal'],
+ ['commitM', 'commit-merge'],
+ ['commitC', 'commit-normal'],
+ ['commit5_8928ea0', 'commit-cherry-pick'],
+ ['commitD', 'commit-normal'],
+ ['commit7_ed848ba', 'commit-cherry-pick'],
+ ]);
+ commits.forEach((commit, key) => {
+ it(`should give the correct class type for commit ${key}`, () => {
+ const classType = getCommitClassType(commit);
+ expect(classType).toBe(expectedCommitClassType.get(key));
+ });
+ });
+ });
+ });
+ describe('building BT parallel commit diagram', () => {
+ const commits = new Map([
+ [
+ '1-abcdefg',
+ {
+ id: '1-abcdefg',
+ message: '',
+ seq: 0,
+ type: 0,
+ tags: [],
+ parents: [],
+ branch: 'main',
+ },
+ ],
+ [
+ '2-abcdefg',
+ {
+ id: '2-abcdefg',
+ message: '',
+ seq: 1,
+ type: 0,
+ tags: [],
+ parents: ['1-abcdefg'],
+ branch: 'main',
+ },
+ ],
+ [
+ '3-abcdefg',
+ {
+ id: '3-abcdefg',
+ message: '',
+ seq: 2,
+ type: 0,
+ tags: [],
+ parents: ['2-abcdefg'],
+ branch: 'develop',
+ },
+ ],
+ [
+ '4-abcdefg',
+ {
+ id: '4-abcdefg',
+ message: '',
+ seq: 3,
+ type: 0,
+ tags: [],
+ parents: ['3-abcdefg'],
+ branch: 'develop',
+ },
+ ],
+ [
+ '5-abcdefg',
+ {
+ id: '5-abcdefg',
+ message: '',
+ seq: 4,
+ type: 0,
+ tags: [],
+ parents: ['2-abcdefg'],
+ branch: 'feature',
+ },
+ ],
+ [
+ '6-abcdefg',
+ {
+ id: '6-abcdefg',
+ message: '',
+ seq: 5,
+ type: 0,
+ tags: [],
+ parents: ['5-abcdefg'],
+ branch: 'feature',
+ },
+ ],
+ [
+ '7-abcdefg',
+ {
+ id: '7-abcdefg',
+ message: '',
+ seq: 6,
+ type: 0,
+ tags: [],
+ parents: ['2-abcdefg'],
+ branch: 'main',
+ },
+ ],
+ [
+ '8-abcdefg',
+ {
+ id: '8-abcdefg',
+ message: '',
+ seq: 7,
+ type: 0,
+ tags: [],
+ parents: ['7-abcdefg'],
+ branch: 'main',
+ },
+ ],
+ ]);
+ const expectedCommitPosition = new Map([
+ ['1-abcdefg', { x: 0, y: 40 }],
+ ['2-abcdefg', { x: 0, y: 90 }],
+ ['3-abcdefg', { x: 107.49609375, y: 140 }],
+ ['4-abcdefg', { x: 107.49609375, y: 190 }],
+ ['5-abcdefg', { x: 225.70703125, y: 140 }],
+ ['6-abcdefg', { x: 225.70703125, y: 190 }],
+ ['7-abcdefg', { x: 0, y: 140 }],
+ ['8-abcdefg', { x: 0, y: 190 }],
+ ]);
+
+ const expectedCommitPositionAfterParallel = new Map([
+ ['1-abcdefg', { x: 0, y: 210 }],
+ ['2-abcdefg', { x: 0, y: 160 }],
+ ['3-abcdefg', { x: 107.49609375, y: 110 }],
+ ['4-abcdefg', { x: 107.49609375, y: 60 }],
+ ['5-abcdefg', { x: 225.70703125, y: 110 }],
+ ['6-abcdefg', { x: 225.70703125, y: 60 }],
+ ['7-abcdefg', { x: 0, y: 110 }],
+ ['8-abcdefg', { x: 0, y: 60 }],
+ ]);
+
+ const expectedCommitCurrentPosition = new Map([
+ ['1-abcdefg', 30],
+ ['2-abcdefg', 80],
+ ['3-abcdefg', 130],
+ ['4-abcdefg', 180],
+ ['5-abcdefg', 130],
+ ['6-abcdefg', 180],
+ ['7-abcdefg', 130],
+ ['8-abcdefg', 180],
+ ]);
+ const sortedKeys = [...expectedCommitPosition.keys()];
+ it('should get the correct commit position and current position', () => {
+ dir = 'BT';
+ let curPos = 30;
+ commitPos.clear();
+ branchPos.clear();
+ branchPos.set('main', { pos: 0, index: 0 });
+ branchPos.set('develop', { pos: 107.49609375, index: 1 });
+ branchPos.set('feature', { pos: 225.70703125, index: 2 });
+ DEFAULT_GITGRAPH_CONFIG!.parallelCommits = true;
+ commits.forEach((commit, key) => {
+ if (commit.parents.length > 0) {
+ curPos = calculateCommitPosition(commit);
+ }
+ const position = setCommitPosition(commit, curPos);
+ expect(position).toEqual(expectedCommitPosition.get(key));
+ expect(curPos).toEqual(expectedCommitCurrentPosition.get(key));
+ });
+ });
+
+ it('should get the correct commit position after parallel commits', () => {
+ commitPos.clear();
+ branchPos.clear();
+ dir = 'BT';
+ const curPos = 30;
+ commitPos.clear();
+ branchPos.clear();
+ branchPos.set('main', { pos: 0, index: 0 });
+ branchPos.set('develop', { pos: 107.49609375, index: 1 });
+ branchPos.set('feature', { pos: 225.70703125, index: 2 });
+ setParallelBTPos(sortedKeys, commits, curPos);
+ sortedKeys.forEach((commit) => {
+ const position = commitPos.get(commit);
+ expect(position).toEqual(expectedCommitPositionAfterParallel.get(commit));
+ });
+ });
+ });
+ DEFAULT_GITGRAPH_CONFIG!.parallelCommits = false;
+ it('add', () => {
+ commitPos.set('parent1', { x: 1, y: 1 });
+ commitPos.set('parent2', { x: 2, y: 2 });
+ commitPos.set('parent3', { x: 3, y: 3 });
+ dir = 'LR';
+ const parents = ['parent1', 'parent2', 'parent3'];
+ const closestParent = findClosestParent(parents);
+
+ expect(closestParent).toBe('parent3');
+ commitPos.clear();
+ });
+}
diff --git a/packages/mermaid/src/diagrams/git/gitGraphTypes.ts b/packages/mermaid/src/diagrams/git/gitGraphTypes.ts
new file mode 100644
index 000000000..32b951bcc
--- /dev/null
+++ b/packages/mermaid/src/diagrams/git/gitGraphTypes.ts
@@ -0,0 +1,134 @@
+import type { GitGraphDiagramConfig } from '../../config.type.js';
+import type { DiagramDBBase } from '../../diagram-api/types.js';
+
+export const commitType = {
+ NORMAL: 0,
+ REVERSE: 1,
+ HIGHLIGHT: 2,
+ MERGE: 3,
+ CHERRY_PICK: 4,
+} as const;
+
+export interface CommitDB {
+ msg: string;
+ id: string;
+ type: number;
+ tags?: string[];
+}
+
+export interface BranchDB {
+ name: string;
+ order: number;
+}
+
+export interface MergeDB {
+ branch: string;
+ id: string;
+ type?: number;
+ tags?: string[];
+}
+
+export interface CherryPickDB {
+ id: string;
+ targetId: string;
+ parent: string;
+ tags?: string[];
+}
+
+export interface Commit {
+ id: string;
+ message: string;
+ seq: number;
+ type: number;
+ tags: string[];
+ parents: string[];
+ branch: string;
+ customType?: number;
+ customId?: boolean;
+}
+
+export interface GitGraph {
+ statements: Statement[];
+}
+
+export type Statement = CommitAst | BranchAst | MergeAst | CheckoutAst | CherryPickingAst;
+
+export interface CommitAst {
+ $type: 'Commit';
+ id: string;
+ message?: string;
+ tags?: string[];
+ type?: 'NORMAL' | 'REVERSE' | 'HIGHLIGHT';
+}
+
+export interface BranchAst {
+ $type: 'Branch';
+ name: string;
+ order?: number;
+}
+
+export interface MergeAst {
+ $type: 'Merge';
+ branch: string;
+ id?: string;
+ tags?: string[];
+ type?: 'NORMAL' | 'REVERSE' | 'HIGHLIGHT';
+}
+
+export interface CheckoutAst {
+ $type: 'Checkout';
+ branch: string;
+}
+
+export interface CherryPickingAst {
+ $type: 'CherryPicking';
+ id: string;
+ parent: string;
+ tags?: string[];
+}
+
+export interface GitGraphDB extends DiagramDBBase {
+ commitType: typeof commitType;
+ setDirection: (dir: DiagramOrientation) => void;
+ setOptions: (rawOptString: string) => void;
+ getOptions: () => any;
+ commit: (commitDB: CommitDB) => void;
+ branch: (branchDB: BranchDB) => void;
+ merge: (mergeDB: MergeDB) => void;
+ cherryPick: (cherryPickDB: CherryPickDB) => void;
+ checkout: (branch: string) => void;
+ prettyPrint: () => void;
+ clear: () => void;
+ getBranchesAsObjArray: () => { name: string }[];
+ getBranches: () => Map;
+ getCommits: () => Map;
+ getCommitsArray: () => Commit[];
+ getCurrentBranch: () => string;
+ getDirection: () => DiagramOrientation;
+ getHead: () => Commit | null;
+}
+
+export interface GitGraphDBParseProvider extends Partial {
+ commitType: typeof commitType;
+ setDirection: (dir: DiagramOrientation) => void;
+ commit: (commitDB: CommitDB) => void;
+ branch: (branchDB: BranchDB) => void;
+ merge: (mergeDB: MergeDB) => void;
+ cherryPick: (cherryPickDB: CherryPickDB) => void;
+ checkout: (branch: string) => void;
+}
+
+export interface GitGraphDBRenderProvider extends Partial {
+ prettyPrint: () => void;
+ clear: () => void;
+ getBranchesAsObjArray: () => { name: string }[];
+ getBranches: () => Map;
+ getCommits: () => Map;
+ getCommitsArray: () => Commit[];
+ getCurrentBranch: () => string;
+ getDirection: () => DiagramOrientation;
+ getHead: () => Commit | null;
+ getDiagramTitle: () => string;
+}
+
+export type DiagramOrientation = 'LR' | 'TB' | 'BT';
diff --git a/packages/mermaid/src/diagrams/git/parser/gitGraph.jison b/packages/mermaid/src/diagrams/git/parser/gitGraph.jison
deleted file mode 100644
index fa2c70586..000000000
--- a/packages/mermaid/src/diagrams/git/parser/gitGraph.jison
+++ /dev/null
@@ -1,248 +0,0 @@
-/*
- * Parse following
- * gitGraph:
- * commit
- * commit
- * branch
- */
-%lex
-
-%x string
-%x options
-%x acc_title
-%x acc_descr
-%x acc_descr_multiline
-%options case-insensitive
-
-
-%%
-accTitle\s*":"\s* { this.begin("acc_title");return 'acc_title'; }
-(?!\n|;|#)*[^\n]* { this.popState(); return "acc_title_value"; }
-accDescr\s*":"\s* { this.begin("acc_descr");return 'acc_descr'; }
-(?!\n|;|#)*[^\n]* { this.popState(); return "acc_descr_value"; }
-accDescr\s*"{"\s* { this.begin("acc_descr_multiline");}
-[\}] { this.popState(); }
-[^\}]* return "acc_descr_multiline_value";
-(\r?\n)+ /*{console.log('New line');return 'NL';}*/ return 'NL';
-\#[^\n]* /* skip comments */
-\%%[^\n]* /* skip comments */
-"gitGraph" return 'GG';
-commit(?=\s|$) return 'COMMIT';
-"id:" return 'COMMIT_ID';
-"type:" return 'COMMIT_TYPE';
-"msg:" return 'COMMIT_MSG';
-"NORMAL" return 'NORMAL';
-"REVERSE" return 'REVERSE';
-"HIGHLIGHT" return 'HIGHLIGHT';
-"tag:" return 'COMMIT_TAG';
-branch(?=\s|$) return 'BRANCH';
-"order:" return 'ORDER';
-merge(?=\s|$) return 'MERGE';
-cherry\-pick(?=\s|$) return 'CHERRY_PICK';
-"parent:" return 'PARENT_COMMIT'
-// "reset" return 'RESET';
-\b(checkout|switch)(?=\s|$) return 'CHECKOUT';
-"LR" return 'DIR';
-"TB" return 'DIR';
-"BT" return 'DIR';
-":" return ':';
-"^" return 'CARET'
-"options"\r?\n this.begin("options"); //
-[ \r\n\t]+"end" this.popState(); // not used anymore in the renderer, fixed for backward compatibility
-[\s\S]+(?=[ \r\n\t]+"end") return 'OPT'; //
-["]["] return 'EMPTYSTR';
-["] this.begin("string");
-["] this.popState();
-[^"]* return 'STR';
-[0-9]+(?=\s|$) return 'NUM';
-\w([-\./\w]*[-\w])? return 'ID'; // only a subset of https://git-scm.com/docs/git-check-ref-format
-<> return 'EOF';
-\s+ /* skip all whitespace */ // lowest priority so we can use lookaheads in earlier regex
-
-/lex
-
-%left '^'
-
-%start start
-
-%% /* language grammar */
-
-start
- : eol start
- | GG document EOF{ return $3; }
- | GG ':' document EOF{ return $3; }
- | GG DIR ':' document EOF {yy.setDirection($2); return $4;}
- ;
-
-
-document
- : /*empty*/
- | options body { yy.setOptions($1); $$ = $2}
- ;
-
-options
- : options OPT {$1 +=$2; $$=$1}
- | NL
- ;
-body
- : /*empty*/ {$$ = []}
- | body line {$1.push($2); $$=$1;}
- ;
-line
- : statement eol {$$ =$1}
- | NL
- ;
-
-statement
- : commitStatement
- | mergeStatement
- | cherryPickStatement
- | acc_title acc_title_value { $$=$2.trim();yy.setAccTitle($$); }
- | acc_descr acc_descr_value { $$=$2.trim();yy.setAccDescription($$); }
- | acc_descr_multiline_value { $$=$1.trim();yy.setAccDescription($$); } | section {yy.addSection($1.substr(8));$$=$1.substr(8);}
- | branchStatement
- | CHECKOUT ref {yy.checkout($2)}
- // | RESET reset_arg {yy.reset($2)}
- ;
-branchStatement
- : BRANCH ref {yy.branch($2)}
- | BRANCH ref ORDER NUM {yy.branch($2, $4)}
- ;
-
-cherryPickStatement
- : CHERRY_PICK COMMIT_ID STR {yy.cherryPick($3, '', undefined)}
- | CHERRY_PICK COMMIT_ID STR PARENT_COMMIT STR {yy.cherryPick($3, '', undefined,$5)}
- | CHERRY_PICK COMMIT_ID STR commitTags {yy.cherryPick($3, '', $4)}
- | CHERRY_PICK COMMIT_ID STR PARENT_COMMIT STR commitTags {yy.cherryPick($3, '', $6,$5)}
- | CHERRY_PICK COMMIT_ID STR commitTags PARENT_COMMIT STR {yy.cherryPick($3, '', $4,$6)}
- | CHERRY_PICK commitTags COMMIT_ID STR {yy.cherryPick($4, '', $2)}
- | CHERRY_PICK commitTags COMMIT_ID STR PARENT_COMMIT STR {yy.cherryPick($4, '', $2,$6)}
- ;
-
-mergeStatement
- : MERGE ref {yy.merge($2,'','', undefined)}
- | MERGE ref COMMIT_ID STR {yy.merge($2, $4,'', undefined)}
- | MERGE ref COMMIT_TYPE commitType {yy.merge($2,'', $4, undefined)}
- | MERGE ref commitTags {yy.merge($2, '','',$3)}
- | MERGE ref commitTags COMMIT_ID STR {yy.merge($2, $5,'', $3)}
- | MERGE ref commitTags COMMIT_TYPE commitType {yy.merge($2, '',$5, $3)}
- | MERGE ref COMMIT_TYPE commitType commitTags {yy.merge($2, '',$4, $5)}
- | MERGE ref COMMIT_ID STR COMMIT_TYPE commitType {yy.merge($2, $4, $6, undefined)}
- | MERGE ref COMMIT_ID STR commitTags {yy.merge($2, $4, '', $5)}
- | MERGE ref COMMIT_TYPE commitType COMMIT_ID STR {yy.merge($2, $6,$4, undefined)}
- | MERGE ref COMMIT_ID STR COMMIT_TYPE commitType commitTags {yy.merge($2, $4, $6, $7)}
- | MERGE ref COMMIT_TYPE commitType commitTags COMMIT_ID STR {yy.merge($2, $7, $4, $5)}
- | MERGE ref COMMIT_ID STR commitTags COMMIT_TYPE commitType {yy.merge($2, $4, $7, $5)}
- | MERGE ref COMMIT_TYPE commitType COMMIT_ID STR commitTags {yy.merge($2, $6, $4, $7)}
- | MERGE ref commitTags COMMIT_TYPE commitType COMMIT_ID STR {yy.merge($2, $7, $5, $3)}
- | MERGE ref commitTags COMMIT_ID STR COMMIT_TYPE commitType {yy.merge($2, $5, $7, $3)}
- ;
-
-commitStatement
- : COMMIT commit_arg {yy.commit($2)}
- | COMMIT commitTags {yy.commit('','',yy.commitType.NORMAL,$2)}
- | COMMIT COMMIT_TYPE commitType {yy.commit('','',$3, undefined)}
- | COMMIT commitTags COMMIT_TYPE commitType {yy.commit('','',$4,$2)}
- | COMMIT COMMIT_TYPE commitType commitTags {yy.commit('','',$3,$4)}
- | COMMIT COMMIT_ID STR {yy.commit('',$3,yy.commitType.NORMAL, undefined)}
- | COMMIT COMMIT_ID STR commitTags {yy.commit('',$3,yy.commitType.NORMAL,$4)}
- | COMMIT commitTags COMMIT_ID STR {yy.commit('',$4,yy.commitType.NORMAL,$2)}
- | COMMIT COMMIT_ID STR COMMIT_TYPE commitType {yy.commit('',$3,$5, undefined)}
- | COMMIT COMMIT_TYPE commitType COMMIT_ID STR {yy.commit('',$5,$3, undefined)}
- | COMMIT COMMIT_ID STR COMMIT_TYPE commitType commitTags {yy.commit('',$3,$5,$6)}
- | COMMIT COMMIT_ID STR commitTags COMMIT_TYPE commitType {yy.commit('',$3,$6,$4)}
- | COMMIT COMMIT_TYPE commitType COMMIT_ID STR commitTags {yy.commit('',$5,$3,$6)}
- | COMMIT COMMIT_TYPE commitType commitTags COMMIT_ID STR {yy.commit('',$6,$3,$4)}
- | COMMIT commitTags COMMIT_TYPE commitType COMMIT_ID STR {yy.commit('',$6,$4,$2)}
- | COMMIT commitTags COMMIT_ID STR COMMIT_TYPE commitType {yy.commit('',$4,$6,$2)}
- | COMMIT COMMIT_MSG STR {yy.commit($3,'',yy.commitType.NORMAL, undefined)}
- | COMMIT commitTags COMMIT_MSG STR {yy.commit($4,'',yy.commitType.NORMAL,$2)}
- | COMMIT COMMIT_MSG STR commitTags {yy.commit($3,'',yy.commitType.NORMAL,$4)}
- | COMMIT COMMIT_MSG STR COMMIT_TYPE commitType {yy.commit($3,'',$5, undefined)}
- | COMMIT COMMIT_TYPE commitType COMMIT_MSG STR {yy.commit($5,'',$3, undefined)}
- | COMMIT COMMIT_ID STR COMMIT_MSG STR {yy.commit($5,$3,yy.commitType.NORMAL, undefined)}
- | COMMIT COMMIT_MSG STR COMMIT_ID STR {yy.commit($3,$5,yy.commitType.NORMAL, undefined)}
-
- | COMMIT COMMIT_MSG STR COMMIT_TYPE commitType commitTags {yy.commit($3,'',$5,$6)}
- | COMMIT COMMIT_MSG STR commitTags COMMIT_TYPE commitType {yy.commit($3,'',$6,$4)}
- | COMMIT COMMIT_TYPE commitType COMMIT_MSG STR commitTags {yy.commit($5,'',$3,$6)}
- | COMMIT COMMIT_TYPE commitType commitTags COMMIT_MSG STR {yy.commit($6,'',$3,$4)}
- | COMMIT commitTags COMMIT_TYPE commitType COMMIT_MSG STR {yy.commit($6,'',$4,$2)}
- | COMMIT commitTags COMMIT_MSG STR COMMIT_TYPE commitType {yy.commit($4,'',$6,$2)}
-
- | COMMIT COMMIT_MSG STR COMMIT_TYPE commitType COMMIT_ID STR {yy.commit($3,$7,$5, undefined)}
- | COMMIT COMMIT_MSG STR COMMIT_ID STR COMMIT_TYPE commitType {yy.commit($3,$5,$7, undefined)}
- | COMMIT COMMIT_TYPE commitType COMMIT_MSG STR COMMIT_ID STR {yy.commit($5,$7,$3, undefined)}
- | COMMIT COMMIT_TYPE commitType COMMIT_ID STR COMMIT_MSG STR {yy.commit($7,$5,$3, undefined)}
- | COMMIT COMMIT_ID STR COMMIT_TYPE commitType COMMIT_MSG STR {yy.commit($7,$3,$5, undefined)}
- | COMMIT COMMIT_ID STR COMMIT_MSG STR COMMIT_TYPE commitType {yy.commit($5,$3,$7, undefined)}
-
- | COMMIT COMMIT_MSG STR commitTags COMMIT_ID STR {yy.commit($3,$6,yy.commitType.NORMAL,$4)}
- | COMMIT COMMIT_MSG STR COMMIT_ID STR commitTags {yy.commit($3,$5,yy.commitType.NORMAL,$6)}
- | COMMIT commitTags COMMIT_MSG STR COMMIT_ID STR {yy.commit($4,$6,yy.commitType.NORMAL,$2)}
- | COMMIT commitTags COMMIT_ID STR COMMIT_MSG STR {yy.commit($6,$4,yy.commitType.NORMAL,$2)}
- | COMMIT COMMIT_ID STR commitTags COMMIT_MSG STR {yy.commit($6,$3,yy.commitType.NORMAL,$4)}
- | COMMIT COMMIT_ID STR COMMIT_MSG STR commitTags {yy.commit($5,$3,yy.commitType.NORMAL,$6)}
-
- | COMMIT COMMIT_MSG STR COMMIT_ID STR COMMIT_TYPE commitType commitTags {yy.commit($3,$5,$7,$8)}
- | COMMIT COMMIT_MSG STR COMMIT_ID STR commitTags COMMIT_TYPE commitType {yy.commit($3,$5,$8,$6)}
- | COMMIT COMMIT_MSG STR COMMIT_TYPE commitType COMMIT_ID STR commitTags {yy.commit($3,$7,$5,$8)}
- | COMMIT COMMIT_MSG STR COMMIT_TYPE commitType commitTags COMMIT_ID STR {yy.commit($3,$8,$5,$6)}
- | COMMIT COMMIT_MSG STR commitTags COMMIT_ID STR COMMIT_TYPE commitType {yy.commit($3,$6,$8,$4)}
- | COMMIT COMMIT_MSG STR commitTags COMMIT_TYPE commitType COMMIT_ID STR {yy.commit($3,$8,$6,$4)}
-
- | COMMIT COMMIT_ID STR COMMIT_MSG STR COMMIT_TYPE commitType commitTags {yy.commit($5,$3,$7,$8)}
- | COMMIT COMMIT_ID STR COMMIT_MSG STR commitTags COMMIT_TYPE commitType {yy.commit($5,$3,$8,$6)}
- | COMMIT COMMIT_ID STR COMMIT_TYPE commitType COMMIT_MSG STR commitTags {yy.commit($7,$3,$5,$8)}
- | COMMIT COMMIT_ID STR COMMIT_TYPE commitType commitTags COMMIT_MSG STR {yy.commit($8,$3,$5,$6)}
- | COMMIT COMMIT_ID STR commitTags COMMIT_MSG STR COMMIT_TYPE commitType {yy.commit($6,$3,$8,$4)}
- | COMMIT COMMIT_ID STR commitTags COMMIT_TYPE commitType COMMIT_MSG STR {yy.commit($8,$3,$6,$4)}
-
- | COMMIT commitTags COMMIT_ID STR COMMIT_TYPE commitType COMMIT_MSG STR {yy.commit($8,$4,$6,$2)}
- | COMMIT commitTags COMMIT_ID STR COMMIT_MSG STR COMMIT_TYPE commitType {yy.commit($6,$4,$8,$2)}
- | COMMIT commitTags COMMIT_TYPE commitType COMMIT_ID STR COMMIT_MSG STR {yy.commit($8,$6,$4,$2)}
- | COMMIT commitTags COMMIT_TYPE commitType COMMIT_MSG STR COMMIT_ID STR {yy.commit($6,$8,$4,$2)}
- | COMMIT commitTags COMMIT_MSG STR COMMIT_ID STR COMMIT_TYPE commitType {yy.commit($4,$6,$8,$2)}
- | COMMIT commitTags COMMIT_MSG STR COMMIT_TYPE commitType COMMIT_ID STR {yy.commit($4,$8,$6,$2)}
-
- | COMMIT COMMIT_TYPE commitType COMMIT_ID STR COMMIT_MSG STR commitTags {yy.commit($7,$5,$3,$8)}
- | COMMIT COMMIT_TYPE commitType COMMIT_ID STR commitTags COMMIT_MSG STR {yy.commit($8,$5,$3,$6)}
- | COMMIT COMMIT_TYPE commitType commitTags COMMIT_MSG STR COMMIT_ID STR {yy.commit($6,$8,$3,$4)}
- | COMMIT COMMIT_TYPE commitType commitTags COMMIT_ID STR COMMIT_MSG STR {yy.commit($8,$6,$3,$4)}
- | COMMIT COMMIT_TYPE commitType COMMIT_MSG STR COMMIT_ID STR commitTags {yy.commit($5,$7,$3,$8)}
- | COMMIT COMMIT_TYPE commitType COMMIT_MSG STR commitTags COMMIT_ID STR {yy.commit($5,$8,$3,$6)}
- ;
-commit_arg
- : /* empty */ {$$ = ""}
- | STR {$$=$1}
- ;
-commitType
- : NORMAL { $$=yy.commitType.NORMAL;}
- | REVERSE { $$=yy.commitType.REVERSE;}
- | HIGHLIGHT { $$=yy.commitType.HIGHLIGHT;}
- ;
-commitTags
- : COMMIT_TAG STR {$$=[$2]}
- | COMMIT_TAG EMPTYSTR {$$=['']}
- | commitTags COMMIT_TAG STR {$commitTags.push($3); $$=$commitTags;}
- | commitTags COMMIT_TAG EMPTYSTR {$commitTags.push(''); $$=$commitTags;}
- ;
-
-ref
- : ID
- | STR
- ;
-
-eol
- : NL
- | ';'
- | EOF
- ;
-// reset_arg
-// : 'HEAD' reset_parents{$$ = $1+ ":" + $2 }
-// | ID reset_parents{$$ = $1+ ":" + yy.count; yy.count = 0}
-// ;
-// reset_parents
-// : /* empty */ {yy.count = 0}
-// | CARET reset_parents { yy.count += 1 }
-// ;
diff --git a/packages/mermaid/src/docs/community/contributing.md b/packages/mermaid/src/docs/community/contributing.md
index 71048d095..4cd649563 100644
--- a/packages/mermaid/src/docs/community/contributing.md
+++ b/packages/mermaid/src/docs/community/contributing.md
@@ -371,9 +371,9 @@ If the users have no way to know that things have changed, then you haven't real
Likewise, if users don't know that there is a new feature that you've implemented, it will forever remain unknown and unused.
The documentation has to be updated for users to know that things have been changed and added!
-If you are adding a new feature, add `(v10.8.0+)` in the title or description. It will be replaced automatically with the current version number when the release happens.
+If you are adding a new feature, add `(v+)` in the title or description. It will be replaced automatically with the current version number when the release happens.
-eg: `# Feature Name (v10.8.0+)`
+eg: `# Feature Name (v+)`
We know it can sometimes be hard to code _and_ write user documentation.
diff --git a/packages/mermaid/src/docs/intro/index.md b/packages/mermaid/src/docs/intro/index.md
index ed2df32dc..627efdaa1 100644
--- a/packages/mermaid/src/docs/intro/index.md
+++ b/packages/mermaid/src/docs/intro/index.md
@@ -50,6 +50,10 @@ For a more detailed introduction to Mermaid and some of its more basic uses, loo
**Thanks to all involved, people committing pull requests, people answering questions and special thanks to Tyler Long who is helping me maintain the project 🙏**
+Our PR Visual Regression Testing is powered by [Argos](https://argos-ci.com/?utm_source=mermaid&utm_campaign=oss) with their generous Open Source plan. It makes the process of reviewing PRs with visual changes a breeze.
+
+[](https://argos-ci.com?utm_source=mermaid&utm_campaign=oss)
+
In our release process we rely heavily on visual regression tests using [applitools](https://applitools.com/). Applitools is a great service which has been easy to use and integrate with our tests.
diff --git a/packages/mermaid/src/docs/intro/syntax-reference.md b/packages/mermaid/src/docs/intro/syntax-reference.md
index d4ee1067f..7d7fd5994 100644
--- a/packages/mermaid/src/docs/intro/syntax-reference.md
+++ b/packages/mermaid/src/docs/intro/syntax-reference.md
@@ -65,3 +65,110 @@ Allows for the limited reconfiguration of a diagram just before it is rendered.
### [Theme Manipulation](../config/theming.md)
An application of using Directives to change [Themes](../config/theming.md). `Theme` is a value within Mermaid's configuration that dictates the color scheme for diagrams.
+
+### Layout and look
+
+We've restructured how Mermaid renders diagrams, enabling new features like selecting layout and look. **Currently, this is supported for flowcharts and state diagrams**, with plans to extend support to all diagram types.
+
+### Selecting Diagram Looks
+
+Mermaid offers a variety of styles or “looks” for your diagrams, allowing you to tailor the visual appearance to match your specific needs or preferences. Whether you prefer a hand-drawn or classic style, you can easily customize your diagrams.
+
+**Available Looks:**
+
+ • Hand-Drawn Look: For a more personal, creative touch, the hand-drawn look brings a sketch-like quality to your diagrams. This style is perfect for informal settings or when you want to add a bit of personality to your diagrams.
+ • Classic Look: If you prefer the traditional Mermaid style, the classic look maintains the original appearance that many users are familiar with. It’s great for consistency across projects or when you want to keep the familiar aesthetic.
+
+**How to Select a Look:**
+
+You can select a look by adding the look parameter in the metadata section of your Mermaid diagram code. Here’s an example:
+
+```mermaid
+---
+config:
+ look: handDrawn
+ theme: neutral
+---
+flowchart LR
+ A[Start] --> B{Decision}
+ B -->|Yes| C[Continue]
+ B -->|No| D[Stop]
+```
+
+#### Selecting Layout Algorithms
+
+In addition to customizing the look of your diagrams, Mermaid Chart now allows you to choose different layout algorithms to better organize and present your diagrams, especially when dealing with more complex structures. The layout algorithm dictates how nodes and edges are arranged on the page.
+
+#### Supported Layout Algorithms:
+
+ • Dagre (default): This is the classic layout algorithm that has been used in Mermaid for a long time. It provides a good balance of simplicity and visual clarity, making it ideal for most diagrams.
+ • ELK: For those who need more sophisticated layout capabilities, especially when working with large or intricate diagrams, the ELK (Eclipse Layout Kernel) layout offers advanced options. It provides a more optimized arrangement, potentially reducing overlapping and improving readability. This is not included out the box but needs to be added when integrating mermaid for sites/applications that want to have elk support.
+
+#### How to Select a Layout Algorithm:
+
+You can specify the layout algorithm directly in the metadata section of your Mermaid diagram code. Here’s an example:
+
+```mermaid
+---
+config:
+ layout: elk
+ look: handDrawn
+ theme: dark
+---
+flowchart TB
+ A[Start] --> B{Decision}
+ B -->|Yes| C[Continue]
+ B -->|No| D[Stop]
+```
+
+In this example, the `layout: elk` line configures the diagram to use the ELK layout algorithm, along with the hand drawn look and forest theme.
+
+#### Customizing ELK Layout:
+
+When using the ELK layout, you can further refine the diagram’s configuration, such as how nodes are placed and whether parallel edges should be combined:
+
+- To combine parallel edges, use mergeEdges: true | false.
+- To configure node placement, use nodePlacementStrategy with the following options:
+ - SIMPLE
+ - NETWORK_SIMPLEX
+ - LINEAR_SEGMENTS
+ - BRANDES_KOEPF (default)
+
+**Example configuration:**
+
+```
+---
+config:
+ layout: elk
+ elk:
+ mergeEdges: true
+ nodePlacementStrategy: LINEAR_SEGMENTS
+---
+flowchart LR
+ A[Start] --> B{Choose Path}
+ B -->|Option 1| C[Path 1]
+ B -->|Option 2| D[Path 2]
+
+#### Using Dagre Layout with Classic Look:
+```
+
+Another example:
+
+```
+---
+config:
+ layout: dagre
+ look: classic
+ theme: default
+---
+
+flowchart LR
+A[Start] --> B{Choose Path}
+B -->|Option 1| C[Path 1]
+B -->|Option 2| D[Path 2]
+
+```
+
+These options give you the flexibility to create diagrams that not only look great but are also arranged to best suit your data’s structure and flow.
+
+When integrating Mermaid, you can include look and layout configuration with the initialize call. This is also where you add the loading of elk.
diff --git a/packages/mermaid/src/docs/news/blog.md b/packages/mermaid/src/docs/news/blog.md
index 10f7672fd..f7f28bf4b 100644
--- a/packages/mermaid/src/docs/news/blog.md
+++ b/packages/mermaid/src/docs/news/blog.md
@@ -1,5 +1,23 @@
# Blog
+## [Mermaid v11 is out!](https://www.mermaidchart.com/blog/posts/mermaid-v11/)
+
+23 August 2024 · 2 mins
+
+Mermaid v11 introduces advanced layout options, new diagram types, and enhanced customization features, thanks to the incredible contributions from our community.
+
+## [Mermaid Innovation - Introducing New Looks for Mermaid Diagrams](https://www.mermaidchart.com/blog/posts/mermaid-innovation-introducing-new-looks-for-mermaid-diagrams/)
+
+6 August 2024 ·3 mins
+
+Discover the fresh new and unique Neo and Hand-Drawn looks for Mermaid Diagrams, while still offering the classic look you love.
+
+## [The Mermaid Chart Plugin for Jira: A How-To User Guide](https://www.mermaidchart.com/blog/posts/the-mermaid-chart-plugin-for-jira-a-how-to-user-guide/)
+
+31 July 2024 · 5 mins
+
+The Mermaid Chart plugin for Jira has arrived!
+
## [Mermaid AI Is Here to Change the Game For Diagram Creation](https://www.mermaidchart.com/blog/posts/mermaid-ai-is-here-to-change-the-game-for-diagram-creation/)
22 July 2024 · 5 mins
diff --git a/packages/mermaid/tsconfig.json b/packages/mermaid/tsconfig.json
index bb3a8106b..0f06a1731 100644
--- a/packages/mermaid/tsconfig.json
+++ b/packages/mermaid/tsconfig.json
@@ -9,5 +9,10 @@
"$root/*": ["src/*"]
}
},
- "include": ["./src/**/*.ts", "./package.json"]
+ "include": [
+ "./src/**/*.ts",
+ "./package.json",
+ "src/diagrams/gantt/ganttDb.js",
+ "src/diagrams/git/gitGraphRenderer.js"
+ ]
}
diff --git a/packages/parser/CHANGELOG.md b/packages/parser/CHANGELOG.md
index f6b145b5a..4b864f523 100644
--- a/packages/parser/CHANGELOG.md
+++ b/packages/parser/CHANGELOG.md
@@ -1,5 +1,11 @@
# @mermaid-js/parser
+## 0.2.0
+
+### Minor Changes
+
+- [#5664](https://github.com/mermaid-js/mermaid/pull/5664) [`5deaef4`](https://github.com/mermaid-js/mermaid/commit/5deaef456e74d796866431c26f69360e4e74dbff) Thanks [@Austin-Fulbright](https://github.com/Austin-Fulbright)! - chore: Migrate git graph to langium, use typescript for internals
+
## 0.1.1
### Patch Changes
diff --git a/packages/parser/langium-config.json b/packages/parser/langium-config.json
index c750f049d..af8a4cfe6 100644
--- a/packages/parser/langium-config.json
+++ b/packages/parser/langium-config.json
@@ -15,6 +15,11 @@
"id": "pie",
"grammar": "src/language/pie/pie.langium",
"fileExtensions": [".mmd", ".mermaid"]
+ },
+ {
+ "id": "gitGraph",
+ "grammar": "src/language/gitGraph/gitGraph.langium",
+ "fileExtensions": [".mmd", ".mermaid"]
}
],
"mode": "production",
diff --git a/packages/parser/package.json b/packages/parser/package.json
index 157d4dd05..fc70e844b 100644
--- a/packages/parser/package.json
+++ b/packages/parser/package.json
@@ -1,6 +1,6 @@
{
"name": "@mermaid-js/parser",
- "version": "0.1.1",
+ "version": "0.2.0",
"description": "MermaidJS parser",
"author": "Yokozuna59",
"contributors": [
diff --git a/packages/parser/src/language/gitGraph/gitGraph.langium b/packages/parser/src/language/gitGraph/gitGraph.langium
new file mode 100644
index 000000000..1571ebba8
--- /dev/null
+++ b/packages/parser/src/language/gitGraph/gitGraph.langium
@@ -0,0 +1,87 @@
+grammar GitGraph
+
+interface Common {
+ accDescr?: string;
+ accTitle?: string;
+ title?: string;
+}
+
+fragment TitleAndAccessibilities:
+ ((accDescr=ACC_DESCR | accTitle=ACC_TITLE | title=TITLE) EOL)+
+;
+
+fragment EOL returns string:
+ NEWLINE+ | EOF
+;
+
+terminal NEWLINE: /\r?\n/;
+terminal ACC_DESCR: /[\t ]*accDescr(?:[\t ]*:([^\n\r]*?(?=%%)|[^\n\r]*)|\s*{([^}]*)})/;
+terminal ACC_TITLE: /[\t ]*accTitle[\t ]*:(?:[^\n\r]*?(?=%%)|[^\n\r]*)/;
+terminal TITLE: /[\t ]*title(?:[\t ][^\n\r]*?(?=%%)|[\t ][^\n\r]*|)/;
+
+hidden terminal WHITESPACE: /[\t ]+/;
+hidden terminal YAML: /---[\t ]*\r?\n(?:[\S\s]*?\r?\n)?---(?:\r?\n|(?!\S))/;
+hidden terminal DIRECTIVE: /[\t ]*%%{[\S\s]*?}%%(?:\r?\n|(?!\S))/;
+hidden terminal SINGLE_LINE_COMMENT: /[\t ]*%%[^\n\r]*/;
+
+entry GitGraph:
+ NEWLINE*
+ ('gitGraph' | 'gitGraph' ':' | 'gitGraph:' | ('gitGraph' Direction ':'))
+ NEWLINE*
+ (
+ NEWLINE*
+ (TitleAndAccessibilities |
+ statements+=Statement |
+ NEWLINE)*
+ )
+;
+
+Statement
+: Commit
+| Branch
+| Merge
+| Checkout
+| CherryPicking
+;
+
+Direction:
+ dir=('LR' | 'TB' | 'BT');
+
+Commit:
+ 'commit'
+ (
+ 'id:' id=STRING
+ |'msg:'? message=STRING
+ |'tag:' tags+=STRING
+ |'type:' type=('NORMAL' | 'REVERSE' | 'HIGHLIGHT')
+ )* EOL;
+Branch:
+ 'branch' name=(ID|STRING)
+ ('order:' order=INT)?
+ EOL;
+
+Merge:
+ 'merge' branch=(ID|STRING)
+ (
+ 'id:' id=STRING
+ |'tag:' tags+=STRING
+ |'type:' type=('NORMAL' | 'REVERSE' | 'HIGHLIGHT')
+ )* EOL;
+
+Checkout:
+ ('checkout'|'switch') branch=(ID|STRING) EOL;
+
+CherryPicking:
+ 'cherry-pick'
+ (
+ 'id:' id=STRING
+ |'tag:' tags+=STRING
+ |'parent:' parent=STRING
+ )* EOL;
+
+
+
+terminal INT returns number: /[0-9]+(?=\s)/;
+terminal ID returns string: /\w([-\./\w]*[-\w])?/;
+terminal STRING: /"[^"]*"|'[^']*'/;
+
diff --git a/packages/parser/src/language/gitGraph/index.ts b/packages/parser/src/language/gitGraph/index.ts
new file mode 100644
index 000000000..fd3c604b0
--- /dev/null
+++ b/packages/parser/src/language/gitGraph/index.ts
@@ -0,0 +1 @@
+export * from './module.js';
diff --git a/packages/parser/src/language/gitGraph/module.ts b/packages/parser/src/language/gitGraph/module.ts
new file mode 100644
index 000000000..e2d45c8fa
--- /dev/null
+++ b/packages/parser/src/language/gitGraph/module.ts
@@ -0,0 +1,52 @@
+import type {
+ DefaultSharedCoreModuleContext,
+ LangiumCoreServices,
+ LangiumSharedCoreServices,
+ Module,
+ PartialLangiumCoreServices,
+} from 'langium';
+import {
+ inject,
+ createDefaultCoreModule,
+ createDefaultSharedCoreModule,
+ EmptyFileSystem,
+} from 'langium';
+import { CommonValueConverter } from '../common/valueConverter.js';
+import { MermaidGeneratedSharedModule, GitGraphGeneratedModule } from '../generated/module.js';
+import { GitGraphTokenBuilder } from './tokenBuilder.js';
+
+interface GitGraphAddedServices {
+ parser: {
+ TokenBuilder: GitGraphTokenBuilder;
+ ValueConverter: CommonValueConverter;
+ };
+}
+
+export type GitGraphServices = LangiumCoreServices & GitGraphAddedServices;
+
+export const GitGraphModule: Module<
+ GitGraphServices,
+ PartialLangiumCoreServices & GitGraphAddedServices
+> = {
+ parser: {
+ TokenBuilder: () => new GitGraphTokenBuilder(),
+ ValueConverter: () => new CommonValueConverter(),
+ },
+};
+
+export function createGitGraphServices(context: DefaultSharedCoreModuleContext = EmptyFileSystem): {
+ shared: LangiumSharedCoreServices;
+ GitGraph: GitGraphServices;
+} {
+ const shared: LangiumSharedCoreServices = inject(
+ createDefaultSharedCoreModule(context),
+ MermaidGeneratedSharedModule
+ );
+ const GitGraph: GitGraphServices = inject(
+ createDefaultCoreModule({ shared }),
+ GitGraphGeneratedModule,
+ GitGraphModule
+ );
+ shared.ServiceRegistry.register(GitGraph);
+ return { shared, GitGraph };
+}
diff --git a/packages/parser/src/language/gitGraph/tokenBuilder.ts b/packages/parser/src/language/gitGraph/tokenBuilder.ts
new file mode 100644
index 000000000..ccadf1a1f
--- /dev/null
+++ b/packages/parser/src/language/gitGraph/tokenBuilder.ts
@@ -0,0 +1,7 @@
+import { AbstractMermaidTokenBuilder } from '../common/index.js';
+
+export class GitGraphTokenBuilder extends AbstractMermaidTokenBuilder {
+ public constructor() {
+ super(['gitGraph']);
+ }
+}
diff --git a/packages/parser/src/language/index.ts b/packages/parser/src/language/index.ts
index 9f1d92ba8..8e8dbce4f 100644
--- a/packages/parser/src/language/index.ts
+++ b/packages/parser/src/language/index.ts
@@ -5,20 +5,31 @@ export {
PacketBlock,
Pie,
PieSection,
+ GitGraph,
+ Branch,
+ Commit,
+ Merge,
+ Statement,
isCommon,
isInfo,
isPacket,
isPacketBlock,
isPie,
isPieSection,
+ isGitGraph,
+ isBranch,
+ isCommit,
+ isMerge,
} from './generated/ast.js';
export {
InfoGeneratedModule,
MermaidGeneratedSharedModule,
PacketGeneratedModule,
PieGeneratedModule,
+ GitGraphGeneratedModule,
} from './generated/module.js';
+export * from './gitGraph/index.js';
export * from './common/index.js';
export * from './info/index.js';
export * from './packet/index.js';
diff --git a/packages/parser/src/parse.ts b/packages/parser/src/parse.ts
index 992b96506..233faed00 100644
--- a/packages/parser/src/parse.ts
+++ b/packages/parser/src/parse.ts
@@ -1,8 +1,8 @@
import type { LangiumParser, ParseResult } from 'langium';
-import type { Info, Packet, Pie } from './index.js';
+import type { Info, Packet, Pie, GitGraph } from './index.js';
-export type DiagramAST = Info | Packet | Pie;
+export type DiagramAST = Info | Packet | Pie | GitGraph;
const parsers: Record = {};
const initializers = {
@@ -21,11 +21,18 @@ const initializers = {
const parser = createPieServices().Pie.parser.LangiumParser;
parsers.pie = parser;
},
+ gitGraph: async () => {
+ const { createGitGraphServices } = await import('./language/gitGraph/index.js');
+ const parser = createGitGraphServices().GitGraph.parser.LangiumParser;
+ parsers.gitGraph = parser;
+ },
} as const;
export async function parse(diagramType: 'info', text: string): Promise;
export async function parse(diagramType: 'packet', text: string): Promise;
export async function parse(diagramType: 'pie', text: string): Promise;
+export async function parse(diagramType: 'gitGraph', text: string): Promise;
+
export async function parse(
diagramType: keyof typeof initializers,
text: string
diff --git a/packages/parser/tests/gitGraph.test.ts b/packages/parser/tests/gitGraph.test.ts
new file mode 100644
index 000000000..2d7c21bbe
--- /dev/null
+++ b/packages/parser/tests/gitGraph.test.ts
@@ -0,0 +1,207 @@
+import { describe, expect, it } from 'vitest';
+import type { Branch, Merge } from '../src/language/index.js';
+import { gitGraphParse as parse } from './test-util.js';
+import type { Commit } from '../src/language/index.js';
+import type { Checkout, CherryPicking } from '../src/language/generated/ast.js';
+
+describe('Parsing Commit Statements', () => {
+ it('should parse a simple commit', () => {
+ const result = parse(`gitGraph\n commit\n`);
+ expect(result.value.statements[0].$type).toBe('Commit');
+ });
+
+ it('should parse multiple commits', () => {
+ const result = parse(`gitGraph\n commit\n commit\n commit\n`);
+ expect(result.value.statements).toHaveLength(3);
+ });
+
+ it('should parse commits with all properties', () => {
+ const result = parse(`gitGraph\n commit id:"1" msg:"Fix bug" tag:"v1.2" type:NORMAL\n`);
+ const commit = result.value.statements[0] as Commit;
+ expect(commit.$type).toBe('Commit');
+ expect(commit.id).toBe('1');
+ expect(commit.message).toBe('Fix bug');
+ expect(commit.tags).toEqual(['v1.2']);
+ expect(commit.type).toBe('NORMAL');
+ });
+
+ it('should handle commit messages with special characters', () => {
+ const result = parse(`gitGraph\n commit msg:"Fix issue #123: Handle errors"\n`);
+ const commit = result.value.statements[0] as Commit;
+ expect(commit.message).toBe('Fix issue #123: Handle errors');
+ });
+
+ it('should parse commits with only a message and no other properties', () => {
+ const result = parse(`gitGraph\n commit msg:"Initial release"\n`);
+ const commit = result.value.statements[0] as Commit;
+ expect(commit.message).toBe('Initial release');
+ expect(commit.id).toBeUndefined();
+ expect(commit.type).toBeUndefined();
+ });
+
+ it('should ignore malformed properties and not break parsing', () => {
+ const result = parse(`gitGraph\n commit id:"2" msg:"Malformed commit" oops:"ignored"\n`);
+ const commit = result.value.statements[0] as Commit;
+ expect(commit.id).toBe('2');
+ expect(commit.message).toBe('Malformed commit');
+ expect(commit.hasOwnProperty('oops')).toBe(false);
+ });
+
+ it('should parse multiple commits with different types', () => {
+ const result = parse(`gitGraph\n commit type:NORMAL\n commit type:REVERSE\n`);
+ const commit1 = result.value.statements[0] as Commit;
+ const commit2 = result.value.statements[1] as Commit;
+ expect(commit1.type).toBe('NORMAL');
+ expect(commit2.type).toBe('REVERSE');
+ });
+});
+
+describe('Parsing Branch Statements', () => {
+ it('should parse a branch with a simple name', () => {
+ const result = parse(`gitGraph\n commit\n commit\n branch master\n`);
+ const branch = result.value.statements[2] as Branch;
+ expect(branch.name).toBe('master');
+ });
+
+ it('should parse a branch with an order property', () => {
+ const result = parse(`gitGraph\n commit\n branch feature order:1\n`);
+ const branch = result.value.statements[1] as Branch;
+ expect(branch.name).toBe('feature');
+ expect(branch.order).toBe(1);
+ });
+
+ it('should handle branch names with special characters', () => {
+ const result = parse(`gitGraph\n branch feature/test-branch\n`);
+ const branch = result.value.statements[0] as Branch;
+ expect(branch.name).toBe('feature/test-branch');
+ });
+
+ it('should parse branches with hyphens and underscores', () => {
+ const result = parse(`gitGraph\n branch my-feature_branch\n`);
+ const branch = result.value.statements[0] as Branch;
+ expect(branch.name).toBe('my-feature_branch');
+ });
+
+ it('should correctly handle branch without order property', () => {
+ const result = parse(`gitGraph\n branch feature\n`);
+ const branch = result.value.statements[0] as Branch;
+ expect(branch.name).toBe('feature');
+ expect(branch.order).toBeUndefined();
+ });
+});
+
+describe('Parsing Merge Statements', () => {
+ it('should parse a merge with a branch name', () => {
+ const result = parse(`gitGraph\n merge master\n`);
+ const merge = result.value.statements[0] as Merge;
+ expect(merge.branch).toBe('master');
+ });
+
+ it('should handle merges with additional properties', () => {
+ const result = parse(`gitGraph\n merge feature id:"m1" tag:"release" type:HIGHLIGHT\n`);
+ const merge = result.value.statements[0] as Merge;
+ expect(merge.branch).toBe('feature');
+ expect(merge.id).toBe('m1');
+ expect(merge.tags).toEqual(['release']);
+ expect(merge.type).toBe('HIGHLIGHT');
+ });
+
+ it('should parse merge without any properties', () => {
+ const result = parse(`gitGraph\n merge feature\n`);
+ const merge = result.value.statements[0] as Merge;
+ expect(merge.branch).toBe('feature');
+ });
+
+ it('should ignore malformed properties in merge statements', () => {
+ const result = parse(`gitGraph\n merge feature random:"ignored"\n`);
+ const merge = result.value.statements[0] as Merge;
+ expect(merge.branch).toBe('feature');
+ expect(merge.hasOwnProperty('random')).toBe(false);
+ });
+});
+
+describe('Parsing Checkout Statements', () => {
+ it('should parse a checkout to a named branch', () => {
+ const result = parse(
+ `gitGraph\n commit id:"1"\n branch develop\n branch fun\n checkout develop\n`
+ );
+ const checkout = result.value.statements[3] as Checkout;
+ expect(checkout.branch).toBe('develop');
+ });
+
+ it('should parse checkout to branches with complex names', () => {
+ const result = parse(`gitGraph\n checkout hotfix-123\n`);
+ const checkout = result.value.statements[0] as Checkout;
+ expect(checkout.branch).toBe('hotfix-123');
+ });
+
+ it('should parse checkouts with hyphens and numbers', () => {
+ const result = parse(`gitGraph\n checkout release-2021\n`);
+ const checkout = result.value.statements[0] as Checkout;
+ expect(checkout.branch).toBe('release-2021');
+ });
+});
+
+describe('Parsing CherryPicking Statements', () => {
+ it('should parse cherry-picking with a commit id', () => {
+ const result = parse(`gitGraph\n commit id:"123" commit id:"321" cherry-pick id:"123"\n`);
+ const cherryPick = result.value.statements[2] as CherryPicking;
+ expect(cherryPick.id).toBe('123');
+ });
+
+ it('should parse cherry-picking with multiple properties', () => {
+ const result = parse(`gitGraph\n cherry-pick id:"123" tag:"urgent" parent:"100"\n`);
+ const cherryPick = result.value.statements[0] as CherryPicking;
+ expect(cherryPick.id).toBe('123');
+ expect(cherryPick.tags).toEqual(['urgent']);
+ expect(cherryPick.parent).toBe('100');
+ });
+
+ describe('Parsing with Accessibility Titles and Descriptions', () => {
+ it('should parse accessibility titles', () => {
+ const result = parse(`gitGraph\n accTitle: Accessible Graph\n commit\n`);
+ expect(result.value.accTitle).toBe('Accessible Graph');
+ });
+
+ it('should parse multiline accessibility descriptions', () => {
+ const result = parse(
+ `gitGraph\n accDescr {\n Detailed description\n across multiple lines\n }\n commit\n`
+ );
+ expect(result.value.accDescr).toBe('Detailed description\nacross multiple lines');
+ });
+ });
+
+ describe('Integration Tests', () => {
+ it('should correctly parse a complex graph with various elements', () => {
+ const result = parse(`
+ gitGraph TB:
+ accTitle: Complex Example
+ commit id:"init" type:NORMAL
+ branch feature
+ commit id:"feat1" msg:"Add feature"
+ checkout main
+ merge feature tag:"v1.0"
+ cherry-pick id:"feat1" tag:"critical fix"
+ `);
+ expect(result.value.accTitle).toBe('Complex Example');
+ expect(result.value.statements[0].$type).toBe('Commit');
+ expect(result.value.statements[1].$type).toBe('Branch');
+ expect(result.value.statements[2].$type).toBe('Commit');
+ expect(result.value.statements[3].$type).toBe('Checkout');
+ expect(result.value.statements[4].$type).toBe('Merge');
+ expect(result.value.statements[5].$type).toBe('CherryPicking');
+ });
+ });
+
+ describe('Error Handling for Invalid Syntax', () => {
+ it('should report errors for unknown properties in commit', () => {
+ const result = parse(`gitGraph\n commit unknown:"oops"\n`);
+ expect(result.parserErrors).not.toHaveLength(0);
+ });
+
+ it('should report errors for invalid branch order', () => {
+ const result = parse(`gitGraph\n branch feature order:xyz\n`);
+ expect(result.parserErrors).not.toHaveLength(0);
+ });
+ });
+});
diff --git a/packages/parser/tests/test-util.ts b/packages/parser/tests/test-util.ts
index 9bdec348a..5cb487758 100644
--- a/packages/parser/tests/test-util.ts
+++ b/packages/parser/tests/test-util.ts
@@ -1,7 +1,18 @@
import type { LangiumParser, ParseResult } from 'langium';
import { expect, vi } from 'vitest';
-import type { Info, InfoServices, Pie, PieServices } from '../src/language/index.js';
-import { createInfoServices, createPieServices } from '../src/language/index.js';
+import type {
+ Info,
+ InfoServices,
+ Pie,
+ PieServices,
+ GitGraph,
+ GitGraphServices,
+} from '../src/language/index.js';
+import {
+ createInfoServices,
+ createPieServices,
+ createGitGraphServices,
+} from '../src/language/index.js';
const consoleMock = vi.spyOn(console, 'log').mockImplementation(() => undefined);
@@ -40,3 +51,14 @@ export function createPieTestServices() {
return { services: pieServices, parse };
}
export const pieParse = createPieTestServices().parse;
+
+const gitGraphServices: GitGraphServices = createGitGraphServices().GitGraph;
+const gitGraphParser: LangiumParser = gitGraphServices.parser.LangiumParser;
+export function createGitGraphTestServices() {
+ const parse = (input: string) => {
+ return gitGraphParser.parse(input);
+ };
+
+ return { services: gitGraphServices, parse };
+}
+export const gitGraphParse = createGitGraphTestServices().parse;