git-watchtower 1.6.0 → 1.7.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,416 @@
1
+ /**
2
+ * Git command execution module
3
+ * Provides safe, timeout-aware git command execution
4
+ */
5
+
6
+ const { execFile } = require('child_process');
7
+ const { GitError } = require('../utils/errors');
8
+ const { withTimeout } = require('../utils/async');
9
+
10
+ // Default timeout for git operations (30 seconds)
11
+ const DEFAULT_TIMEOUT = 30000;
12
+
13
+ // Longer timeout for fetch operations (60 seconds)
14
+ const FETCH_TIMEOUT = 60000;
15
+
16
+ /**
17
+ * Execute a git command safely using execFile (no shell).
18
+ * @param {string | string[]} args - Git arguments as an array (e.g. ['log', '--oneline'])
19
+ * or a legacy command string (e.g. 'git --version') for backwards compatibility
20
+ * @param {Object} [options] - Execution options
21
+ * @param {number} [options.timeout] - Command timeout in ms
22
+ * @param {string} [options.cwd] - Working directory
23
+ * @returns {Promise<{stdout: string, stderr: string}>}
24
+ * @throws {GitError}
25
+ */
26
+ async function execGit(args, options = {}) {
27
+ const { timeout = DEFAULT_TIMEOUT, cwd = process.cwd() } = options;
28
+
29
+ // Backwards compatibility: accept a full command string for
30
+ // simple constant commands (no user-controlled data).
31
+ if (typeof args === 'string') {
32
+ const parts = /** @type {string} */ (args).split(/\s+/);
33
+ // Strip leading 'git' if present so callers can pass 'git --version'
34
+ if (parts[0] === 'git') {
35
+ args = parts.slice(1);
36
+ } else {
37
+ args = parts;
38
+ }
39
+ }
40
+
41
+ const command = `git ${args.join(' ')}`;
42
+
43
+ try {
44
+ const promise = new Promise((resolve, reject) => {
45
+ execFile('git', args, {
46
+ cwd,
47
+ maxBuffer: 10 * 1024 * 1024, // 10MB buffer for large outputs
48
+ }, (error, stdout, stderr) => {
49
+ if (error) {
50
+ error.stderr = stderr;
51
+ reject(error);
52
+ } else {
53
+ resolve({ stdout, stderr });
54
+ }
55
+ });
56
+ });
57
+
58
+ const result = await withTimeout(
59
+ promise,
60
+ timeout,
61
+ `Git command timed out after ${timeout}ms: ${command}`
62
+ );
63
+
64
+ return {
65
+ stdout: result.stdout.trim(),
66
+ stderr: result.stderr.trim(),
67
+ };
68
+ } catch (error) {
69
+ // Handle timeout error
70
+ if (error.message && error.message.includes('timed out')) {
71
+ throw new GitError(error.message, 'GIT_TIMEOUT', { command });
72
+ }
73
+
74
+ // Handle exec error
75
+ throw GitError.fromExecError(error, command, error.stderr);
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Execute git command silently (suppress errors)
81
+ * @param {string | string[]} command - Git arguments (array or legacy string)
82
+ * @param {Object} [options] - Execution options
83
+ * @returns {Promise<{stdout: string, stderr: string}|null>}
84
+ */
85
+ async function execGitSilent(command, options = {}) {
86
+ try {
87
+ return await execGit(command, options);
88
+ } catch (error) {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Check if git is available
95
+ * @returns {Promise<boolean>}
96
+ */
97
+ async function isGitAvailable() {
98
+ try {
99
+ await execGit(['--version'], { timeout: 5000 });
100
+ return true;
101
+ } catch (error) {
102
+ return false;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Check if current directory is a git repository
108
+ * @param {string} [cwd] - Working directory
109
+ * @returns {Promise<boolean>}
110
+ */
111
+ async function isGitRepository(cwd) {
112
+ try {
113
+ await execGit(['rev-parse', '--git-dir'], { cwd, timeout: 5000 });
114
+ return true;
115
+ } catch (error) {
116
+ return false;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Get list of remotes
122
+ * @param {string} [cwd] - Working directory
123
+ * @returns {Promise<string[]>}
124
+ */
125
+ async function getRemotes(cwd) {
126
+ try {
127
+ const { stdout } = await execGit(['remote'], { cwd, timeout: 5000 });
128
+ return stdout.split('\n').filter(Boolean);
129
+ } catch (error) {
130
+ return [];
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Check if a specific remote exists
136
+ * @param {string} remoteName - Remote name to check
137
+ * @param {string} [cwd] - Working directory
138
+ * @returns {Promise<boolean>}
139
+ */
140
+ async function remoteExists(remoteName, cwd) {
141
+ const remotes = await getRemotes(cwd);
142
+ return remotes.includes(remoteName);
143
+ }
144
+
145
+ /**
146
+ * Fetch from remote with timeout
147
+ * @param {string} [remoteName='origin'] - Remote name
148
+ * @param {Object} [options] - Fetch options
149
+ * @param {boolean} [options.prune=true] - Prune deleted branches
150
+ * @param {boolean} [options.all=true] - Fetch all branches
151
+ * @param {string} [options.cwd] - Working directory
152
+ * @returns {Promise<{success: boolean, error?: GitError}>}
153
+ */
154
+ async function fetch(remoteName = 'origin', options = {}) {
155
+ const { prune = true, all = true, cwd } = options;
156
+
157
+ const args = ['fetch'];
158
+ if (all) args.push('--all');
159
+ if (prune) args.push('--prune');
160
+
161
+ try {
162
+ await execGit(args, { cwd, timeout: FETCH_TIMEOUT });
163
+ return { success: true };
164
+ } catch (error) {
165
+ return { success: false, error };
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Pull from remote
171
+ * @param {string} remoteName - Remote name
172
+ * @param {string} branchName - Branch to pull
173
+ * @param {string} [cwd] - Working directory
174
+ * @returns {Promise<{success: boolean, error?: GitError}>}
175
+ */
176
+ async function pull(remoteName, branchName, cwd) {
177
+ try {
178
+ await execGit(['pull', remoteName, branchName], {
179
+ cwd,
180
+ timeout: FETCH_TIMEOUT,
181
+ });
182
+ return { success: true };
183
+ } catch (error) {
184
+ return { success: false, error };
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Get commit log for a branch
190
+ * @param {string} branchName - Branch name
191
+ * @param {Object} [options] - Log options
192
+ * @param {number} [options.count=10] - Number of commits
193
+ * @param {string} [options.format] - Format string
194
+ * @param {string} [options.cwd] - Working directory
195
+ * @returns {Promise<string>}
196
+ */
197
+ async function log(branchName, options = {}) {
198
+ const {
199
+ count = 10,
200
+ format = '%h|%s|%cr',
201
+ cwd,
202
+ } = options;
203
+
204
+ const { stdout } = await execGit(
205
+ ['log', branchName, '-n', String(count), `--format=${format}`],
206
+ { cwd }
207
+ );
208
+
209
+ return stdout;
210
+ }
211
+
212
+ /**
213
+ * Get commit count by day for sparkline
214
+ * @param {string} branchName - Branch name
215
+ * @param {number} [days=7] - Number of days
216
+ * @param {string} [cwd] - Working directory
217
+ * @returns {Promise<number[]>} - Array of commit counts per day
218
+ */
219
+ async function getCommitsByDay(branchName, days = 7, cwd) {
220
+ const counts = new Array(days).fill(0);
221
+
222
+ try {
223
+ const { stdout } = await execGit(
224
+ ['log', branchName, '--format=%ci', `--since=${days} days ago`],
225
+ { cwd, timeout: 10000 }
226
+ );
227
+
228
+ if (!stdout) return counts;
229
+
230
+ const today = new Date();
231
+ today.setHours(0, 0, 0, 0);
232
+
233
+ for (const line of stdout.split('\n').filter(Boolean)) {
234
+ const commitDate = new Date(line);
235
+ commitDate.setHours(0, 0, 0, 0);
236
+ const daysDiff = Math.floor((today.getTime() - commitDate.getTime()) / (1000 * 60 * 60 * 24));
237
+ if (daysDiff >= 0 && daysDiff < days) {
238
+ counts[days - 1 - daysDiff]++;
239
+ }
240
+ }
241
+ } catch (error) {
242
+ // Return zeros on error
243
+ }
244
+
245
+ return counts;
246
+ }
247
+
248
+ /**
249
+ * Stash all uncommitted changes (tracked and untracked)
250
+ * @param {Object} [options] - Stash options
251
+ * @param {string} [options.message] - Optional stash message
252
+ * @param {boolean} [options.includeUntracked=true] - Include untracked files
253
+ * @param {string} [options.cwd] - Working directory
254
+ * @returns {Promise<{success: boolean, error?: GitError}>}
255
+ */
256
+ async function stash(options = {}) {
257
+ const { message, includeUntracked = true, cwd } = options;
258
+
259
+ const args = ['stash', 'push'];
260
+ if (includeUntracked) args.push('--include-untracked');
261
+ if (message) {
262
+ args.push('-m', message);
263
+ }
264
+
265
+ try {
266
+ const result = await execGit(args, { cwd });
267
+ // git stash returns "No local changes to save" if there's nothing to stash
268
+ if (result.stdout.includes('No local changes')) {
269
+ return { success: false, error: new GitError('No local changes to stash', 'GIT_STASH_EMPTY') };
270
+ }
271
+ return { success: true };
272
+ } catch (error) {
273
+ return {
274
+ success: false,
275
+ error: error instanceof GitError ? error : GitError.fromExecError(error, 'stash'),
276
+ };
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Pop the most recent stash entry
282
+ * @param {Object} [options] - Options
283
+ * @param {string} [options.cwd] - Working directory
284
+ * @returns {Promise<{success: boolean, error?: GitError}>}
285
+ */
286
+ async function stashPop(options = {}) {
287
+ const { cwd } = options;
288
+
289
+ try {
290
+ await execGit(['stash', 'pop'], { cwd });
291
+ return { success: true };
292
+ } catch (error) {
293
+ return {
294
+ success: false,
295
+ error: error instanceof GitError ? error : GitError.fromExecError(error, 'stash pop'),
296
+ };
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Check if working directory has uncommitted changes
302
+ * @param {string} [cwd] - Working directory
303
+ * @returns {Promise<boolean>}
304
+ */
305
+ async function hasUncommittedChanges(cwd) {
306
+ try {
307
+ const { stdout } = await execGit(['status', '--porcelain'], {
308
+ cwd,
309
+ timeout: 5000,
310
+ });
311
+ return stdout.length > 0;
312
+ } catch (error) {
313
+ return false;
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Get changed files for a branch compared to another
319
+ * @param {string} branchName - Branch to compare
320
+ * @param {string} [baseBranch] - Base branch (defaults to current)
321
+ * @param {string} [cwd] - Working directory
322
+ * @returns {Promise<string[]>}
323
+ */
324
+ async function getChangedFiles(branchName, baseBranch = 'HEAD', cwd) {
325
+ try {
326
+ const { stdout } = await execGit(
327
+ ['diff', '--name-only', `${baseBranch}...${branchName}`],
328
+ { cwd }
329
+ );
330
+ return stdout.split('\n').filter(Boolean);
331
+ } catch (error) {
332
+ return [];
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Parse git diff --stat output into added/deleted line counts
338
+ * @param {string} diffStatOutput - Output from `git diff --stat`
339
+ * @returns {{added: number, deleted: number}}
340
+ */
341
+ function parseDiffStats(diffStatOutput) {
342
+ // Parse the summary line: "X files changed, Y insertions(+), Z deletions(-)"
343
+ const match = diffStatOutput.match(/(\d+) insertions?\(\+\).*?(\d+) deletions?\(-\)/);
344
+ if (match) {
345
+ return { added: parseInt(match[1], 10), deleted: parseInt(match[2], 10) };
346
+ }
347
+ // Try to match just insertions or just deletions
348
+ const insertMatch = diffStatOutput.match(/(\d+) insertions?\(\+\)/);
349
+ const deleteMatch = diffStatOutput.match(/(\d+) deletions?\(-\)/);
350
+ return {
351
+ added: insertMatch ? parseInt(insertMatch[1], 10) : 0,
352
+ deleted: deleteMatch ? parseInt(deleteMatch[1], 10) : 0,
353
+ };
354
+ }
355
+
356
+ /**
357
+ * Get diff stats between two commits
358
+ * @param {string} fromCommit - Starting commit
359
+ * @param {string} [toCommit='HEAD'] - Ending commit
360
+ * @param {Object} [options] - Options
361
+ * @param {string} [options.cwd] - Working directory
362
+ * @returns {Promise<{added: number, deleted: number}>}
363
+ */
364
+ async function getDiffStats(fromCommit, toCommit = 'HEAD', options = {}) {
365
+ try {
366
+ const { stdout } = await execGit(['diff', '--stat', `${fromCommit}..${toCommit}`], options);
367
+ return parseDiffStats(stdout);
368
+ } catch (e) {
369
+ return { added: 0, deleted: 0 };
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Delete a local branch
375
+ * @param {string} branchName - Branch to delete
376
+ * @param {Object} [options] - Options
377
+ * @param {boolean} [options.force=false] - Force delete (git branch -D) even if not fully merged
378
+ * @param {string} [options.cwd] - Working directory
379
+ * @returns {Promise<{success: boolean, error?: GitError}>}
380
+ */
381
+ async function deleteLocalBranch(branchName, options = {}) {
382
+ const { force = false, cwd } = options;
383
+ const flag = force ? '-D' : '-d';
384
+
385
+ try {
386
+ await execGit(['branch', flag, branchName], { cwd });
387
+ return { success: true };
388
+ } catch (error) {
389
+ return {
390
+ success: false,
391
+ error: error instanceof GitError ? error : GitError.fromExecError(error, `branch ${flag}`),
392
+ };
393
+ }
394
+ }
395
+
396
+ module.exports = {
397
+ execGit,
398
+ execGitSilent,
399
+ isGitAvailable,
400
+ isGitRepository,
401
+ getRemotes,
402
+ remoteExists,
403
+ fetch,
404
+ pull,
405
+ log,
406
+ getCommitsByDay,
407
+ hasUncommittedChanges,
408
+ stash,
409
+ stashPop,
410
+ getChangedFiles,
411
+ parseDiffStats,
412
+ getDiffStats,
413
+ deleteLocalBranch,
414
+ DEFAULT_TIMEOUT,
415
+ FETCH_TIMEOUT,
416
+ };
package/src/git/pr.js ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * PR/CI integration for GitHub (gh) and GitLab (glab) CLIs
3
+ * @module git/pr
4
+ */
5
+
6
+ /**
7
+ * Parse GitHub PR JSON response into normalized PR info.
8
+ * @param {Array} prs - Array of PR objects from gh CLI
9
+ * @returns {object|null} Normalized PR info
10
+ */
11
+ function parseGitHubPr(prs) {
12
+ if (!prs || prs.length === 0) return null;
13
+ const pr = prs[0];
14
+ const checks = pr.statusCheckRollup || [];
15
+ const checksPass = checks.length > 0 && checks.every(c => c.conclusion === 'SUCCESS');
16
+ const checksFail = checks.some(c => c.conclusion === 'FAILURE');
17
+ return {
18
+ number: pr.number,
19
+ title: pr.title,
20
+ state: pr.state,
21
+ approved: pr.reviewDecision === 'APPROVED',
22
+ checksPass,
23
+ checksFail,
24
+ checksCount: checks.length,
25
+ };
26
+ }
27
+
28
+ /**
29
+ * Parse GitLab MR JSON response into normalized PR info.
30
+ * @param {Array} mrs - Array of MR objects from glab CLI
31
+ * @returns {object|null} Normalized PR info
32
+ */
33
+ function parseGitLabMr(mrs) {
34
+ if (!mrs || mrs.length === 0) return null;
35
+ const mr = mrs[0];
36
+ return {
37
+ number: mr.iid,
38
+ title: mr.title,
39
+ state: mr.state === 'merged' ? 'MERGED' : mr.state === 'opened' ? 'OPEN' : 'CLOSED',
40
+ approved: false,
41
+ checksPass: false,
42
+ checksFail: false,
43
+ checksCount: 0,
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Parse bulk GitHub PR list into a Map of branch -> PR status.
49
+ * @param {Array} prs - Array of PR objects from gh CLI
50
+ * @returns {Map<string, {state: string, number: number, title: string}>}
51
+ */
52
+ function parseGitHubPrList(prs) {
53
+ const map = new Map();
54
+ if (!prs || !Array.isArray(prs)) return map;
55
+ for (const pr of prs) {
56
+ const existing = map.get(pr.headRefName);
57
+ if (!existing || pr.number > existing.number) {
58
+ map.set(pr.headRefName, {
59
+ state: pr.state,
60
+ number: pr.number,
61
+ title: pr.title,
62
+ });
63
+ }
64
+ }
65
+ return map;
66
+ }
67
+
68
+ /**
69
+ * Parse bulk GitLab MR list into a Map of branch -> PR status.
70
+ * @param {Array} mrs - Array of MR objects from glab CLI
71
+ * @returns {Map<string, {state: string, number: number, title: string}>}
72
+ */
73
+ function parseGitLabMrList(mrs) {
74
+ const map = new Map();
75
+ if (!mrs || !Array.isArray(mrs)) return map;
76
+ for (const mr of mrs) {
77
+ const branchName = mr.source_branch;
78
+ const existing = map.get(branchName);
79
+ if (!existing || mr.iid > existing.number) {
80
+ map.set(branchName, {
81
+ state: mr.state === 'merged' ? 'MERGED' : mr.state === 'opened' ? 'OPEN' : 'CLOSED',
82
+ number: mr.iid,
83
+ title: mr.title,
84
+ });
85
+ }
86
+ }
87
+ return map;
88
+ }
89
+
90
+ /**
91
+ * Default/base branches that should never get "merged" treatment.
92
+ */
93
+ const BASE_BRANCH_RE = /^(main|master|develop|development|staging|production|trunk|release)$/;
94
+
95
+ /**
96
+ * Check if a branch name is a base/default branch.
97
+ * @param {string} name
98
+ * @returns {boolean}
99
+ */
100
+ function isBaseBranch(name) {
101
+ return BASE_BRANCH_RE.test(name);
102
+ }
103
+
104
+ module.exports = {
105
+ parseGitHubPr,
106
+ parseGitLabMr,
107
+ parseGitHubPrList,
108
+ parseGitLabMrList,
109
+ BASE_BRANCH_RE,
110
+ isBaseBranch,
111
+ };
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Git remote URL parsing and branch URL building
3
+ * @module git/remote
4
+ */
5
+
6
+ /**
7
+ * Parse a git remote URL into { host, path } components.
8
+ * Supports SSH (git@host:path), HTTPS, and ssh:// protocol formats.
9
+ * @param {string} remoteUrl - The raw remote URL from git
10
+ * @returns {{ host: string, path: string } | null}
11
+ */
12
+ function parseRemoteUrl(remoteUrl) {
13
+ const url = (remoteUrl || '').trim();
14
+
15
+ // SSH format: git@host:user/repo.git
16
+ const sshMatch = url.match(/^[\w-]+@([^:]+):(.+?)(?:\.git)?$/);
17
+ if (sshMatch) return { host: sshMatch[1], path: sshMatch[2] };
18
+
19
+ // HTTPS/HTTP format: https://host/path.git
20
+ const httpMatch = url.match(/^https?:\/\/([^/]+)\/(.+?)(?:\.git)?$/);
21
+ if (httpMatch) return { host: httpMatch[1], path: httpMatch[2] };
22
+
23
+ // ssh:// format: ssh://git@host/user/repo.git or ssh://git@host:port/user/repo.git
24
+ const sshProtoMatch = url.match(/^ssh:\/\/[\w-]+@([^/:]+)(?::\d+)?\/(.+?)(?:\.git)?$/);
25
+ if (sshProtoMatch) return { host: sshProtoMatch[1], path: sshProtoMatch[2] };
26
+
27
+ return null;
28
+ }
29
+
30
+ /**
31
+ * Build a branch URL for the appropriate git hosting service.
32
+ * Each service has its own URL format for viewing branches.
33
+ * @param {string} baseUrl - Repository base URL (e.g., https://github.com/user/repo)
34
+ * @param {string} host - Hostname of the git hosting service
35
+ * @param {string} branchName - Name of the branch
36
+ * @returns {string}
37
+ */
38
+ function buildBranchUrl(baseUrl, host, branchName) {
39
+ const branch = encodeURIComponent(branchName);
40
+
41
+ // Azure DevOps: dev.azure.com/org/project/_git/repo or org.visualstudio.com
42
+ if (host === 'dev.azure.com' || host.endsWith('.visualstudio.com')) {
43
+ return `${baseUrl}?version=GB${branch}`;
44
+ }
45
+
46
+ // Bitbucket Cloud
47
+ if (host === 'bitbucket.org') {
48
+ return `${baseUrl}/src/${branch}`;
49
+ }
50
+
51
+ // AWS CodeCommit
52
+ if (host.match(/codecommit\..+\.amazonaws\.com/)) {
53
+ return `${baseUrl}/browse/refs/heads/${branch}`;
54
+ }
55
+
56
+ // SourceHut
57
+ if (host === 'git.sr.ht') {
58
+ return `${baseUrl}/tree/${branch}`;
59
+ }
60
+
61
+ // GitHub, GitLab, Codeberg, Gitea, Forgejo, Gogs, and self-hosted instances
62
+ // All use /tree/<branch>
63
+ return `${baseUrl}/tree/${branch}`;
64
+ }
65
+
66
+ /**
67
+ * Detect the git hosting platform from a web URL.
68
+ * @param {string|null} webUrl - Web URL of the repository
69
+ * @returns {string|null} Platform name: 'github' | 'gitlab' | 'bitbucket' | 'azure' | null
70
+ */
71
+ function detectPlatform(webUrl) {
72
+ if (!webUrl) return null;
73
+ try {
74
+ const host = new URL(webUrl).hostname;
75
+ if (host === 'github.com' || host.includes('github')) return 'github';
76
+ if (host === 'gitlab.com' || host.includes('gitlab')) return 'gitlab';
77
+ if (host === 'bitbucket.org' || host.includes('bitbucket')) return 'bitbucket';
78
+ if (host === 'dev.azure.com' || host.includes('visualstudio.com')) return 'azure';
79
+ } catch (e) { /* ignore */ }
80
+ return 'github'; // default assumption for self-hosted
81
+ }
82
+
83
+ /**
84
+ * Build a web URL for a repository from a parsed remote.
85
+ * Handles Azure DevOps SSH special case.
86
+ * @param {{ host: string, path: string }} parsed - Parsed remote URL
87
+ * @param {string|null} branchName - Optional branch name
88
+ * @returns {string|null}
89
+ */
90
+ function buildWebUrl(parsed, branchName) {
91
+ if (!parsed) return null;
92
+
93
+ let baseUrl;
94
+
95
+ // Azure DevOps SSH uses org@ssh.dev.azure.com:v3/org/project/repo
96
+ if (parsed.host === 'ssh.dev.azure.com') {
97
+ const parts = parsed.path.replace(/^v3\//, '').split('/');
98
+ if (parts.length >= 3) {
99
+ baseUrl = `https://dev.azure.com/${parts[0]}/${parts[1]}/_git/${parts.slice(2).join('/')}`;
100
+ if (branchName) return buildBranchUrl(baseUrl, 'dev.azure.com', branchName);
101
+ return baseUrl;
102
+ }
103
+ return null;
104
+ }
105
+
106
+ baseUrl = `https://${parsed.host}/${parsed.path}`;
107
+ if (branchName) return buildBranchUrl(baseUrl, parsed.host, branchName);
108
+ return baseUrl;
109
+ }
110
+
111
+ /**
112
+ * Extract a Claude Code session URL from a commit message body.
113
+ * @param {string} commitBody - Full commit message body
114
+ * @returns {string|null} Session URL or null
115
+ */
116
+ function extractSessionUrl(commitBody) {
117
+ const match = (commitBody || '').match(/https:\/\/claude\.ai\/code\/session_[\w]+/);
118
+ return match ? match[0] : null;
119
+ }
120
+
121
+ module.exports = {
122
+ parseRemoteUrl,
123
+ buildBranchUrl,
124
+ detectPlatform,
125
+ buildWebUrl,
126
+ extractSessionUrl,
127
+ };