semver-ratchet 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Mock Git implementation for testing semver-ratchet.
3
+ *
4
+ * This module provides an in-memory mock implementation of GitAdapter
5
+ * that simulates git operations without requiring a real repository.
6
+ */
7
+
8
+ import { GitAdapter } from './git_adapter.js';
9
+
10
+ /**
11
+ * In-memory mock implementation of GitAdapter for testing.
12
+ *
13
+ * This mock simulates git operations without requiring a real repository,
14
+ * allowing for deterministic and isolated tests.
15
+ *
16
+ * Example usage:
17
+ * ```javascript
18
+ * const mock = new MockGitAdapter();
19
+ * mock.setBranch('feature/test');
20
+ * mock.addCommit('Fix bug');
21
+ * mock.createTag('1.0.0');
22
+ * assert.equal(mock.getLatestTag(), '1.0.0');
23
+ * ```
24
+ */
25
+ export class MockGitAdapter extends GitAdapter {
26
+ /**
27
+ * Initialize the mock git adapter with default state.
28
+ */
29
+ constructor() {
30
+ super();
31
+ this._currentBranch = 'main';
32
+ this._branches = {
33
+ main: [],
34
+ master: [],
35
+ };
36
+ this._tags = {}; // tag_name -> commit_hash
37
+ this._defaultBranch = 'main';
38
+ this._historyVerified = true;
39
+ this._fetchDepth = 50;
40
+ this._tagCreationOrder = [];
41
+ this._pushedTags = [];
42
+ this._remoteConfigured = false;
43
+ }
44
+
45
+ /**
46
+ * Reset all state to defaults.
47
+ */
48
+ reset() {
49
+ this._currentBranch = 'main';
50
+ this._branches = { main: [], master: [] };
51
+ this._tags = {};
52
+ this._defaultBranch = 'main';
53
+ this._historyVerified = true;
54
+ this._fetchDepth = 50;
55
+ this._tagCreationOrder = [];
56
+ this._pushedTags = [];
57
+ this._remoteConfigured = false;
58
+ }
59
+
60
+ /**
61
+ * Set the current branch.
62
+ * @param {string} branchName - Name of the branch to switch to
63
+ */
64
+ setBranch(branchName) {
65
+ this._currentBranch = branchName;
66
+ if (!(branchName in this._branches)) {
67
+ this._branches[branchName] = [];
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Set the default/main branch name.
73
+ * @param {string} branchName - Name of the main branch (e.g., "main" or "master")
74
+ */
75
+ setDefaultBranch(branchName) {
76
+ this._defaultBranch = branchName;
77
+ if (!(branchName in this._branches)) {
78
+ this._branches[branchName] = [];
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Add a commit to the current (or specified) branch.
84
+ * @param {string} message - Commit message
85
+ * @param {string} [branch] - Optional branch name (defaults to current branch)
86
+ */
87
+ addCommit(message, branch = null) {
88
+ const targetBranch = branch || this._currentBranch;
89
+ if (!(targetBranch in this._branches)) {
90
+ this._branches[targetBranch] = [];
91
+ }
92
+ this._branches[targetBranch].push(message);
93
+ }
94
+
95
+ /**
96
+ * Create a tag internally (without printing).
97
+ * @param {string} version - Version string (without 'v' prefix)
98
+ * @private
99
+ */
100
+ _createTagInternal(version) {
101
+ const tagName = `v${version}`;
102
+
103
+ // Tag points to the latest commit on the branch
104
+ const commits = this._branches[this._currentBranch];
105
+ if (commits && commits.length > 0) {
106
+ this._tags[tagName] = commits[commits.length - 1];
107
+ } else {
108
+ // Create a synthetic commit hash for empty branches
109
+ this._tags[tagName] = `tag_${version}_no_commits`;
110
+ }
111
+
112
+ this._tagCreationOrder.push(tagName);
113
+ }
114
+
115
+ /**
116
+ * Create a tag on the current branch.
117
+ * @param {string} version - Version string (without 'v' prefix)
118
+ * @param {boolean} [force=false] - If true, overwrite existing tag
119
+ * @returns {boolean} True if tag was created successfully
120
+ */
121
+ createTag(version, force = false) {
122
+ const tagName = `v${version}`;
123
+
124
+ if (tagName in this._tags) {
125
+ if (force) {
126
+ // Overwrite existing tag
127
+ this._createTagInternal(version);
128
+ console.log(`Created tag v${version} (forced)`);
129
+ return true;
130
+ } else {
131
+ console.log(`Tag v${version} already exists. Use --force to overwrite.`);
132
+ return false;
133
+ }
134
+ } else {
135
+ // Create new tag
136
+ this._createTagInternal(version);
137
+ console.log(`Created tag v${version}`);
138
+ return true;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Delete a tag.
144
+ * @param {string} version - Version string (without 'v' prefix)
145
+ */
146
+ deleteTag(version) {
147
+ const tagName = `v${version}`;
148
+ if (tagName in this._tags) {
149
+ delete this._tags[tagName];
150
+ this._tagCreationOrder = this._tagCreationOrder.filter(t => t !== tagName);
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Set multiple tags at once.
156
+ * @param {string[]} tags - List of version strings (without 'v' prefix)
157
+ */
158
+ setTags(tags) {
159
+ for (const version of tags) {
160
+ this._createTagInternal(version);
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Set commit messages for a branch.
166
+ * @param {string[]} messages - List of commit messages
167
+ * @param {string} [branch] - Optional branch name (defaults to current branch)
168
+ */
169
+ setCommitMessages(messages, branch = null) {
170
+ const targetBranch = branch || this._currentBranch;
171
+ this._branches[targetBranch] = [...messages];
172
+ }
173
+
174
+ /**
175
+ * Set whether git history verification should succeed.
176
+ * @param {boolean} verified - True if history is sufficient
177
+ */
178
+ setHistoryVerified(verified) {
179
+ this._historyVerified = verified;
180
+ }
181
+
182
+ /**
183
+ * Set the required fetch depth for history verification.
184
+ * @param {number} depth - Minimum number of commits required
185
+ */
186
+ setFetchDepth(depth) {
187
+ this._fetchDepth = depth;
188
+ }
189
+
190
+ /**
191
+ * Set whether a remote is configured.
192
+ * @param {boolean} configured - True if remote is configured
193
+ */
194
+ setRemoteConfigured(configured) {
195
+ this._remoteConfigured = configured;
196
+ }
197
+
198
+ /**
199
+ * Get the current branch name.
200
+ * @returns {string} The branch name
201
+ */
202
+ getCurrentBranch() {
203
+ return this._currentBranch;
204
+ }
205
+
206
+ /**
207
+ * Check if current branch is main or master.
208
+ * @returns {boolean} True if on main/master branch
209
+ */
210
+ isMainBranch() {
211
+ return this._currentBranch.toLowerCase() === 'main' || this._currentBranch.toLowerCase() === 'master';
212
+ }
213
+
214
+ /**
215
+ * Calculate the number of commits between HEAD and main branch.
216
+ * @returns {number} Number of commits
217
+ */
218
+ getGitDistance() {
219
+ if (this.isMainBranch()) {
220
+ return 0;
221
+ }
222
+
223
+ const currentBranch = this._currentBranch;
224
+ const currentCommits = this._branches[currentBranch] || [];
225
+
226
+ // Simple distance calculation: commits on current branch
227
+ // In a real scenario, this would be more complex with merge tracking
228
+ return currentCommits.length;
229
+ }
230
+
231
+ /**
232
+ * Get the latest SemVer tag on the current branch.
233
+ * @returns {string|null} Tag version without 'v' prefix, or null if no tags
234
+ */
235
+ getLatestTag() {
236
+ if (this._tagCreationOrder.length === 0) {
237
+ return null;
238
+ }
239
+
240
+ // Return the last tag created (simple implementation)
241
+ const lastTag = this._tagCreationOrder[this._tagCreationOrder.length - 1];
242
+ return lastTag.substring(1); // Strip 'v' prefix
243
+ }
244
+
245
+ /**
246
+ * Get commit messages since a specific tag or all commits.
247
+ * @param {string} [sinceTag] - Optional tag to start from (without 'v' prefix)
248
+ * @returns {string[]} List of commit messages
249
+ */
250
+ getCommitMessages(sinceTag = null) {
251
+ const currentBranch = this._currentBranch;
252
+ const messages = this._branches[currentBranch] || [];
253
+
254
+ if (!sinceTag) {
255
+ return [...messages];
256
+ }
257
+
258
+ // Find commits after the tag
259
+ const tagName = `v${sinceTag}`;
260
+ if (!(tagName in this._tags)) {
261
+ return [...messages];
262
+ }
263
+
264
+ // Simple implementation: return all messages if tag exists
265
+ // A more sophisticated mock would track commit positions
266
+ return [...messages];
267
+ }
268
+
269
+ /**
270
+ * Verify that enough git history is available.
271
+ * @param {number} [fetchDepth=50] - Minimum number of commits required
272
+ * @returns {boolean} True if sufficient history exists
273
+ * @throws {Error} If history is insufficient
274
+ */
275
+ verifyGitHistory(fetchDepth = 50) {
276
+ if (!this._historyVerified) {
277
+ throw new Error('Insufficient git history: mock history verification disabled');
278
+ }
279
+
280
+ // Check if we have enough commits
281
+ const currentBranch = this._currentBranch;
282
+ const commits = this._branches[currentBranch] || [];
283
+
284
+ if (commits.length < fetchDepth) {
285
+ throw new Error(
286
+ `Insufficient git history: ${commits.length} commits found, ` +
287
+ `need at least ${fetchDepth} for accurate version calculation`,
288
+ );
289
+ }
290
+
291
+ return true;
292
+ }
293
+
294
+ /**
295
+ * Check if a tag exists for the given version.
296
+ * @param {string} version - Version string (without 'v' prefix)
297
+ * @returns {boolean} True if tag exists
298
+ */
299
+ tagExists(version) {
300
+ const tagName = `v${version}`;
301
+ return tagName in this._tags;
302
+ }
303
+
304
+ /**
305
+ * Push a tag to the remote repository.
306
+ * @param {string} version - Version string (without 'v' prefix)
307
+ * @param {string} [remote='origin'] - Remote name to push to
308
+ * @throws {Error} If tag doesn't exist or remote not configured
309
+ */
310
+ pushTag(version, remote = 'origin') {
311
+ const tagName = `v${version}`;
312
+ if (!(tagName in this._tags)) {
313
+ throw new Error(`Tag v${version} does not exist, cannot push`);
314
+ }
315
+
316
+ if (!this._remoteConfigured) {
317
+ throw new Error('No remote configured, cannot push');
318
+ }
319
+
320
+ this._pushedTags.push(tagName);
321
+ console.log(`Pushed tag v${version} to ${remote}`);
322
+ }
323
+
324
+ /**
325
+ * Get the list of pushed tags (for testing).
326
+ * @returns {string[]} List of pushed tag names
327
+ */
328
+ getPushedTags() {
329
+ return [...this._pushedTags];
330
+ }
331
+
332
+ /**
333
+ * Get the number of commits on a branch (for testing).
334
+ * @param {string} branch - Branch name
335
+ * @returns {number} Number of commits
336
+ */
337
+ getCommitCount(branch) {
338
+ return this._branches[branch]?.length || 0;
339
+ }
340
+
341
+ /**
342
+ * Get all branches (for testing).
343
+ * @returns {string[]} List of branch names
344
+ */
345
+ getBranches() {
346
+ return Object.keys(this._branches);
347
+ }
348
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Real Git implementation for semver-ratchet.
3
+ *
4
+ * This module provides a production implementation of GitAdapter
5
+ * that executes actual git commands.
6
+ */
7
+
8
+ import { execSync } from 'child_process';
9
+ import { GitAdapter } from './git_adapter.js';
10
+
11
+ /**
12
+ * Helper function to execute git commands.
13
+ * @param {string} command - Git command to execute (without 'git' prefix)
14
+ * @returns {string} Command output
15
+ * @throws {Error} If command fails
16
+ */
17
+ function execGit(command) {
18
+ return execSync(`git ${command}`, { encoding: 'utf-8' }).trim();
19
+ }
20
+
21
+ /**
22
+ * Real git implementation that executes actual git commands.
23
+ *
24
+ * This is the production implementation used when not testing.
25
+ */
26
+ export class RealGitAdapter extends GitAdapter {
27
+ /**
28
+ * Get the current branch name.
29
+ * @returns {string} The branch name
30
+ * @throws {Error} If git operation fails
31
+ */
32
+ getCurrentBranch() {
33
+ try {
34
+ return execGit('branch --show-current');
35
+ } catch (error) {
36
+ throw new Error(`Failed to get current branch: ${error.message}`);
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Check if current branch is main or master.
42
+ * @returns {boolean} True if on main/master branch
43
+ * @throws {Error} If git operation fails
44
+ */
45
+ isMainBranch() {
46
+ const branch = this.getCurrentBranch();
47
+ return branch.toLowerCase() === 'main' || branch.toLowerCase() === 'master';
48
+ }
49
+
50
+ /**
51
+ * Calculate the number of commits between HEAD and main branch.
52
+ * @returns {number} Number of commits
53
+ * @throws {Error} If git operation fails
54
+ */
55
+ getGitDistance() {
56
+ if (this.isMainBranch()) {
57
+ return 0;
58
+ }
59
+
60
+ try {
61
+ // Count commits on current branch not on main
62
+ this.getCurrentBranch();
63
+ const output = execGit('rev-list --count HEAD ^origin/main 2>/dev/null || rev-list --count HEAD');
64
+ return parseInt(output, 10) || 0;
65
+ } catch {
66
+ // Fallback: try a simpler approach
67
+ try {
68
+ const output = execGit('rev-list --count HEAD');
69
+ return parseInt(output, 10) || 0;
70
+ } catch {
71
+ return 0;
72
+ }
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Get the latest SemVer tag on the current branch.
78
+ * @returns {string|null} Tag version without 'v' prefix, or null if no tags
79
+ * @throws {Error} If git operation fails
80
+ */
81
+ getLatestTag() {
82
+ try {
83
+ const tag = execGit('describe --tags --abbrev=0');
84
+ // Strip 'v' prefix if present (e.g., "v1.0.0" -> "1.0.0")
85
+ let version = tag.replace(/^v/, '');
86
+
87
+ // Strip pre-release and build metadata if present
88
+ // e.g., "1.0.0-alpha" -> "1.0.0", "1.0.0+build123" -> "1.0.0"
89
+ version = version.split(/[-+]/)[0];
90
+
91
+ return version;
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Get commit messages since a specific tag or all commits.
99
+ * @param {string} [sinceTag] - Optional tag to start from (without 'v' prefix)
100
+ * @returns {string[]} List of commit messages
101
+ * @throws {Error} If git operation fails
102
+ */
103
+ getCommitMessages(sinceTag = null) {
104
+ try {
105
+ let command;
106
+ if (sinceTag) {
107
+ command = `log ${sinceTag}..HEAD --pretty=format:%s`;
108
+ } else {
109
+ command = 'log --pretty=format:%s -100'; // Get last 100 commits
110
+ }
111
+ const output = execGit(command);
112
+ return output ? output.split('\n').filter(m => m.trim()) : [];
113
+ } catch {
114
+ return [];
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Verify that enough git history is available.
120
+ * @param {number} [fetchDepth=50] - Minimum number of commits required
121
+ * @returns {boolean} True if sufficient history exists
122
+ * @throws {Error} If history is insufficient
123
+ */
124
+ verifyGitHistory(fetchDepth = 50) {
125
+ try {
126
+ // Check if we have enough commits
127
+ const output = execGit('rev-list --count HEAD');
128
+ const commitCount = parseInt(output, 10) || 0;
129
+
130
+ if (commitCount < fetchDepth) {
131
+ throw new Error(
132
+ `Insufficient git history: ${commitCount} commits found, ` +
133
+ `need at least ${fetchDepth} for accurate version calculation`,
134
+ );
135
+ }
136
+
137
+ return true;
138
+ } catch (error) {
139
+ if (error.message.includes('Insufficient git history')) {
140
+ throw error;
141
+ }
142
+ throw new Error(`Failed to verify git history: ${error.message}`);
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Check if a tag exists for the given version.
148
+ * @param {string} version - Version string (without 'v' prefix)
149
+ * @returns {boolean} True if tag exists
150
+ * @throws {Error} If git operation fails
151
+ */
152
+ tagExists(version) {
153
+ try {
154
+ const tagName = `v${version}`;
155
+ execGit(`rev-parse --verify refs/tags/${tagName} >/dev/null 2>&1`);
156
+ return true;
157
+ } catch {
158
+ return false;
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Create a git tag for the specified version.
164
+ * @param {string} version - Version string (without 'v' prefix)
165
+ * @param {boolean} [force=false] - If true, overwrite existing tag
166
+ * @returns {boolean} True if tag was created successfully
167
+ * @throws {Error} If git operation fails
168
+ */
169
+ createTag(version, force = false) {
170
+ try {
171
+ const tagName = `v${version}`;
172
+
173
+ if (this.tagExists(version)) {
174
+ if (force) {
175
+ // Delete existing tag and create new one
176
+ execGit(`tag -d ${tagName}`);
177
+ execGit(`tag -a ${tagName} -m "Release ${version}"`);
178
+ console.log(`Created tag v${version} (forced)`);
179
+ return true;
180
+ } else {
181
+ console.log(`Tag v${version} already exists. Use --force to overwrite.`);
182
+ return false;
183
+ }
184
+ } else {
185
+ // Create new tag
186
+ execGit(`tag -a ${tagName} -m "Release ${version}"`);
187
+ console.log(`Created tag v${version}`);
188
+ return true;
189
+ }
190
+ } catch (error) {
191
+ throw new Error(`Failed to create tag: ${error.message}`);
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Push a tag to the remote repository.
197
+ * @param {string} version - Version string (without 'v' prefix)
198
+ * @param {string} [remote='origin'] - Remote name to push to
199
+ * @throws {Error} If git operation fails
200
+ */
201
+ pushTag(version, remote = 'origin') {
202
+ try {
203
+ const tagName = `v${version}`;
204
+ execSync(`git push ${remote} ${tagName}`, { encoding: 'utf-8' });
205
+ console.log(`Pushed tag v${version} to ${remote}`);
206
+ } catch (error) {
207
+ throw new Error(`Failed to push tag: ${error.message}`);
208
+ }
209
+ }
210
+ }