@versu/core 0.4.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.
Files changed (117) hide show
  1. package/README.md +241 -0
  2. package/dist/adapters/gradle/constants.d.ts +13 -0
  3. package/dist/adapters/gradle/constants.d.ts.map +1 -0
  4. package/dist/adapters/gradle/constants.js +12 -0
  5. package/dist/adapters/gradle/gradle-project-information.d.ts +19 -0
  6. package/dist/adapters/gradle/gradle-project-information.d.ts.map +1 -0
  7. package/dist/adapters/gradle/gradle-project-information.js +233 -0
  8. package/dist/adapters/gradle/init-project-information.gradle.kts +163 -0
  9. package/dist/adapters/gradle/services/gradle-adapter-identifier.d.ts +21 -0
  10. package/dist/adapters/gradle/services/gradle-adapter-identifier.d.ts.map +1 -0
  11. package/dist/adapters/gradle/services/gradle-adapter-identifier.js +44 -0
  12. package/dist/adapters/gradle/services/gradle-module-detector.d.ts +19 -0
  13. package/dist/adapters/gradle/services/gradle-module-detector.d.ts.map +1 -0
  14. package/dist/adapters/gradle/services/gradle-module-detector.js +28 -0
  15. package/dist/adapters/gradle/services/gradle-module-system-factory.d.ts +26 -0
  16. package/dist/adapters/gradle/services/gradle-module-system-factory.d.ts.map +1 -0
  17. package/dist/adapters/gradle/services/gradle-module-system-factory.js +29 -0
  18. package/dist/adapters/gradle/services/gradle-version-update-strategy.d.ts +23 -0
  19. package/dist/adapters/gradle/services/gradle-version-update-strategy.d.ts.map +1 -0
  20. package/dist/adapters/gradle/services/gradle-version-update-strategy.js +38 -0
  21. package/dist/adapters/project-information.d.ts +62 -0
  22. package/dist/adapters/project-information.d.ts.map +1 -0
  23. package/dist/adapters/project-information.js +1 -0
  24. package/dist/changelog/index.d.ts +14 -0
  25. package/dist/changelog/index.d.ts.map +1 -0
  26. package/dist/changelog/index.js +132 -0
  27. package/dist/config/index.d.ts +122 -0
  28. package/dist/config/index.d.ts.map +1 -0
  29. package/dist/config/index.js +117 -0
  30. package/dist/factories/adapter-identifier-registry.d.ts +12 -0
  31. package/dist/factories/adapter-identifier-registry.d.ts.map +1 -0
  32. package/dist/factories/adapter-identifier-registry.js +24 -0
  33. package/dist/factories/module-system-factory.d.ts +10 -0
  34. package/dist/factories/module-system-factory.d.ts.map +1 -0
  35. package/dist/factories/module-system-factory.js +18 -0
  36. package/dist/git/index.d.ts +355 -0
  37. package/dist/git/index.d.ts.map +1 -0
  38. package/dist/git/index.js +702 -0
  39. package/dist/index.d.ts +23 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +22 -0
  42. package/dist/semver/index.d.ts +86 -0
  43. package/dist/semver/index.d.ts.map +1 -0
  44. package/dist/semver/index.js +186 -0
  45. package/dist/services/adapter-identifier-registry.d.ts +38 -0
  46. package/dist/services/adapter-identifier-registry.d.ts.map +1 -0
  47. package/dist/services/adapter-identifier-registry.js +59 -0
  48. package/dist/services/adapter-identifier.d.ts +31 -0
  49. package/dist/services/adapter-identifier.d.ts.map +1 -0
  50. package/dist/services/adapter-identifier.js +1 -0
  51. package/dist/services/adapter-metadata-provider.d.ts +51 -0
  52. package/dist/services/adapter-metadata-provider.d.ts.map +1 -0
  53. package/dist/services/adapter-metadata-provider.js +66 -0
  54. package/dist/services/changelog-generator.d.ts +16 -0
  55. package/dist/services/changelog-generator.d.ts.map +1 -0
  56. package/dist/services/changelog-generator.js +28 -0
  57. package/dist/services/commit-analyzer.d.ts +47 -0
  58. package/dist/services/commit-analyzer.d.ts.map +1 -0
  59. package/dist/services/commit-analyzer.js +89 -0
  60. package/dist/services/configuration-loader.d.ts +23 -0
  61. package/dist/services/configuration-loader.d.ts.map +1 -0
  62. package/dist/services/configuration-loader.js +79 -0
  63. package/dist/services/configuration-validator.d.ts +16 -0
  64. package/dist/services/configuration-validator.d.ts.map +1 -0
  65. package/dist/services/configuration-validator.js +24 -0
  66. package/dist/services/git-operations.d.ts +16 -0
  67. package/dist/services/git-operations.d.ts.map +1 -0
  68. package/dist/services/git-operations.js +89 -0
  69. package/dist/services/module-detector.d.ts +24 -0
  70. package/dist/services/module-detector.d.ts.map +1 -0
  71. package/dist/services/module-detector.js +1 -0
  72. package/dist/services/module-registry.d.ts +45 -0
  73. package/dist/services/module-registry.d.ts.map +1 -0
  74. package/dist/services/module-registry.js +57 -0
  75. package/dist/services/module-system-factory.d.ts +27 -0
  76. package/dist/services/module-system-factory.d.ts.map +1 -0
  77. package/dist/services/module-system-factory.js +1 -0
  78. package/dist/services/version-applier.d.ts +26 -0
  79. package/dist/services/version-applier.d.ts.map +1 -0
  80. package/dist/services/version-applier.js +63 -0
  81. package/dist/services/version-bumper.d.ts +159 -0
  82. package/dist/services/version-bumper.d.ts.map +1 -0
  83. package/dist/services/version-bumper.js +291 -0
  84. package/dist/services/version-manager.d.ts +68 -0
  85. package/dist/services/version-manager.d.ts.map +1 -0
  86. package/dist/services/version-manager.js +94 -0
  87. package/dist/services/version-update-strategy.d.ts +18 -0
  88. package/dist/services/version-update-strategy.d.ts.map +1 -0
  89. package/dist/services/version-update-strategy.js +1 -0
  90. package/dist/services/versu-runner.d.ts +46 -0
  91. package/dist/services/versu-runner.d.ts.map +1 -0
  92. package/dist/services/versu-runner.js +188 -0
  93. package/dist/utils/banner.d.ts +2 -0
  94. package/dist/utils/banner.d.ts.map +1 -0
  95. package/dist/utils/banner.js +12 -0
  96. package/dist/utils/commits.d.ts +12 -0
  97. package/dist/utils/commits.d.ts.map +1 -0
  98. package/dist/utils/commits.js +24 -0
  99. package/dist/utils/file.d.ts +7 -0
  100. package/dist/utils/file.d.ts.map +1 -0
  101. package/dist/utils/file.js +19 -0
  102. package/dist/utils/index.d.ts +6 -0
  103. package/dist/utils/index.d.ts.map +1 -0
  104. package/dist/utils/index.js +5 -0
  105. package/dist/utils/logger.d.ts +14 -0
  106. package/dist/utils/logger.d.ts.map +1 -0
  107. package/dist/utils/logger.js +22 -0
  108. package/dist/utils/properties.d.ts +24 -0
  109. package/dist/utils/properties.d.ts.map +1 -0
  110. package/dist/utils/properties.js +94 -0
  111. package/dist/utils/version.d.ts +3 -0
  112. package/dist/utils/version.d.ts.map +1 -0
  113. package/dist/utils/version.js +4 -0
  114. package/dist/utils/versioning.d.ts +9 -0
  115. package/dist/utils/versioning.d.ts.map +1 -0
  116. package/dist/utils/versioning.js +20 -0
  117. package/package.json +73 -0
