@bretwardjames/ghp-core 0.2.0-beta.0 → 0.2.0-beta.2

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.
package/dist/index.cjs CHANGED
@@ -36,10 +36,13 @@ __export(index_exports, {
36
36
  checkoutBranch: () => checkoutBranch,
37
37
  computeSettingsDiff: () => computeSettingsDiff,
38
38
  createBranch: () => createBranch,
39
+ createWorktree: () => createWorktree,
39
40
  detectRepository: () => detectRepository,
41
+ extractIssueNumberFromBranch: () => extractIssueNumberFromBranch,
40
42
  fetchOrigin: () => fetchOrigin,
41
43
  formatConflict: () => formatConflict,
42
44
  generateBranchName: () => generateBranchName,
45
+ generateWorktreePath: () => generateWorktreePath,
43
46
  getAllBranches: () => getAllBranches,
44
47
  getCommitsAhead: () => getCommitsAhead,
45
48
  getCommitsBehind: () => getCommitsBehind,
@@ -49,9 +52,11 @@ __export(index_exports, {
49
52
  getLocalBranches: () => getLocalBranches,
50
53
  getRemoteBranches: () => getRemoteBranches,
51
54
  getRepositoryRoot: () => getRepositoryRoot,
55
+ getWorktreeForBranch: () => getWorktreeForBranch,
52
56
  hasDifferences: () => hasDifferences,
53
57
  hasUncommittedChanges: () => hasUncommittedChanges,
54
58
  isGitRepository: () => isGitRepository,
59
+ listWorktrees: () => listWorktrees,
55
60
  normalizeVSCodeSettings: () => normalizeVSCodeSettings,
56
61
  parseBranchLink: () => parseBranchLink,
57
62
  parseGitHubUrl: () => parseGitHubUrl,
@@ -59,6 +64,7 @@ __export(index_exports, {
59
64
  pullLatest: () => pullLatest,
60
65
  queries: () => queries_exports,
61
66
  removeBranchLinkFromBody: () => removeBranchLinkFromBody,
67
+ removeWorktree: () => removeWorktree,
62
68
  resolveConflicts: () => resolveConflicts,
63
69
  sanitizeForBranchName: () => sanitizeForBranchName,
64
70
  setBranchLinkInBody: () => setBranchLinkInBody,
@@ -66,7 +72,8 @@ __export(index_exports, {
66
72
  toVSCodeSettings: () => toVSCodeSettings,
67
73
  useCli: () => useCli,
68
74
  useCustom: () => useCustom,
69
- useVSCode: () => useVSCode
75
+ useVSCode: () => useVSCode,
76
+ worktreeExists: () => worktreeExists
70
77
  });
71
78
  module.exports = __toCommonJS(index_exports);
72
79
 
@@ -405,7 +412,7 @@ var REMOVE_LABELS_MUTATION = `
405
412
  var ISSUES_WITH_LABEL_QUERY = `
406
413
  query($owner: String!, $name: String!, $labels: [String!]) {
407
414
  repository(owner: $owner, name: $name) {
408
- issues(first: 10, labels: $labels, states: [OPEN]) {
415
+ issues(first: 10, labels: $labels, states: [OPEN, CLOSED]) {
409
416
  nodes {
410
417
  number
411
418
  }
@@ -1158,6 +1165,7 @@ var BranchLinker = class {
1158
1165
  // src/git-utils.ts
1159
1166
  var import_child_process = require("child_process");
1160
1167
  var import_util = require("util");
1168
+ var import_os = require("os");
1161
1169
 
1162
1170
  // src/url-parser.ts
1163
1171
  function parseGitHubUrl(url) {
@@ -1210,6 +1218,28 @@ function buildOrgProjectUrl(org, projectNumber) {
1210
1218
  }
1211
1219
 
1212
1220
  // src/git-utils.ts
1221
+ function sanitizeForPath(input) {
1222
+ return String(input).replace(/\.\./g, "_").replace(/[;&|`$(){}[\]<>!]/g, "").replace(/\s+/g, "-").replace(/[^a-zA-Z0-9_\-./]/g, "_");
1223
+ }
1224
+ function validateBranchName(branch) {
1225
+ if (!branch || branch.trim().length === 0) {
1226
+ throw new Error("Branch name cannot be empty");
1227
+ }
1228
+ const dangerousChars = /[`$\\!;|&<>(){}[\]'"]/;
1229
+ if (dangerousChars.test(branch)) {
1230
+ throw new Error(`Branch name contains invalid characters: ${branch}`);
1231
+ }
1232
+ const gitInvalidChars = /[\s~^:?*\[\\]/;
1233
+ if (gitInvalidChars.test(branch)) {
1234
+ throw new Error(`Branch name contains invalid git characters: ${branch}`);
1235
+ }
1236
+ if (branch.includes("..")) {
1237
+ throw new Error(`Branch name cannot contain '..': ${branch}`);
1238
+ }
1239
+ if (/^[./]|[./]$/.test(branch)) {
1240
+ throw new Error(`Branch name cannot start or end with '/' or '.': ${branch}`);
1241
+ }
1242
+ }
1213
1243
  var execAsync = (0, import_util.promisify)(import_child_process.exec);
1214
1244
  async function execGit(command, options = {}) {
1215
1245
  const cwd = options.cwd || process.cwd();
@@ -1311,6 +1341,25 @@ function generateBranchName(pattern, vars, maxLength = 60) {
1311
1341
  }
1312
1342
  return branch;
1313
1343
  }
1344
+ function extractIssueNumberFromBranch(branchName) {
1345
+ const patterns = [
1346
+ /\/(\d+)-/,
1347
+ // user/123-title
1348
+ /^(\d+)-/,
1349
+ // 123-title
1350
+ /-(\d+)-/,
1351
+ // feature-123-title
1352
+ /[/#](\d+)$/
1353
+ // ends with #123 or /123
1354
+ ];
1355
+ for (const pattern of patterns) {
1356
+ const match = branchName.match(pattern);
1357
+ if (match) {
1358
+ return parseInt(match[1], 10);
1359
+ }
1360
+ }
1361
+ return null;
1362
+ }
1314
1363
  async function getLocalBranches(options = {}) {
1315
1364
  try {
1316
1365
  const { stdout } = await execGit('git branch --format="%(refname:short)"', options);
@@ -1357,6 +1406,90 @@ async function getDefaultBranch(options = {}) {
1357
1406
  }
1358
1407
  return "master";
1359
1408
  }
1409
+ function validatePath(path) {
1410
+ if (!path || path.trim().length === 0) {
1411
+ throw new Error("Path cannot be empty");
1412
+ }
1413
+ const dangerousChars = /[`$;|&<>(){}[\]'"\n\r]/;
1414
+ if (dangerousChars.test(path)) {
1415
+ throw new Error(`Path contains invalid characters: ${path}`);
1416
+ }
1417
+ }
1418
+ async function createWorktree(worktreePath, branch, options = {}) {
1419
+ validateBranchName(branch);
1420
+ validatePath(worktreePath);
1421
+ const localExists = await branchExists(branch, options);
1422
+ if (localExists) {
1423
+ await execGit(`git worktree add "${worktreePath}" "${branch}"`, options);
1424
+ } else {
1425
+ try {
1426
+ await execGit(`git worktree add "${worktreePath}" -b "${branch}" "origin/${branch}"`, options);
1427
+ } catch {
1428
+ await execGit(`git worktree add -b "${branch}" "${worktreePath}"`, options);
1429
+ }
1430
+ }
1431
+ }
1432
+ async function removeWorktree(worktreePath, options = {}, force = false) {
1433
+ validatePath(worktreePath);
1434
+ const forceFlag = force ? "--force" : "";
1435
+ await execGit(`git worktree remove ${forceFlag} "${worktreePath}"`, options);
1436
+ }
1437
+ async function listWorktrees(options = {}) {
1438
+ try {
1439
+ const { stdout } = await execGit("git worktree list --porcelain", options);
1440
+ const worktrees = [];
1441
+ const entries = stdout.trim().split("\n\n");
1442
+ for (const entry of entries) {
1443
+ if (!entry.trim()) continue;
1444
+ const lines = entry.split("\n");
1445
+ const info = {
1446
+ isMain: false,
1447
+ branch: null
1448
+ };
1449
+ for (const line of lines) {
1450
+ if (line.startsWith("worktree ")) {
1451
+ info.path = line.substring(9);
1452
+ } else if (line.startsWith("HEAD ")) {
1453
+ info.head = line.substring(5);
1454
+ } else if (line.startsWith("branch ")) {
1455
+ info.branch = line.substring(7).replace(/^refs\/heads\//, "");
1456
+ } else if (line === "bare") {
1457
+ info.isMain = true;
1458
+ }
1459
+ }
1460
+ if (worktrees.length === 0) {
1461
+ info.isMain = true;
1462
+ }
1463
+ if (info.path && info.head) {
1464
+ worktrees.push(info);
1465
+ }
1466
+ }
1467
+ return worktrees;
1468
+ } catch {
1469
+ return [];
1470
+ }
1471
+ }
1472
+ async function getWorktreeForBranch(branch, options = {}) {
1473
+ const worktrees = await listWorktrees(options);
1474
+ return worktrees.find((wt) => wt.branch === branch) || null;
1475
+ }
1476
+ async function worktreeExists(worktreePath, options = {}) {
1477
+ const worktrees = await listWorktrees(options);
1478
+ return worktrees.some((wt) => wt.path === worktreePath);
1479
+ }
1480
+ function generateWorktreePath(basePath, repoName, identifier, title) {
1481
+ const safeRepoName = sanitizeForPath(repoName);
1482
+ let dirName;
1483
+ if (title && typeof identifier === "number") {
1484
+ const titleSlug = sanitizeForBranchName(title).substring(0, 35).replace(/-$/, "");
1485
+ dirName = `${identifier}-${titleSlug}`;
1486
+ } else {
1487
+ dirName = sanitizeForPath(String(identifier));
1488
+ }
1489
+ const expandedBase = basePath.startsWith("~") ? basePath.replace("~", (0, import_os.homedir)()) : basePath;
1490
+ const cleanBase = expandedBase.replace(/\/+$/, "");
1491
+ return `${cleanBase}/${safeRepoName}/${dirName}`;
1492
+ }
1360
1493
 
1361
1494
  // src/sync.ts
1362
1495
  var SYNCABLE_KEYS = [
@@ -1537,10 +1670,13 @@ function getDiffSummary(diff) {
1537
1670
  checkoutBranch,
1538
1671
  computeSettingsDiff,
1539
1672
  createBranch,
1673
+ createWorktree,
1540
1674
  detectRepository,
1675
+ extractIssueNumberFromBranch,
1541
1676
  fetchOrigin,
1542
1677
  formatConflict,
1543
1678
  generateBranchName,
1679
+ generateWorktreePath,
1544
1680
  getAllBranches,
1545
1681
  getCommitsAhead,
1546
1682
  getCommitsBehind,
@@ -1550,9 +1686,11 @@ function getDiffSummary(diff) {
1550
1686
  getLocalBranches,
1551
1687
  getRemoteBranches,
1552
1688
  getRepositoryRoot,
1689
+ getWorktreeForBranch,
1553
1690
  hasDifferences,
1554
1691
  hasUncommittedChanges,
1555
1692
  isGitRepository,
1693
+ listWorktrees,
1556
1694
  normalizeVSCodeSettings,
1557
1695
  parseBranchLink,
1558
1696
  parseGitHubUrl,
@@ -1560,6 +1698,7 @@ function getDiffSummary(diff) {
1560
1698
  pullLatest,
1561
1699
  queries,
1562
1700
  removeBranchLinkFromBody,
1701
+ removeWorktree,
1563
1702
  resolveConflicts,
1564
1703
  sanitizeForBranchName,
1565
1704
  setBranchLinkInBody,
@@ -1567,5 +1706,6 @@ function getDiffSummary(diff) {
1567
1706
  toVSCodeSettings,
1568
1707
  useCli,
1569
1708
  useCustom,
1570
- useVSCode
1709
+ useVSCode,
1710
+ worktreeExists
1571
1711
  });
package/dist/index.d.cts CHANGED
@@ -629,6 +629,16 @@ declare function generateBranchName(pattern: string, vars: {
629
629
  title: string;
630
630
  repo: string;
631
631
  }, maxLength?: number): string;
632
+ /**
633
+ * Extract issue number from a branch name.
634
+ * Supports common patterns:
635
+ * - user/123-feature-name
636
+ * - feature/123-something
637
+ * - 123-fix-bug
638
+ * - fix-123-something
639
+ * - ends with #123 or /123
640
+ */
641
+ declare function extractIssueNumberFromBranch(branchName: string): number | null;
632
642
  /**
633
643
  * Get all local branches
634
644
  */
@@ -645,6 +655,61 @@ declare function getAllBranches(options?: GitOptions): Promise<string[]>;
645
655
  * Get the default branch name (main or master)
646
656
  */
647
657
  declare function getDefaultBranch(options?: GitOptions): Promise<string>;
658
+ /**
659
+ * Information about a git worktree
660
+ */
661
+ interface WorktreeInfo {
662
+ /** Absolute path to the worktree directory */
663
+ path: string;
664
+ /** Commit SHA the worktree is at */
665
+ head: string;
666
+ /** Branch name (without refs/heads/ prefix), or null if detached */
667
+ branch: string | null;
668
+ /** Whether this is the main worktree (the original repo) */
669
+ isMain: boolean;
670
+ }
671
+ /**
672
+ * Create a new worktree for a branch
673
+ * @param worktreePath - Path where the worktree will be created
674
+ * @param branch - Branch to checkout in the worktree
675
+ * @param options - Git options (cwd determines the source repository)
676
+ */
677
+ declare function createWorktree(worktreePath: string, branch: string, options?: GitOptions): Promise<void>;
678
+ /**
679
+ * Remove a worktree
680
+ * @param worktreePath - Path to the worktree to remove
681
+ * @param options - Git options
682
+ * @param force - Force removal even if worktree has uncommitted changes
683
+ */
684
+ declare function removeWorktree(worktreePath: string, options?: GitOptions, force?: boolean): Promise<void>;
685
+ /**
686
+ * List all worktrees for the repository
687
+ * @param options - Git options
688
+ * @returns Array of worktree information
689
+ */
690
+ declare function listWorktrees(options?: GitOptions): Promise<WorktreeInfo[]>;
691
+ /**
692
+ * Get worktree for a specific branch
693
+ * @param branch - Branch name to find
694
+ * @param options - Git options
695
+ * @returns Worktree info if found, null otherwise
696
+ */
697
+ declare function getWorktreeForBranch(branch: string, options?: GitOptions): Promise<WorktreeInfo | null>;
698
+ /**
699
+ * Check if a worktree exists at the given path
700
+ * @param worktreePath - Path to check
701
+ * @param options - Git options
702
+ */
703
+ declare function worktreeExists(worktreePath: string, options?: GitOptions): Promise<boolean>;
704
+ /**
705
+ * Generate a worktree path based on repo and issue info
706
+ * @param basePath - Base directory for worktrees (e.g., ~/.ghp/worktrees)
707
+ * @param repoName - Repository name
708
+ * @param identifier - Issue number or branch name to use as identifier
709
+ * @param title - Optional title to create a descriptive directory name (e.g., "123-fix-auth-bug")
710
+ * @returns Full path to the worktree directory
711
+ */
712
+ declare function generateWorktreePath(basePath: string, repoName: string, identifier: string | number, title?: string): string;
648
713
 
649
714
  /**
650
715
  * URL parsing utilities for GitHub repositories and issues.
@@ -937,7 +1002,7 @@ declare const REMOVE_LABELS_MUTATION = "\n mutation($issueId: ID!, $labelIds:
937
1002
  /**
938
1003
  * Query to find issues with a specific label
939
1004
  */
940
- declare const ISSUES_WITH_LABEL_QUERY = "\n query($owner: String!, $name: String!, $labels: [String!]) {\n repository(owner: $owner, name: $name) {\n issues(first: 10, labels: $labels, states: [OPEN]) {\n nodes {\n number\n }\n }\n }\n }\n";
1005
+ declare const ISSUES_WITH_LABEL_QUERY = "\n query($owner: String!, $name: String!, $labels: [String!]) {\n repository(owner: $owner, name: $name) {\n issues(first: 10, labels: $labels, states: [OPEN, CLOSED]) {\n nodes {\n number\n }\n }\n }\n }\n";
941
1006
  /**
942
1007
  * Query to get available issue types for a repository
943
1008
  */
@@ -988,4 +1053,4 @@ declare namespace queries {
988
1053
  export { queries_ADD_COMMENT_MUTATION as ADD_COMMENT_MUTATION, queries_ADD_LABELS_MUTATION as ADD_LABELS_MUTATION, queries_ADD_TO_PROJECT_MUTATION as ADD_TO_PROJECT_MUTATION, queries_COLLABORATORS_QUERY as COLLABORATORS_QUERY, queries_CREATE_ISSUE_MUTATION as CREATE_ISSUE_MUTATION, queries_ISSUES_WITH_LABEL_QUERY as ISSUES_WITH_LABEL_QUERY, queries_ISSUE_AND_LABEL_QUERY as ISSUE_AND_LABEL_QUERY, queries_ISSUE_DETAILS_QUERY as ISSUE_DETAILS_QUERY, queries_ISSUE_FOR_UPDATE_QUERY as ISSUE_FOR_UPDATE_QUERY, queries_ISSUE_NODE_ID_QUERY as ISSUE_NODE_ID_QUERY, queries_ISSUE_TYPES_QUERY as ISSUE_TYPES_QUERY, queries_LABEL_EXISTS_QUERY as LABEL_EXISTS_QUERY, queries_PROJECT_FIELDS_QUERY as PROJECT_FIELDS_QUERY, queries_PROJECT_ITEMS_QUERY as PROJECT_ITEMS_QUERY, queries_PROJECT_VIEWS_QUERY as PROJECT_VIEWS_QUERY, queries_RECENT_ISSUES_QUERY as RECENT_ISSUES_QUERY, queries_REMOVE_LABELS_MUTATION as REMOVE_LABELS_MUTATION, queries_REPOSITORY_ID_QUERY as REPOSITORY_ID_QUERY, queries_REPOSITORY_PROJECTS_QUERY as REPOSITORY_PROJECTS_QUERY, queries_UPDATE_ISSUE_BODY_MUTATION as UPDATE_ISSUE_BODY_MUTATION, queries_UPDATE_ISSUE_MUTATION as UPDATE_ISSUE_MUTATION, queries_UPDATE_ISSUE_TYPE_MUTATION as UPDATE_ISSUE_TYPE_MUTATION, queries_UPDATE_ITEM_FIELD_MUTATION as UPDATE_ITEM_FIELD_MUTATION, queries_UPDATE_ITEM_STATUS_MUTATION as UPDATE_ITEM_STATUS_MUTATION, queries_VIEWER_QUERY as VIEWER_QUERY };
989
1054
  }
990
1055
 
991
- export { type AssigneeInfo, type AuthError, BranchLinker, CLI_TO_VSCODE_MAP, type Collaborator, type ConflictChoices, type ConflictResolution, DEFAULT_VALUES, type DateFieldValue, type FieldInfo, type FieldValue, type FieldValueConnection, GitHubAPI, type GitHubAPIOptions, type GitOptions, type IssueDetails, type IssueReference, type IterationFieldValue, type LabelInfo, type NumberFieldValue, type Project, type ProjectConfig, type ProjectItem, type ProjectItemContent, type ProjectItemsQueryResponse, type ProjectV2, type ProjectV2Field, type ProjectV2Item, type ProjectV2View, type ProjectWithViews, type ProjectsQueryResponse, type RepoInfo, type ResolvedSettings, SETTING_DISPLAY_NAMES, SYNCABLE_KEYS, type SettingConflict, type SettingsDiff, type SettingsSource, type SingleSelectFieldValue, type StatusField, type SyncableSettingKey, type SyncableSettings, type TextFieldValue, type TokenProvider, VSCODE_TO_CLI_MAP, branchExists, buildIssueUrl, buildOrgProjectUrl, buildProjectUrl, buildPullRequestUrl, buildRepoUrl, checkoutBranch, computeSettingsDiff, createBranch, detectRepository, fetchOrigin, formatConflict, generateBranchName, getAllBranches, getCommitsAhead, getCommitsBehind, getCurrentBranch, getDefaultBranch, getDiffSummary, getLocalBranches, getRemoteBranches, getRepositoryRoot, hasDifferences, hasUncommittedChanges, isGitRepository, normalizeVSCodeSettings, parseBranchLink, parseGitHubUrl, parseIssueUrl, pullLatest, queries, removeBranchLinkFromBody, resolveConflicts, sanitizeForBranchName, setBranchLinkInBody, skip, toVSCodeSettings, useCli, useCustom, useVSCode };
1056
+ export { type AssigneeInfo, type AuthError, BranchLinker, CLI_TO_VSCODE_MAP, type Collaborator, type ConflictChoices, type ConflictResolution, DEFAULT_VALUES, type DateFieldValue, type FieldInfo, type FieldValue, type FieldValueConnection, GitHubAPI, type GitHubAPIOptions, type GitOptions, type IssueDetails, type IssueReference, type IterationFieldValue, type LabelInfo, type NumberFieldValue, type Project, type ProjectConfig, type ProjectItem, type ProjectItemContent, type ProjectItemsQueryResponse, type ProjectV2, type ProjectV2Field, type ProjectV2Item, type ProjectV2View, type ProjectWithViews, type ProjectsQueryResponse, type RepoInfo, type ResolvedSettings, SETTING_DISPLAY_NAMES, SYNCABLE_KEYS, type SettingConflict, type SettingsDiff, type SettingsSource, type SingleSelectFieldValue, type StatusField, type SyncableSettingKey, type SyncableSettings, type TextFieldValue, type TokenProvider, VSCODE_TO_CLI_MAP, type WorktreeInfo, branchExists, buildIssueUrl, buildOrgProjectUrl, buildProjectUrl, buildPullRequestUrl, buildRepoUrl, checkoutBranch, computeSettingsDiff, createBranch, createWorktree, detectRepository, extractIssueNumberFromBranch, fetchOrigin, formatConflict, generateBranchName, generateWorktreePath, getAllBranches, getCommitsAhead, getCommitsBehind, getCurrentBranch, getDefaultBranch, getDiffSummary, getLocalBranches, getRemoteBranches, getRepositoryRoot, getWorktreeForBranch, hasDifferences, hasUncommittedChanges, isGitRepository, listWorktrees, normalizeVSCodeSettings, parseBranchLink, parseGitHubUrl, parseIssueUrl, pullLatest, queries, removeBranchLinkFromBody, removeWorktree, resolveConflicts, sanitizeForBranchName, setBranchLinkInBody, skip, toVSCodeSettings, useCli, useCustom, useVSCode, worktreeExists };
package/dist/index.d.ts CHANGED
@@ -629,6 +629,16 @@ declare function generateBranchName(pattern: string, vars: {
629
629
  title: string;
630
630
  repo: string;
631
631
  }, maxLength?: number): string;
632
+ /**
633
+ * Extract issue number from a branch name.
634
+ * Supports common patterns:
635
+ * - user/123-feature-name
636
+ * - feature/123-something
637
+ * - 123-fix-bug
638
+ * - fix-123-something
639
+ * - ends with #123 or /123
640
+ */
641
+ declare function extractIssueNumberFromBranch(branchName: string): number | null;
632
642
  /**
633
643
  * Get all local branches
634
644
  */
@@ -645,6 +655,61 @@ declare function getAllBranches(options?: GitOptions): Promise<string[]>;
645
655
  * Get the default branch name (main or master)
646
656
  */
647
657
  declare function getDefaultBranch(options?: GitOptions): Promise<string>;
658
+ /**
659
+ * Information about a git worktree
660
+ */
661
+ interface WorktreeInfo {
662
+ /** Absolute path to the worktree directory */
663
+ path: string;
664
+ /** Commit SHA the worktree is at */
665
+ head: string;
666
+ /** Branch name (without refs/heads/ prefix), or null if detached */
667
+ branch: string | null;
668
+ /** Whether this is the main worktree (the original repo) */
669
+ isMain: boolean;
670
+ }
671
+ /**
672
+ * Create a new worktree for a branch
673
+ * @param worktreePath - Path where the worktree will be created
674
+ * @param branch - Branch to checkout in the worktree
675
+ * @param options - Git options (cwd determines the source repository)
676
+ */
677
+ declare function createWorktree(worktreePath: string, branch: string, options?: GitOptions): Promise<void>;
678
+ /**
679
+ * Remove a worktree
680
+ * @param worktreePath - Path to the worktree to remove
681
+ * @param options - Git options
682
+ * @param force - Force removal even if worktree has uncommitted changes
683
+ */
684
+ declare function removeWorktree(worktreePath: string, options?: GitOptions, force?: boolean): Promise<void>;
685
+ /**
686
+ * List all worktrees for the repository
687
+ * @param options - Git options
688
+ * @returns Array of worktree information
689
+ */
690
+ declare function listWorktrees(options?: GitOptions): Promise<WorktreeInfo[]>;
691
+ /**
692
+ * Get worktree for a specific branch
693
+ * @param branch - Branch name to find
694
+ * @param options - Git options
695
+ * @returns Worktree info if found, null otherwise
696
+ */
697
+ declare function getWorktreeForBranch(branch: string, options?: GitOptions): Promise<WorktreeInfo | null>;
698
+ /**
699
+ * Check if a worktree exists at the given path
700
+ * @param worktreePath - Path to check
701
+ * @param options - Git options
702
+ */
703
+ declare function worktreeExists(worktreePath: string, options?: GitOptions): Promise<boolean>;
704
+ /**
705
+ * Generate a worktree path based on repo and issue info
706
+ * @param basePath - Base directory for worktrees (e.g., ~/.ghp/worktrees)
707
+ * @param repoName - Repository name
708
+ * @param identifier - Issue number or branch name to use as identifier
709
+ * @param title - Optional title to create a descriptive directory name (e.g., "123-fix-auth-bug")
710
+ * @returns Full path to the worktree directory
711
+ */
712
+ declare function generateWorktreePath(basePath: string, repoName: string, identifier: string | number, title?: string): string;
648
713
 
649
714
  /**
650
715
  * URL parsing utilities for GitHub repositories and issues.
@@ -937,7 +1002,7 @@ declare const REMOVE_LABELS_MUTATION = "\n mutation($issueId: ID!, $labelIds:
937
1002
  /**
938
1003
  * Query to find issues with a specific label
939
1004
  */
940
- declare const ISSUES_WITH_LABEL_QUERY = "\n query($owner: String!, $name: String!, $labels: [String!]) {\n repository(owner: $owner, name: $name) {\n issues(first: 10, labels: $labels, states: [OPEN]) {\n nodes {\n number\n }\n }\n }\n }\n";
1005
+ declare const ISSUES_WITH_LABEL_QUERY = "\n query($owner: String!, $name: String!, $labels: [String!]) {\n repository(owner: $owner, name: $name) {\n issues(first: 10, labels: $labels, states: [OPEN, CLOSED]) {\n nodes {\n number\n }\n }\n }\n }\n";
941
1006
  /**
942
1007
  * Query to get available issue types for a repository
943
1008
  */
@@ -988,4 +1053,4 @@ declare namespace queries {
988
1053
  export { queries_ADD_COMMENT_MUTATION as ADD_COMMENT_MUTATION, queries_ADD_LABELS_MUTATION as ADD_LABELS_MUTATION, queries_ADD_TO_PROJECT_MUTATION as ADD_TO_PROJECT_MUTATION, queries_COLLABORATORS_QUERY as COLLABORATORS_QUERY, queries_CREATE_ISSUE_MUTATION as CREATE_ISSUE_MUTATION, queries_ISSUES_WITH_LABEL_QUERY as ISSUES_WITH_LABEL_QUERY, queries_ISSUE_AND_LABEL_QUERY as ISSUE_AND_LABEL_QUERY, queries_ISSUE_DETAILS_QUERY as ISSUE_DETAILS_QUERY, queries_ISSUE_FOR_UPDATE_QUERY as ISSUE_FOR_UPDATE_QUERY, queries_ISSUE_NODE_ID_QUERY as ISSUE_NODE_ID_QUERY, queries_ISSUE_TYPES_QUERY as ISSUE_TYPES_QUERY, queries_LABEL_EXISTS_QUERY as LABEL_EXISTS_QUERY, queries_PROJECT_FIELDS_QUERY as PROJECT_FIELDS_QUERY, queries_PROJECT_ITEMS_QUERY as PROJECT_ITEMS_QUERY, queries_PROJECT_VIEWS_QUERY as PROJECT_VIEWS_QUERY, queries_RECENT_ISSUES_QUERY as RECENT_ISSUES_QUERY, queries_REMOVE_LABELS_MUTATION as REMOVE_LABELS_MUTATION, queries_REPOSITORY_ID_QUERY as REPOSITORY_ID_QUERY, queries_REPOSITORY_PROJECTS_QUERY as REPOSITORY_PROJECTS_QUERY, queries_UPDATE_ISSUE_BODY_MUTATION as UPDATE_ISSUE_BODY_MUTATION, queries_UPDATE_ISSUE_MUTATION as UPDATE_ISSUE_MUTATION, queries_UPDATE_ISSUE_TYPE_MUTATION as UPDATE_ISSUE_TYPE_MUTATION, queries_UPDATE_ITEM_FIELD_MUTATION as UPDATE_ITEM_FIELD_MUTATION, queries_UPDATE_ITEM_STATUS_MUTATION as UPDATE_ITEM_STATUS_MUTATION, queries_VIEWER_QUERY as VIEWER_QUERY };
989
1054
  }
990
1055
 
991
- export { type AssigneeInfo, type AuthError, BranchLinker, CLI_TO_VSCODE_MAP, type Collaborator, type ConflictChoices, type ConflictResolution, DEFAULT_VALUES, type DateFieldValue, type FieldInfo, type FieldValue, type FieldValueConnection, GitHubAPI, type GitHubAPIOptions, type GitOptions, type IssueDetails, type IssueReference, type IterationFieldValue, type LabelInfo, type NumberFieldValue, type Project, type ProjectConfig, type ProjectItem, type ProjectItemContent, type ProjectItemsQueryResponse, type ProjectV2, type ProjectV2Field, type ProjectV2Item, type ProjectV2View, type ProjectWithViews, type ProjectsQueryResponse, type RepoInfo, type ResolvedSettings, SETTING_DISPLAY_NAMES, SYNCABLE_KEYS, type SettingConflict, type SettingsDiff, type SettingsSource, type SingleSelectFieldValue, type StatusField, type SyncableSettingKey, type SyncableSettings, type TextFieldValue, type TokenProvider, VSCODE_TO_CLI_MAP, branchExists, buildIssueUrl, buildOrgProjectUrl, buildProjectUrl, buildPullRequestUrl, buildRepoUrl, checkoutBranch, computeSettingsDiff, createBranch, detectRepository, fetchOrigin, formatConflict, generateBranchName, getAllBranches, getCommitsAhead, getCommitsBehind, getCurrentBranch, getDefaultBranch, getDiffSummary, getLocalBranches, getRemoteBranches, getRepositoryRoot, hasDifferences, hasUncommittedChanges, isGitRepository, normalizeVSCodeSettings, parseBranchLink, parseGitHubUrl, parseIssueUrl, pullLatest, queries, removeBranchLinkFromBody, resolveConflicts, sanitizeForBranchName, setBranchLinkInBody, skip, toVSCodeSettings, useCli, useCustom, useVSCode };
1056
+ export { type AssigneeInfo, type AuthError, BranchLinker, CLI_TO_VSCODE_MAP, type Collaborator, type ConflictChoices, type ConflictResolution, DEFAULT_VALUES, type DateFieldValue, type FieldInfo, type FieldValue, type FieldValueConnection, GitHubAPI, type GitHubAPIOptions, type GitOptions, type IssueDetails, type IssueReference, type IterationFieldValue, type LabelInfo, type NumberFieldValue, type Project, type ProjectConfig, type ProjectItem, type ProjectItemContent, type ProjectItemsQueryResponse, type ProjectV2, type ProjectV2Field, type ProjectV2Item, type ProjectV2View, type ProjectWithViews, type ProjectsQueryResponse, type RepoInfo, type ResolvedSettings, SETTING_DISPLAY_NAMES, SYNCABLE_KEYS, type SettingConflict, type SettingsDiff, type SettingsSource, type SingleSelectFieldValue, type StatusField, type SyncableSettingKey, type SyncableSettings, type TextFieldValue, type TokenProvider, VSCODE_TO_CLI_MAP, type WorktreeInfo, branchExists, buildIssueUrl, buildOrgProjectUrl, buildProjectUrl, buildPullRequestUrl, buildRepoUrl, checkoutBranch, computeSettingsDiff, createBranch, createWorktree, detectRepository, extractIssueNumberFromBranch, fetchOrigin, formatConflict, generateBranchName, generateWorktreePath, getAllBranches, getCommitsAhead, getCommitsBehind, getCurrentBranch, getDefaultBranch, getDiffSummary, getLocalBranches, getRemoteBranches, getRepositoryRoot, getWorktreeForBranch, hasDifferences, hasUncommittedChanges, isGitRepository, listWorktrees, normalizeVSCodeSettings, parseBranchLink, parseGitHubUrl, parseIssueUrl, pullLatest, queries, removeBranchLinkFromBody, removeWorktree, resolveConflicts, sanitizeForBranchName, setBranchLinkInBody, skip, toVSCodeSettings, useCli, useCustom, useVSCode, worktreeExists };
package/dist/index.js CHANGED
@@ -339,7 +339,7 @@ var REMOVE_LABELS_MUTATION = `
339
339
  var ISSUES_WITH_LABEL_QUERY = `
340
340
  query($owner: String!, $name: String!, $labels: [String!]) {
341
341
  repository(owner: $owner, name: $name) {
342
- issues(first: 10, labels: $labels, states: [OPEN]) {
342
+ issues(first: 10, labels: $labels, states: [OPEN, CLOSED]) {
343
343
  nodes {
344
344
  number
345
345
  }
@@ -1092,6 +1092,7 @@ var BranchLinker = class {
1092
1092
  // src/git-utils.ts
1093
1093
  import { exec } from "child_process";
1094
1094
  import { promisify } from "util";
1095
+ import { homedir } from "os";
1095
1096
 
1096
1097
  // src/url-parser.ts
1097
1098
  function parseGitHubUrl(url) {
@@ -1144,6 +1145,28 @@ function buildOrgProjectUrl(org, projectNumber) {
1144
1145
  }
1145
1146
 
1146
1147
  // src/git-utils.ts
1148
+ function sanitizeForPath(input) {
1149
+ return String(input).replace(/\.\./g, "_").replace(/[;&|`$(){}[\]<>!]/g, "").replace(/\s+/g, "-").replace(/[^a-zA-Z0-9_\-./]/g, "_");
1150
+ }
1151
+ function validateBranchName(branch) {
1152
+ if (!branch || branch.trim().length === 0) {
1153
+ throw new Error("Branch name cannot be empty");
1154
+ }
1155
+ const dangerousChars = /[`$\\!;|&<>(){}[\]'"]/;
1156
+ if (dangerousChars.test(branch)) {
1157
+ throw new Error(`Branch name contains invalid characters: ${branch}`);
1158
+ }
1159
+ const gitInvalidChars = /[\s~^:?*\[\\]/;
1160
+ if (gitInvalidChars.test(branch)) {
1161
+ throw new Error(`Branch name contains invalid git characters: ${branch}`);
1162
+ }
1163
+ if (branch.includes("..")) {
1164
+ throw new Error(`Branch name cannot contain '..': ${branch}`);
1165
+ }
1166
+ if (/^[./]|[./]$/.test(branch)) {
1167
+ throw new Error(`Branch name cannot start or end with '/' or '.': ${branch}`);
1168
+ }
1169
+ }
1147
1170
  var execAsync = promisify(exec);
1148
1171
  async function execGit(command, options = {}) {
1149
1172
  const cwd = options.cwd || process.cwd();
@@ -1245,6 +1268,25 @@ function generateBranchName(pattern, vars, maxLength = 60) {
1245
1268
  }
1246
1269
  return branch;
1247
1270
  }
1271
+ function extractIssueNumberFromBranch(branchName) {
1272
+ const patterns = [
1273
+ /\/(\d+)-/,
1274
+ // user/123-title
1275
+ /^(\d+)-/,
1276
+ // 123-title
1277
+ /-(\d+)-/,
1278
+ // feature-123-title
1279
+ /[/#](\d+)$/
1280
+ // ends with #123 or /123
1281
+ ];
1282
+ for (const pattern of patterns) {
1283
+ const match = branchName.match(pattern);
1284
+ if (match) {
1285
+ return parseInt(match[1], 10);
1286
+ }
1287
+ }
1288
+ return null;
1289
+ }
1248
1290
  async function getLocalBranches(options = {}) {
1249
1291
  try {
1250
1292
  const { stdout } = await execGit('git branch --format="%(refname:short)"', options);
@@ -1291,6 +1333,90 @@ async function getDefaultBranch(options = {}) {
1291
1333
  }
1292
1334
  return "master";
1293
1335
  }
1336
+ function validatePath(path) {
1337
+ if (!path || path.trim().length === 0) {
1338
+ throw new Error("Path cannot be empty");
1339
+ }
1340
+ const dangerousChars = /[`$;|&<>(){}[\]'"\n\r]/;
1341
+ if (dangerousChars.test(path)) {
1342
+ throw new Error(`Path contains invalid characters: ${path}`);
1343
+ }
1344
+ }
1345
+ async function createWorktree(worktreePath, branch, options = {}) {
1346
+ validateBranchName(branch);
1347
+ validatePath(worktreePath);
1348
+ const localExists = await branchExists(branch, options);
1349
+ if (localExists) {
1350
+ await execGit(`git worktree add "${worktreePath}" "${branch}"`, options);
1351
+ } else {
1352
+ try {
1353
+ await execGit(`git worktree add "${worktreePath}" -b "${branch}" "origin/${branch}"`, options);
1354
+ } catch {
1355
+ await execGit(`git worktree add -b "${branch}" "${worktreePath}"`, options);
1356
+ }
1357
+ }
1358
+ }
1359
+ async function removeWorktree(worktreePath, options = {}, force = false) {
1360
+ validatePath(worktreePath);
1361
+ const forceFlag = force ? "--force" : "";
1362
+ await execGit(`git worktree remove ${forceFlag} "${worktreePath}"`, options);
1363
+ }
1364
+ async function listWorktrees(options = {}) {
1365
+ try {
1366
+ const { stdout } = await execGit("git worktree list --porcelain", options);
1367
+ const worktrees = [];
1368
+ const entries = stdout.trim().split("\n\n");
1369
+ for (const entry of entries) {
1370
+ if (!entry.trim()) continue;
1371
+ const lines = entry.split("\n");
1372
+ const info = {
1373
+ isMain: false,
1374
+ branch: null
1375
+ };
1376
+ for (const line of lines) {
1377
+ if (line.startsWith("worktree ")) {
1378
+ info.path = line.substring(9);
1379
+ } else if (line.startsWith("HEAD ")) {
1380
+ info.head = line.substring(5);
1381
+ } else if (line.startsWith("branch ")) {
1382
+ info.branch = line.substring(7).replace(/^refs\/heads\//, "");
1383
+ } else if (line === "bare") {
1384
+ info.isMain = true;
1385
+ }
1386
+ }
1387
+ if (worktrees.length === 0) {
1388
+ info.isMain = true;
1389
+ }
1390
+ if (info.path && info.head) {
1391
+ worktrees.push(info);
1392
+ }
1393
+ }
1394
+ return worktrees;
1395
+ } catch {
1396
+ return [];
1397
+ }
1398
+ }
1399
+ async function getWorktreeForBranch(branch, options = {}) {
1400
+ const worktrees = await listWorktrees(options);
1401
+ return worktrees.find((wt) => wt.branch === branch) || null;
1402
+ }
1403
+ async function worktreeExists(worktreePath, options = {}) {
1404
+ const worktrees = await listWorktrees(options);
1405
+ return worktrees.some((wt) => wt.path === worktreePath);
1406
+ }
1407
+ function generateWorktreePath(basePath, repoName, identifier, title) {
1408
+ const safeRepoName = sanitizeForPath(repoName);
1409
+ let dirName;
1410
+ if (title && typeof identifier === "number") {
1411
+ const titleSlug = sanitizeForBranchName(title).substring(0, 35).replace(/-$/, "");
1412
+ dirName = `${identifier}-${titleSlug}`;
1413
+ } else {
1414
+ dirName = sanitizeForPath(String(identifier));
1415
+ }
1416
+ const expandedBase = basePath.startsWith("~") ? basePath.replace("~", homedir()) : basePath;
1417
+ const cleanBase = expandedBase.replace(/\/+$/, "");
1418
+ return `${cleanBase}/${safeRepoName}/${dirName}`;
1419
+ }
1294
1420
 
1295
1421
  // src/sync.ts
1296
1422
  var SYNCABLE_KEYS = [
@@ -1470,10 +1596,13 @@ export {
1470
1596
  checkoutBranch,
1471
1597
  computeSettingsDiff,
1472
1598
  createBranch,
1599
+ createWorktree,
1473
1600
  detectRepository,
1601
+ extractIssueNumberFromBranch,
1474
1602
  fetchOrigin,
1475
1603
  formatConflict,
1476
1604
  generateBranchName,
1605
+ generateWorktreePath,
1477
1606
  getAllBranches,
1478
1607
  getCommitsAhead,
1479
1608
  getCommitsBehind,
@@ -1483,9 +1612,11 @@ export {
1483
1612
  getLocalBranches,
1484
1613
  getRemoteBranches,
1485
1614
  getRepositoryRoot,
1615
+ getWorktreeForBranch,
1486
1616
  hasDifferences,
1487
1617
  hasUncommittedChanges,
1488
1618
  isGitRepository,
1619
+ listWorktrees,
1489
1620
  normalizeVSCodeSettings,
1490
1621
  parseBranchLink,
1491
1622
  parseGitHubUrl,
@@ -1493,6 +1624,7 @@ export {
1493
1624
  pullLatest,
1494
1625
  queries_exports as queries,
1495
1626
  removeBranchLinkFromBody,
1627
+ removeWorktree,
1496
1628
  resolveConflicts,
1497
1629
  sanitizeForBranchName,
1498
1630
  setBranchLinkInBody,
@@ -1500,5 +1632,6 @@ export {
1500
1632
  toVSCodeSettings,
1501
1633
  useCli,
1502
1634
  useCustom,
1503
- useVSCode
1635
+ useVSCode,
1636
+ worktreeExists
1504
1637
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bretwardjames/ghp-core",
3
- "version": "0.2.0-beta.0",
3
+ "version": "0.2.0-beta.2",
4
4
  "description": "Shared core library for GitHub Projects tools",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.js",
@@ -16,6 +16,12 @@
16
16
  "files": [
17
17
  "dist"
18
18
  ],
19
+ "scripts": {
20
+ "build": "tsup src/index.ts --format cjs,esm --dts",
21
+ "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
22
+ "typecheck": "tsc --noEmit",
23
+ "prepublishOnly": "npm run build"
24
+ },
19
25
  "keywords": [
20
26
  "github",
21
27
  "projects",
@@ -38,10 +44,5 @@
38
44
  "@types/node": "^20.10.0",
39
45
  "tsup": "^8.0.0",
40
46
  "typescript": "^5.3.2"
41
- },
42
- "scripts": {
43
- "build": "tsup src/index.ts --format cjs,esm --dts",
44
- "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
45
- "typecheck": "tsc --noEmit"
46
47
  }
47
- }
48
+ }