Skip to content

Commit d927a61

Browse files
authored
feat(utils): add git helper (#469)
1 parent 1602197 commit d927a61

File tree

3 files changed

+153
-6
lines changed

3 files changed

+153
-6
lines changed

packages/utils/src/lib/git.integration.test.ts

+99-2
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,19 @@ import { mkdir, rm, writeFile } from 'node:fs/promises';
22
import { join } from 'node:path';
33
import { type SimpleGit, simpleGit } from 'simple-git';
44
import { expect } from 'vitest';
5-
import { getGitRoot, getLatestCommit, toGitPath } from './git';
5+
import {
6+
getCurrentBranchOrTag,
7+
getGitRoot,
8+
getLatestCommit,
9+
guardAgainstLocalChanges,
10+
safeCheckout,
11+
toGitPath,
12+
} from './git';
613
import { toUnixPath } from './transform';
714

8-
describe('git utils', () => {
15+
describe('git utils in a git repo with a branch and commits', () => {
916
const baseDir = join(process.cwd(), 'tmp', 'testing-git-repo');
17+
const changesDir = join(baseDir, 'changes-dir');
1018
let git: SimpleGit;
1119

1220
beforeAll(async () => {
@@ -21,12 +29,26 @@ describe('git utils', () => {
2129

2230
await git.add('README.md');
2331
await git.commit('Create README');
32+
33+
await git.checkout(['master']);
2434
});
2535

2636
afterAll(async () => {
2737
await rm(baseDir, { recursive: true, force: true });
2838
});
2939

40+
beforeEach(async () => {
41+
await git.checkout(['-b', 'feature-branch']);
42+
await git.checkout(['master']);
43+
});
44+
45+
afterEach(async () => {
46+
// @TODO try why restore/stash/clean/reset hard etc does not work
47+
await rm(changesDir, { recursive: true, force: true });
48+
await git.checkout(['master']);
49+
await git.deleteLocalBranch('feature-branch');
50+
});
51+
3052
it('should log latest commit', async () => {
3153
const gitCommitDateRegex =
3254
/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{1,2} \d{2}:\d{2}:\d{2} \d{4} [+|-]\d{4}$/;
@@ -60,4 +82,79 @@ describe('git utils', () => {
6082
'Backend/API/Startup.cs',
6183
);
6284
});
85+
86+
it('guardAgainstLocalChanges should throw if history is dirty', async () => {
87+
await mkdir(changesDir, { recursive: true });
88+
await writeFile(join(changesDir, 'change.md'), '# hello-change\n');
89+
await expect(guardAgainstLocalChanges(git)).rejects.toThrow(
90+
'Working directory needs to be clean before we you can proceed. Commit your local changes or stash them.',
91+
);
92+
});
93+
94+
it('guardAgainstLocalChanges should not throw if history is clean', async () => {
95+
await expect(guardAgainstLocalChanges(git)).resolves.toBeUndefined();
96+
});
97+
98+
it('safeCheckout should checkout target branch in clean state', async () => {
99+
await expect(git.branch()).resolves.toEqual(
100+
expect.objectContaining({ current: 'master' }),
101+
);
102+
await expect(
103+
safeCheckout('feature-branch', {}, git),
104+
).resolves.toBeUndefined();
105+
await expect(git.branch()).resolves.toEqual(
106+
expect.objectContaining({ current: 'feature-branch' }),
107+
);
108+
});
109+
110+
it('safeCheckout should throw if history is dirty', async () => {
111+
await mkdir(changesDir, { recursive: true });
112+
await writeFile(join(changesDir, 'change.md'), '# hello-change\n');
113+
await expect(safeCheckout('master', {}, git)).rejects.toThrow(
114+
'Working directory needs to be clean before we you can proceed. Commit your local changes or stash them.',
115+
);
116+
});
117+
118+
it('safeCheckout should clean local changes and check out to feature-branch', async () => {
119+
// needs to get reset to be clean
120+
await mkdir(changesDir, { recursive: true });
121+
await writeFile(join(changesDir, 'change.md'), '# hello-change\n');
122+
// needs to get cleaned to be clean
123+
await writeFile(join(baseDir, 'README.md'), '# hello-world-2\n');
124+
125+
await expect(
126+
safeCheckout('feature-branch', { forceCleanStatus: true }, git),
127+
).resolves.toBeUndefined();
128+
await expect(git.branch()).resolves.toEqual(
129+
expect.objectContaining({ current: 'feature-branch' }),
130+
);
131+
await expect(git.status()).resolves.toEqual(
132+
expect.objectContaining({ files: [] }),
133+
);
134+
});
135+
136+
it('getCurrentBranchOrTag should log current branch', async () => {
137+
await expect(getCurrentBranchOrTag(git)).resolves.toBe('master');
138+
});
139+
});
140+
141+
describe('git utils in a git repo without a branch and commits', () => {
142+
const baseDir = join(process.cwd(), 'tmp', 'testing-git-repo');
143+
let git: SimpleGit;
144+
145+
beforeAll(async () => {
146+
await mkdir(baseDir, { recursive: true });
147+
git = simpleGit(baseDir);
148+
await git.init();
149+
});
150+
151+
afterAll(async () => {
152+
await rm(baseDir, { recursive: true, force: true });
153+
});
154+
155+
it('getCurrentBranchOrTag should throw if no branch is given', async () => {
156+
await expect(getCurrentBranchOrTag(git)).rejects.toThrow(
157+
'Could not get current tag or branch.',
158+
);
159+
});
63160
});

packages/utils/src/lib/git.ts

+53-3
Original file line numberDiff line numberDiff line change
@@ -38,17 +38,67 @@ export async function toGitPath(
3838

3939
export function validateCommitData(
4040
commitData: CommitData | null,
41-
options: { throwError?: boolean } = {},
41+
options: { throwError?: true } = {},
4242
): commitData is CommitData {
43-
const { throwError = false } = options;
4443
if (!commitData) {
4544
const msg = 'no commit data available';
46-
if (throwError) {
45+
if (options?.throwError) {
4746
throw new Error(msg);
4847
} else {
48+
// @TODO replace with ui().logger.warning
4949
console.warn(msg);
5050
return false;
5151
}
5252
}
5353
return true;
5454
}
55+
56+
export async function guardAgainstLocalChanges(
57+
git = simpleGit(),
58+
): Promise<void> {
59+
const isClean = await git.status(['-s']).then(r => r.files.length === 0);
60+
if (!isClean) {
61+
throw new Error(
62+
'Working directory needs to be clean before we you can proceed. Commit your local changes or stash them.',
63+
);
64+
}
65+
}
66+
67+
export async function getCurrentBranchOrTag(
68+
git = simpleGit(),
69+
): Promise<string> {
70+
try {
71+
const branch = await git.branch().then(r => r.current);
72+
// eslint-disable-next-line unicorn/prefer-ternary
73+
if (branch) {
74+
return branch;
75+
} else {
76+
// If no current branch, try to get the tag
77+
// @TODO use simple git
78+
return await git
79+
.raw(['describe', '--tags', '--exact-match'])
80+
.then(out => out.trim());
81+
}
82+
} catch {
83+
// Return a custom error message when something goes wrong
84+
throw new Error('Could not get current tag or branch.');
85+
}
86+
}
87+
88+
export async function safeCheckout(
89+
branchOrHash: string,
90+
options: {
91+
forceCleanStatus?: true;
92+
} = {},
93+
git = simpleGit(),
94+
): Promise<void> {
95+
// git requires a clean history to check out a branch
96+
if (options?.forceCleanStatus) {
97+
await git.raw(['reset', '--hard']);
98+
await git.clean(['f', 'd']);
99+
// @TODO replace with ui().logger.info
100+
console.info(`git status cleaned`);
101+
}
102+
await guardAgainstLocalChanges(git);
103+
await git.checkout(branchOrHash);
104+
}

tsconfig.base.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@
2828
],
2929
"@code-pushup/models": ["packages/models/src/index.ts"],
3030
"@code-pushup/nx-plugin": ["packages/nx-plugin/src/index.ts"],
31-
"@code-pushup/test-utils": ["testing/test-utils/src/index.ts"],
3231
"@code-pushup/test-setup": ["testing/test-setup/src/index.ts"],
32+
"@code-pushup/test-utils": ["testing/test-utils/src/index.ts"],
3333
"@code-pushup/utils": ["packages/utils/src/index.ts"]
3434
}
3535
},

0 commit comments

Comments
 (0)