@@ -0,0 +1,702 @@
1
+ /**
2
+ * Git operations module for VERSU version management.
3
+ * Provides interfaces for commit analysis, tagging, and conventional commit parsing.
4
+ * Supports monorepo and multi-module projects with module-specific tag management.
5
+ */
6
+ import { CommitParser } from "conventional-commits-parser";
7
+ import { logger } from "../utils/logger.js";
8
+ import { execa } from "execa";
9
+ /**
10
+ * Shared CommitParser instance for parsing conventional commits.
11
+ * Reused across all commit parsing operations to avoid repeated instantiation.
12
+ */
13
+ const commitParser = new CommitParser({
14
+ breakingHeaderPattern: /^(\w*)(?:\(([\w$@.\-*/ ]*)\))?!: (.*)$/,
15
+ });
16
+ /**
17
+ * Retrieves all commits for a module since its last release tag.
18
+ * Handles monorepo and single-repo scenarios with path filtering.
19
+ * @param projectInfo - Module information including path and type
20
+ * @param options - Git operation options
21
+ * @param excludePaths - Paths to exclude using git pathspec syntax
22
+ * @returns Promise resolving to array of parsed commits (oldest to newest)
23
+ */
24
+ export async function getCommitsSinceLastTag(projectInfo, options = {}, excludePaths = []) {
25
+ // Resolve the working directory, defaulting to current process directory
26
+ const cwd = options.cwd || process.cwd();
27
+ logger.debug(`🔍 Getting commits for module '${projectInfo.name}' at path '${projectInfo.path}' since last tag...`);
28
+ try {
29
+ // Find the most recent tag for this module
30
+ // For root modules, this finds general tags (v1.0.0)
31
+ // For submodules, this finds module-specific tags (module@1.0.0)
32
+ const lastTag = await getLastTagForModule(projectInfo, { cwd });
33
+ logger.debug(`🔍 Last tag for module '${projectInfo.name}' of type '${projectInfo.type}': ${lastTag}`);
34
+ // Build the git revision range
35
+ // If tag exists: 'tag..HEAD' means commits after tag up to HEAD
36
+ // If no tag: empty string means all commits in history
37
+ const range = lastTag ? `${lastTag}..HEAD` : "";
38
+ const commits = await getCommitsInRange(range, projectInfo.path, { cwd }, excludePaths);
39
+ return { commits, lastTag };
40
+ }
41
+ catch (error) {
42
+ // If tag lookup fails for any reason, fall back to all commits
43
+ // This ensures we always have commit history for version determination
44
+ const commits = await getCommitsInRange("", projectInfo.path, { cwd }, excludePaths);
45
+ return { commits, lastTag: null };
46
+ }
47
+ }
48
+ /**
49
+ * Retrieves commits within a specific git revision range with path filtering.
50
+ * Uses git's native pathspec syntax for efficient filtering in monorepos.
51
+ * @param range - Git revision range (e.g., 'tag1..tag2', 'tag..HEAD', or '' for all)
52
+ * @param pathFilter - Optional path to filter commits (use '.' for root)
53
+ * @param options - Git operation options
54
+ * @param excludePaths - Paths to exclude using ':(exclude)path' syntax
55
+ * @returns Promise resolving to array of parsed commits (oldest to newest)
56
+ */
57
+ export async function getCommitsInRange(range, pathFilter, options = {}, excludePaths = []) {
58
+ // Resolve working directory, defaulting to current directory
59
+ const cwd = options.cwd || process.cwd();
60
+ logger.debug(`🔍 Getting commits in range '${range}' with path filter '${pathFilter}' and excluding paths: ${excludePaths.join(", ")}`);
61
+ try {
62
+ // Build git log command with custom format for easy parsing
63
+ // Format: hash, subject, body, delimiter
64
+ const args = [
65
+ "log",
66
+ "--format=%s%n%b%n-hash-%n%H%n-decorations-%n%D%n-authorName-%n%an%n-authorEmail-%n%ae%n-committerName-%n%cn%n-committerEmail-%n%ce%n---COMMIT-END---",
67
+ ];
68
+ // Only add range if it's not empty
69
+ // Empty range means "all commits" which is valid
70
+ if (range.trim()) {
71
+ args.push(range);
72
+ }
73
+ // Add pathspec separator ('--') if we have paths or excludes
74
+ // This separates revision arguments from path arguments
75
+ // Add path filter if provided and not root
76
+ if (pathFilter && pathFilter !== ".") {
77
+ args.push("--", pathFilter);
78
+ }
79
+ else if (excludePaths.length > 0) {
80
+ // For root path, we still need to add the pathspec separator
81
+ // when we have exclude patterns
82
+ args.push("--");
83
+ }
84
+ // Add each exclude pattern using git's pathspec syntax
85
+ // :(exclude)path tells git to ignore commits touching that path
86
+ for (const excludePath of excludePaths) {
87
+ if (excludePath && excludePath !== ".") {
88
+ args.push(`:(exclude)${excludePath}`);
89
+ }
90
+ }
91
+ logger.debug(`🐙 Executing git command: git ${args.join(" ")}`);
92
+ // Execute git log command
93
+ // Silent mode prevents output pollution in GitHub Actions
94
+ const { stdout } = await execa("git", args, { cwd });
95
+ // Parse the formatted output into CommitInfo objects
96
+ return parseGitLog(stdout);
97
+ }
98
+ catch (error) {
99
+ // Non-throwing error handling: log warning and return empty array
100
+ // This allows the system to continue even if git operations fail
101
+ logger.warning(`Warning: Failed to get git commits: ${error}`);
102
+ return [];
103
+ }
104
+ }
105
+ /**
106
+ * Parses raw git log output into structured Commit objects with Conventional Commits analysis.
107
+ * Resilient to parsing failures - classifies non-conventional commits as 'unknown' type.
108
+ * @param output - Raw git log output using custom format
109
+ * @returns Array of parsed Commit objects (empty if no valid commits)
110
+ * @internal
111
+ */
112
+ function parseGitLog(output) {
113
+ // Early return for empty output - no commits to parse
114
+ if (!output.trim()) {
115
+ return [];
116
+ }
117
+ logger.debug(`Raw git log output:\n${output}`);
118
+ const commits = [];
119
+ // Split output into individual commit blocks using custom delimiter
120
+ // Filter removes empty blocks (trailing delimiters, etc.)
121
+ const commitBlocks = output
122
+ .split("---COMMIT-END---")
123
+ .filter((block) => block.trim());
124
+ for (const block of commitBlocks) {
125
+ const parsed = commitParser.parse(block);
126
+ if (!parsed.hash) {
127
+ throw new Error("Parsed commit is missing hash");
128
+ }
129
+ logger.debug(`Parsed commit ${parsed.hash}: ${JSON.stringify(parsed)}`);
130
+ commits.push(parsed);
131
+ }
132
+ return commits;
133
+ }
134
+ export function isBreakingCommit(commit) {
135
+ return (commit?.notes?.some((note) => note.title === "BREAKING CHANGE") || false);
136
+ }
137
+ /**
138
+ * Finds the most recent git tag for a specific module with fallback to general tags.
139
+ * Searches module-specific tags first (moduleName@*), then falls back to general tags.
140
+ * @param projectInfo - Module information for tag pattern construction
141
+ * @param options - Git operation options
142
+ * @returns Most recent tag name or null if no tags exist
143
+ */
144
+ export async function getLastTagForModule(projectInfo, options = {}) {
145
+ // Resolve working directory, defaulting to current directory
146
+ const cwd = options.cwd || process.cwd();
147
+ logger.debug(`🔍 Finding last tag for module '${projectInfo.name}' of type '${projectInfo.type}'...`);
148
+ try {
149
+ // Generate glob pattern for module-specific tags (e.g., 'api@*')
150
+ const moduleTagPattern = getModuleTagPattern(projectInfo.name);
151
+ // Only search for module-specific tags if it's not root and version is declared
152
+ // Root projects use general tags (v1.0.0) rather than module tags (root@1.0.0)
153
+ if (projectInfo.type !== "root" && projectInfo.declaredVersion) {
154
+ // Search for module-specific tags with version sorting
155
+ // --sort=-version:refname: Sort by version in descending order (newest first)
156
+ logger.debug(`🐙 Executing git command: git tag -l ${moduleTagPattern} --sort=-version:refname`);
157
+ const { stdout } = await execa("git", ["tag", "-l", moduleTagPattern, "--sort=-version:refname"], {
158
+ cwd,
159
+ });
160
+ // If we found module-specific tags, return the first (most recent)
161
+ if (stdout.trim()) {
162
+ return stdout.trim().split("\n")[0];
163
+ }
164
+ }
165
+ // Fallback to general tags when:
166
+ // 1. Module type is 'root', or
167
+ // 2. No module-specific tags were found
168
+ try {
169
+ // git describe finds the most recent tag reachable from HEAD
170
+ // --tags: Consider all tags (not just annotated)
171
+ // --abbrev=0: Don't show commit hash suffix
172
+ logger.debug(`🐙 Executing git command: git describe --tags --abbrev=0 HEAD`);
173
+ const { stdout: fallbackOutput } = await execa("git", ["describe", "--tags", "--abbrev=0", "HEAD"], {
174
+ cwd,
175
+ });
176
+ return fallbackOutput.trim();
177
+ }
178
+ catch {
179
+ // If no tags at all, return null
180
+ // This typically means it's a new repository or no releases yet
181
+ return null;
182
+ }
183
+ }
184
+ catch (error) {
185
+ // Catch-all error handler: return null if any unexpected error occurs
186
+ // This makes the function non-throwing, which is safer for version calculations
187
+ return null;
188
+ }
189
+ }
190
+ /**
191
+ * Retrieves all git tags in the repository with parsed metadata.
192
+ * Returns array with tag name, commit hash, and parsed module/version information.
193
+ * @param options - Git operation options
194
+ * @returns Promise resolving to array of GitTag objects (empty array if no tags exist)
195
+ */
196
+ export async function getAllTags(options = {}) {
197
+ // Resolve working directory
198
+ const cwd = options.cwd || process.cwd();
199
+ try {
200
+ // List all tags with custom format to get name and commit hash
201
+ // %(refname:short): Tag name without refs/tags/ prefix
202
+ // %(objectname): Full commit SHA that the tag points to
203
+ logger.debug(`🐙 Executing git command: git tag -l --format=%(refname:short) %(objectname)`);
204
+ const { stdout } = await execa("git", ["tag", "-l", "--format=%(refname:short) %(objectname)"], {
205
+ cwd,
206
+ });
207
+ // Parse each line into a GitTag object
208
+ return stdout
209
+ .trim()
210
+ .split("\n")
211
+ .filter((line) => line.trim()) // Remove empty lines
212
+ .map((line) => {
213
+ // Each line format: "tagname commithash"
214
+ const [name, hash] = line.split(" ");
215
+ // Parse tag name to extract module and version (if present)
216
+ const { module, version } = parseTagName(name);
217
+ // Return structured tag object
218
+ return {
219
+ name,
220
+ hash,
221
+ module,
222
+ version,
223
+ };
224
+ });
225
+ }
226
+ catch (error) {
227
+ // Non-throwing: return empty array if git command fails
228
+ // This could happen if not in a git repository or no tags exist
229
+ return [];
230
+ }
231
+ }
232
+ /**
233
+ * Creates an annotated git tag at the current HEAD commit.
234
+ * Annotated tags include tagger metadata, date, message, and can be GPG signed.
235
+ * @param tagName - The tag name (e.g., 'core@1.0.0' or 'v1.0.0'). Must not already exist
236
+ * @param message - The annotation message for the tag
237
+ * @param options - Git operation options
238
+ * @returns Promise that resolves when the tag is successfully created
239
+ * @throws {Error} If tag creation fails (tag exists, invalid name, etc.)
240
+ */
241
+ export async function createTag(tagName, message, options = {}) {
242
+ // Resolve working directory
243
+ const cwd = options.cwd || process.cwd();
244
+ try {
245
+ // Create annotated tag with message
246
+ // -a: Create an annotated tag (full git object)
247
+ // -m: Provide tag message inline
248
+ await execa("git", ["tag", "-a", tagName, "-m", message], { cwd });
249
+ }
250
+ catch (error) {
251
+ // Wrap error with more context for debugging
252
+ // Common failures: tag exists, no git repo, no user config
253
+ throw new Error(`Failed to create tag ${tagName}: ${error}`);
254
+ }
255
+ }
256
+ /**
257
+ * Pushes all local tags to the configured remote repository.
258
+ * Only pushes tags that don't exist on remote. Does NOT push commits.
259
+ * @param options - Git operation options
260
+ * @returns Promise that resolves when all tags are successfully pushed
261
+ * @throws {Error} If push fails (no remote, authentication, network, conflicts, etc.)
262
+ */
263
+ export async function pushTags(options = {}) {
264
+ // Resolve working directory
265
+ const cwd = options.cwd || process.cwd();
266
+ try {
267
+ // Push all tags to the remote repository
268
+ // --tags: Push all tags (annotated and lightweight)
269
+ // This does NOT push commits, only tags
270
+ await execa("git", ["push", "--tags"], { cwd });
271
+ }
272
+ catch (error) {
273
+ // Wrap error with context
274
+ // Common failures: no remote, auth, network, conflicts
275
+ throw new Error(`Failed to push tags: ${error}`);
276
+ }
277
+ }
278
+ /**
279
+ * Generates a glob pattern for searching module-specific git tags (moduleName@*).
280
+ * @param moduleName - The name of the module
281
+ * @returns A glob pattern string matching all tags for the module
282
+ * @internal
283
+ */
284
+ function getModuleTagPattern(moduleName) {
285
+ // Create glob pattern for module-specific tags
286
+ // Format: moduleName@* where * matches any version
287
+ return `${moduleName}@*`;
288
+ }
289
+ /**
290
+ * Parses a git tag name to extract module and version components.
291
+ *
292
+ * This internal utility function handles multiple tag naming conventions used in
293
+ * VERSU and returns a structured object with extracted metadata. It supports:
294
+ * - **Module tags**: `moduleName@version` (monorepo convention)
295
+ * - **Version tags**: `v{version}` or `{version}` (single repo convention)
296
+ * - **Custom tags**: Returns empty object for unrecognized formats
297
+ *
298
+ * @param tagName - The full git tag name to parse.
299
+ * Can be any string, but structured formats are recognized.
300
+ *
301
+ * @returns Object with optional `module` and `version` fields:
302
+ * - Both present: Module tag (e.g., `core@1.0.0`)
303
+ * - Only version: General tag (e.g., `v1.0.0`)
304
+ * - Empty object: Unrecognized format
305
+ * @internal
306
+ */
307
+ function parseTagName(tagName) {
308
+ // Try to match module-specific tag pattern: moduleName@version
309
+ // Regex: ^(.+)@(.+)$
310
+ // ^(.+) - Start of string, capture group 1 (module name, greedy)
311
+ // @ - Literal @ separator
312
+ // (.+)$ - Capture group 2 (version, greedy), end of string
313
+ const match = tagName.match(/^(.+)@(.+)$/);
314
+ if (match) {
315
+ // Module tag matched - return both components
316
+ return {
317
+ module: match[1],
318
+ version: match[2],
319
+ };
320
+ }
321
+ // Try to match version-only tag pattern: v?MAJOR.MINOR.PATCH...
322
+ // Regex: ^v?(\d+\.\d+\.\d+.*)$
323
+ // ^v? - Start, optional 'v' prefix
324
+ // (\d+\.\d+\.\d+ - Capture group: MAJOR.MINOR.PATCH (digits)
325
+ // .*)$ - Any remaining characters (pre-release, metadata), end
326
+ const versionMatch = tagName.match(/^v?(\d+\.\d+\.\d+.*)$/);
327
+ if (versionMatch) {
328
+ // Version tag matched - return only version (no module)
329
+ return {
330
+ version: versionMatch[1],
331
+ };
332
+ }
333
+ // Unrecognized format - return empty object
334
+ return {};
335
+ }
336
+ /**
337
+ * Checks if the git working directory is clean (no uncommitted changes).
338
+ * Uses `git status --porcelain` to detect modified, staged, deleted, or untracked files.
339
+ * @param options - Git operation options
340
+ * @returns Promise resolving to true if clean, false if there are changes or on error
341
+ */
342
+ export async function isWorkingDirectoryClean(options = {}) {
343
+ // Resolve working directory
344
+ const cwd = options.cwd || process.cwd();
345
+ try {
346
+ // Get machine-readable status output
347
+ // --porcelain: Stable, easy-to-parse format
348
+ const { stdout } = await execa("git", ["status", "--porcelain"], {
349
+ cwd,
350
+ });
351
+ // Empty output means clean working directory
352
+ // Any output indicates changes (modified, untracked, staged, etc.)
353
+ return stdout.trim() === "";
354
+ }
355
+ catch (error) {
356
+ // On error, assume directory is not clean (safe default)
357
+ // This could happen if not a git repo, or permissions issue
358
+ return false;
359
+ }
360
+ }
361
+ /**
362
+ * Retrieves the name of the currently checked out git branch.
363
+ *
364
+ * This function returns the active branch name, which is useful for:
365
+ * - Conditional logic based on branch (e.g., only release from 'main')
366
+ * - CI/CD branch-specific workflows
367
+ * - Logging and debugging
368
+ * - Branch validation before operations
369
+ *
370
+ * Returns empty string if in detached HEAD state.
371
+ * @param options - Git operation options
372
+ * @returns Promise resolving to the current branch name (empty string if detached HEAD)
373
+ * @throws {Error} If git command fails
374
+ */
375
+ export async function getCurrentBranch(options = {}) {
376
+ // Resolve working directory
377
+ const cwd = options.cwd || process.cwd();
378
+ try {
379
+ // Get the current branch name
380
+ // --show-current: Returns active branch name or empty string if detached
381
+ const { stdout } = await execa("git", ["branch", "--show-current"], {
382
+ cwd,
383
+ });
384
+ // Return branch name (or empty string for detached HEAD)
385
+ return stdout.trim();
386
+ }
387
+ catch (error) {
388
+ // Wrap error with context
389
+ throw new Error(`Failed to get current branch: ${error}`);
390
+ }
391
+ }
392
+ /**
393
+ * Retrieves the abbreviated (short) SHA-1 hash of the current HEAD commit.
394
+ *
395
+ * This function returns a shortened version of the commit hash (typically 7 characters),
396
+ * which is:
397
+ * - Human-readable and easier to reference
398
+ * - Suitable for build metadata in semantic versions
399
+ * - Commonly used in CI/CD for build identification
400
+ * - Still unique enough for most repositories
401
+ *
402
+ * The short SHA is git's default abbreviated format and balances uniqueness with brevity.
403
+ *
404
+ * @param options - Git operation options, primarily for specifying working directory.
405
+ *
406
+ * @returns Promise resolving to the abbreviated commit SHA.
407
+ * Typically 7 characters (e.g., 'abc1234').
408
+ * Length may vary based on repository size to ensure uniqueness.
409
+ *
410
+ * @throws {Error} If git command fails:
411
+ * - Not in a git repository
412
+ * - No commits exist (empty repository)
413
+ * - Permissions issues
414
+ */
415
+ export async function getCurrentCommitShortSha(options = {}) {
416
+ // Resolve working directory
417
+ const cwd = options.cwd || process.cwd();
418
+ try {
419
+ // Get abbreviated commit SHA
420
+ // rev-parse: Resolve git revision to commit hash
421
+ // --short: Return abbreviated version (typically 7 chars)
422
+ // HEAD: The current commit
423
+ const { stdout } = await execa("git", ["rev-parse", "--short", "HEAD"], {
424
+ cwd,
425
+ });
426
+ // Return the short SHA
427
+ return stdout.trim();
428
+ }
429
+ catch (error) {
430
+ // Wrap error with context
431
+ throw new Error(`Failed to get current commit SHA: ${error}`);
432
+ }
433
+ }
434
+ /**
435
+ * Stages all changed files in the working directory for the next commit.
436
+ *
437
+ * This function executes `git add .` which stages:
438
+ * - All modified tracked files
439
+ * - All new untracked files
440
+ * - All deleted files
441
+ *
442
+ * **Warning**: This stages **everything** in the working directory. Use with caution
443
+ * in interactive environments. For selective staging, use git commands directly.
444
+ *
445
+ * @param options - Git operation options, primarily for specifying working directory.
446
+ *
447
+ * @returns Promise that resolves when all files are successfully staged.
448
+ *
449
+ * @throws {Error} If git add command fails:
450
+ * - Not in a git repository
451
+ * - Permissions issues
452
+ * - Invalid .gitignore patterns
453
+ */
454
+ export async function addChangedFiles(options = {}) {
455
+ // Resolve working directory
456
+ const cwd = options.cwd || process.cwd();
457
+ try {
458
+ // Stage all changes in the working directory
459
+ // '.': Current directory and all subdirectories
460
+ await execa("git", ["add", "."], { cwd });
461
+ }
462
+ catch (error) {
463
+ // Wrap error with context
464
+ throw new Error(`Failed to add changed files: ${error}`);
465
+ }
466
+ }
467
+ /**
468
+ * Creates a git commit with the specified message using currently staged changes.
469
+ * Files must be staged first (via `git add`). Follows Conventional Commits format.
470
+ * @param message - The commit message (e.g., 'feat: description', 'fix: description')
471
+ * @param options - Git operation options
472
+ * @returns Promise that resolves when commit is created
473
+ * @throws {Error} If commit fails (no staged changes, no git user, empty message, etc.)
474
+ */
475
+ export async function commitChanges(message, options = {}) {
476
+ // Resolve working directory
477
+ const cwd = options.cwd || process.cwd();
478
+ try {
479
+ // Create commit with staged changes
480
+ // -m: Specify commit message inline
481
+ await execa("git", ["commit", "-m", message], { cwd });
482
+ }
483
+ catch (error) {
484
+ // Wrap error with context
485
+ throw new Error(`Failed to commit changes: ${error}`);
486
+ }
487
+ }
488
+ /**
489
+ * Pushes local commits to the remote repository.
490
+ *
491
+ * This function uploads all commits from the current branch that don't exist
492
+ * on the remote. It uses `git push` without arguments, which:
493
+ * - Pushes the current branch to its configured upstream
494
+ * - Only pushes commits (use `pushTags()` for tags)
495
+ * - Requires network access and authentication
496
+ *
497
+ * @param options - Git operation options, primarily for specifying working directory.
498
+ *
499
+ * @returns Promise that resolves when commits are successfully pushed.
500
+ *
501
+ * @throws {Error} If push fails:
502
+ * - No remote configured
503
+ * - No upstream branch set
504
+ * - Authentication failure
505
+ * - Network issues
506
+ * - Remote rejects (e.g., force push needed, protected branch)
507
+ */
508
+ export async function pushCommits(options = {}) {
509
+ // Resolve working directory
510
+ const cwd = options.cwd || process.cwd();
511
+ try {
512
+ // Push commits to remote
513
+ // No arguments: Push current branch to configured upstream
514
+ await execa("git", ["push"], { cwd });
515
+ }
516
+ catch (error) {
517
+ // Wrap error with context
518
+ throw new Error(`Failed to push commits: ${error}`);
519
+ }
520
+ }
521
+ /**
522
+ * Checks if there are any changes in the working directory or staging area.
523
+ *
524
+ * This function is similar to `isWorkingDirectoryClean()` but returns the opposite
525
+ * boolean value. It's useful when you want to check if there's work to commit.
526
+ *
527
+ * Uses `git status --porcelain` to detect:
528
+ * - Modified tracked files
529
+ * - New untracked files
530
+ * - Deleted files
531
+ * - Staged changes
532
+ *
533
+ * @param options - Git operation options, primarily for specifying working directory.
534
+ *
535
+ * @returns Promise resolving to:
536
+ * - `true`: There are changes (modified, staged, untracked files)
537
+ * - `false`: Working directory is clean OR git command failed
538
+ *
539
+ * @throws {Error} If git status command fails.
540
+ * Unlike `isWorkingDirectoryClean()`, this function throws on errors.
541
+ */
542
+ export async function hasChangesToCommit(options = {}) {
543
+ // Resolve working directory
544
+ const cwd = options.cwd || process.cwd();
545
+ try {
546
+ // Get machine-readable status output
547
+ // --porcelain: Stable, easy-to-parse format
548
+ const { stdout } = await execa("git", ["status", "--porcelain"], {
549
+ cwd,
550
+ });
551
+ // If output is not empty, there are changes
552
+ // Returns true if changes exist, false if clean
553
+ return stdout.trim().length > 0;
554
+ }
555
+ catch (error) {
556
+ // Throw on error (unlike isWorkingDirectoryClean which returns false)
557
+ throw new Error(`Failed to check git status: ${error}`);
558
+ }
559
+ }
560
+ export async function getCurrentRepoUrl(options = {}) {
561
+ // Resolve working directory
562
+ const cwd = options.cwd || process.cwd();
563
+ try {
564
+ // Get the URL of the 'origin' remote
565
+ const { stdout } = await execa("git", ["remote", "get-url", "origin"], {
566
+ cwd,
567
+ });
568
+ return stdout.trim();
569
+ }
570
+ catch (error) {
571
+ throw new Error(`Failed to get repository URL: ${error}`);
572
+ }
573
+ }
574
+ /**
575
+ * Parses a git repository URL (SSH or HTTP/HTTPS) and extracts its components.
576
+ *
577
+ * Supports multiple URL formats:
578
+ * - SSH: `git@github.com:owner/repo.git`
579
+ * - HTTPS: `https://github.com/owner/repo.git`
580
+ * - HTTP: `http://github.com/owner/repo.git`
581
+ *
582
+ * @param repoUrl - The repository URL to parse
583
+ * @returns Object containing host, owner, and repo name
584
+ * @throws {Error} If URL format is invalid or cannot be parsed
585
+ * @internal
586
+ */
587
+ export function parseRepoUrl(repoUrl) {
588
+ // Handle SSH format: git@github.com:owner/repo.git
589
+ const sshMatch = repoUrl.match(/^git@([^:]+):(.+?)\/([^/]+?)(\.git)?$/);
590
+ if (sshMatch) {
591
+ return {
592
+ host: sshMatch[1],
593
+ owner: sshMatch[2],
594
+ repo: sshMatch[3],
595
+ };
596
+ }
597
+ // Handle HTTP/HTTPS format: https://github.com/owner/repo.git
598
+ const httpsMatch = repoUrl.match(/^https?:\/\/([^/]+)\/(.+?)\/([^/]+?)(\.git)?$/);
599
+ if (httpsMatch) {
600
+ return {
601
+ host: httpsMatch[1],
602
+ owner: httpsMatch[2],
603
+ repo: httpsMatch[3],
604
+ };
605
+ }
606
+ throw new Error(`Invalid repository URL format: ${repoUrl}`);
607
+ }
608
+ /**
609
+ * Retrieves the current repository URL and converts it to HTTPS format.
610
+ *
611
+ * This function is useful for:
612
+ * - Generating consistent HTTPS URLs for documentation
613
+ * - Creating web links to the repository
614
+ * - CI/CD systems that prefer HTTPS over SSH
615
+ *
616
+ * Converts SSH URLs (git@github.com:owner/repo.git) to HTTPS format
617
+ * (https://github.com/owner/repo.git).
618
+ *
619
+ * @param options - Git operation options, primarily for specifying working directory.
620
+ *
621
+ * @returns Promise resolving to the repository URL in HTTPS format.
622
+ *
623
+ * @throws {Error} If:
624
+ * - Unable to get remote URL
625
+ * - URL format is invalid
626
+ * - Not in a git repository
627
+ */
628
+ export async function getRepoUrlAsHttps(options = {}) {
629
+ const repoUrl = await getCurrentRepoUrl(options);
630
+ const { host, owner, repo } = parseRepoUrl(repoUrl);
631
+ return `https://${host}/${owner}/${repo}.git`;
632
+ }
633
+ /**
634
+ * Extracts the hostname from the current repository's remote URL.
635
+ *
636
+ * Returns the hosting service domain (e.g., 'github.com', 'gitlab.com',
637
+ * 'bitbucket.org', or custom Git server hostname).
638
+ *
639
+ * Supports both SSH and HTTP/HTTPS URL formats.
640
+ *
641
+ * @param options - Git operation options, primarily for specifying working directory.
642
+ *
643
+ * @returns Promise resolving to the repository host (e.g., 'github.com').
644
+ *
645
+ * @throws {Error} If:
646
+ * - Unable to get remote URL
647
+ * - URL format is invalid
648
+ * - Not in a git repository
649
+ */
650
+ export async function getRepoHost(options = {}) {
651
+ const repoUrl = await getCurrentRepoUrl(options);
652
+ const { host } = parseRepoUrl(repoUrl);
653
+ return host;
654
+ }
655
+ /**
656
+ * Extracts the repository owner/organization name from the current repository's remote URL.
657
+ *
658
+ * Returns the account or organization that owns the repository.
659
+ * For example:
660
+ * - `git@github.com:microsoft/vscode.git` → 'microsoft'
661
+ * - `https://github.com/facebook/react.git` → 'facebook'
662
+ *
663
+ * Supports both SSH and HTTP/HTTPS URL formats.
664
+ *
665
+ * @param options - Git operation options, primarily for specifying working directory.
666
+ *
667
+ * @returns Promise resolving to the repository owner name.
668
+ *
669
+ * @throws {Error} If:
670
+ * - Unable to get remote URL
671
+ * - URL format is invalid
672
+ * - Not in a git repository
673
+ */
674
+ export async function getRepoOwner(options = {}) {
675
+ const repoUrl = await getCurrentRepoUrl(options);
676
+ const { owner } = parseRepoUrl(repoUrl);
677
+ return owner;
678
+ }
679
+ /**
680
+ * Extracts the repository name from the current repository's remote URL.
681
+ *
682
+ * Returns the name of the repository without the owner prefix or .git suffix.
683
+ * For example:
684
+ * - `git@github.com:microsoft/vscode.git` → 'vscode'
685
+ * - `https://github.com/facebook/react.git` → 'react'
686
+ *
687
+ * Supports both SSH and HTTP/HTTPS URL formats.
688
+ *
689
+ * @param options - Git operation options, primarily for specifying working directory.
690
+ *
691
+ * @returns Promise resolving to the repository name.
692
+ *
693
+ * @throws {Error} If:
694
+ * - Unable to get remote URL
695
+ * - URL format is invalid
696
+ * - Not in a git repository
697
+ */
698
+ export async function getRepoName(options = {}) {
699
+ const repoUrl = await getCurrentRepoUrl(options);
700
+ const { repo } = parseRepoUrl(repoUrl);
701
+ return repo;
702
+ }