dsh-ide-git 0.21.1 → 0.22.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.
package/src/index.js CHANGED
@@ -45,7 +45,7 @@ try {
45
45
 
46
46
  /** Rail action ids, in rail order. MUST mirror RAIL_SPECS in src/client.js. */
47
47
  const RAIL_CONFIG_IDS = [
48
- 'refresh', 'tree', 'float', 'split', 'fullscreen', 'newBranch', 'checkout', 'delete',
48
+ 'refresh', 'tree', 'worktrees', 'float', 'split', 'fullscreen', 'newBranch', 'checkout', 'delete',
49
49
  'compare', 'diff', 'stash', 'tag', 'favorite', 'fetch', 'pull', 'push',
50
50
  ]
51
51
 
@@ -429,25 +429,54 @@ function sameDirectory(left, right) {
429
429
  try { return realpathSync(one) === realpathSync(two) } catch { return false }
430
430
  }
431
431
 
432
+ /* `--porcelain -z` is the only parseable form: a path may contain spaces and
433
+ newlines, and git realpaths the main worktree (/var -> /private/var on macOS),
434
+ so every record is NUL-terminated and an empty record closes the current
435
+ block. The flags describe the ADMIN view: `locked` and `prunable` say
436
+ whether removal is blocked or the directory is already gone. */
432
437
  async function worktreesOf(root) {
433
- const result = await runGit(root, ['worktree', 'list', '--porcelain'])
438
+ const result = await runGit(root, ['worktree', 'list', '--porcelain', '-z'])
434
439
  if (result.code !== 0) return []
435
440
  const trees = []
436
441
  let current = null
437
- for (const line of result.stdout.split('\n')) {
438
- if (line.startsWith('worktree ')) {
439
- if (current !== null) trees.push(current)
440
- current = { path: line.slice('worktree '.length), branch: null, head: null, detached: false, bare: false }
442
+ const push = () => { if (current !== null) trees.push(current); current = null }
443
+ for (const record of result.stdout.split('\u0000')) {
444
+ if (record === '') { push(); continue }
445
+ if (record.startsWith('worktree ')) {
446
+ push()
447
+ current = {
448
+ path: record.slice('worktree '.length),
449
+ branch: null,
450
+ head: null,
451
+ detached: false,
452
+ bare: false,
453
+ locked: false,
454
+ lockReason: null,
455
+ prunable: false,
456
+ pruneReason: null,
457
+ }
441
458
  continue
442
459
  }
443
460
  if (current === null) continue
444
- if (line.startsWith('HEAD ')) current.head = line.slice('HEAD '.length)
445
- else if (line.startsWith('branch ')) current.branch = line.slice('branch '.length).replace('refs/heads/', '')
446
- else if (line === 'detached') current.detached = true
447
- else if (line === 'bare') current.bare = true
461
+ if (record.startsWith('HEAD ')) current.head = record.slice('HEAD '.length)
462
+ else if (record.startsWith('branch ')) current.branch = record.slice('branch '.length).replace('refs/heads/', '')
463
+ else if (record === 'detached') current.detached = true
464
+ else if (record === 'bare') current.bare = true
465
+ else if (record.startsWith('locked')) {
466
+ current.locked = true
467
+ const reason = record.slice('locked'.length).trim()
468
+ current.lockReason = reason === '' ? null : reason
469
+ } else if (record.startsWith('prunable')) {
470
+ current.prunable = true
471
+ const reason = record.slice('prunable'.length).trim()
472
+ current.pruneReason = reason === '' ? null : reason
473
+ }
448
474
  }
449
- if (current !== null) trees.push(current)
450
- return trees
475
+ push()
476
+ return trees.map((tree, index) => Object.assign(tree, {
477
+ primary: index === 0,
478
+ current: sameDirectory(tree.path, root),
479
+ }))
451
480
  }
452
481
 
453
482
  /* ============================== safety ============================== */
@@ -471,7 +500,7 @@ function withRepoLock(key, work) {
471
500
 
472
501
  const WRITE_METHODS = new Set([
473
502
  'stage', 'unstage', 'discard', 'commit', 'checkout', 'branchCreate', 'branchRename', 'branch-upstream',
474
- 'branchDelete', 'merge', 'rebase', 'cherryPick', 'revert', 'reset', 'fetch', 'pull',
503
+ 'branchDelete', 'worktreeAdd', 'worktreeRemove', 'worktreePrune', 'merge', 'rebase', 'cherryPick', 'revert', 'reset', 'fetch', 'pull',
475
504
  'push', 'stashPush', 'stashApply', 'stashDrop', 'stashClear', 'tagCreate', 'tagDelete', 'tagDeleteRemote', 'pushTags', 'clone', 'remoteAdd', 'remoteRemove', 'branchDeleteRemote', 'undoApply',
476
505
  ])
477
506
 
@@ -1025,6 +1054,70 @@ async function branchCreate(payload) {
1025
1054
  return { branch: name }
1026
1055
  }
1027
1056
 
1057
+ /* ---------- worktrees (v0.22, alpha.2) ----------
1058
+ A worktree is a second checkout of the same repository. These three verbs
1059
+ are the whole lifecycle the panel offers: create a checkout (on a new or an
1060
+ existing branch), remove one, and prune the admin records of directories
1061
+ that are already gone.
1062
+
1063
+ Removal is deliberately strict. The current and primary checkouts are
1064
+ refused outright (removing the tree the panel is reading is never what a
1065
+ click meant), a locked worktree must be unlocked first, and a dirty one
1066
+ needs a second request with force:true. The dirty check runs INSIDE the
1067
+ target, not the repository root: git status at the root says nothing
1068
+ about the linked checkout's files. */
1069
+ async function worktreeAdd(payload) {
1070
+ const cwd = cwdOf(payload)
1071
+ const root = await repoRootOf(cwd, payload)
1072
+ const target = requireAbsolute(payload.path, 'path')
1073
+ if (sameDirectory(target, root)) throw badRequest('path must differ from the current worktree')
1074
+ if (existsSync(target)) throw new PanelError('worktree-exists', 'path already exists: ' + target, 409)
1075
+ const branch = requireRef(payload.branch, 'branch')
1076
+ const checked = await runGit(root, ['check-ref-format', '--branch', branch])
1077
+ if (checked.code !== 0) throw badRequest('invalid branch name: ' + branch)
1078
+ const createBranch = payload.createBranch !== false
1079
+ const args = ['worktree', 'add']
1080
+ if (createBranch === true) {
1081
+ args.push('-b', branch, target)
1082
+ if (typeof payload.base === 'string' && payload.base.trim() !== '') args.push(requireRef(payload.base, 'base'))
1083
+ } else {
1084
+ args.push(target, branch)
1085
+ }
1086
+ await git(root, args)
1087
+ return { path: target, branch: branch, created: createBranch }
1088
+ }
1089
+
1090
+ async function worktreeRemove(payload) {
1091
+ const cwd = cwdOf(payload)
1092
+ const root = await repoRootOf(cwd, payload)
1093
+ if (payload.confirm !== true) throw badRequest('removing a worktree requires confirm: true')
1094
+ const target = requireAbsolute(payload.path, 'path')
1095
+ const trees = await worktreesOf(root)
1096
+ const tree = trees.find((entry) => sameDirectory(entry.path, target))
1097
+ if (tree === undefined) throw new PanelError('worktree-missing', 'not a worktree of this repository: ' + target, 409)
1098
+ if (tree.current === true) throw new PanelError('protected-worktree', 'refusing to remove the worktree this panel is showing', 409)
1099
+ if (tree.primary === true) throw new PanelError('protected-worktree', 'refusing to remove the primary worktree', 409)
1100
+ if (tree.locked === true) throw new PanelError('worktree-locked', 'worktree is locked; unlock it before removing: ' + target, 409)
1101
+ if (tree.prunable === true || existsSync(target) === false) throw new PanelError('worktree-missing', 'worktree directory is gone; prune the record instead: ' + target, 409)
1102
+ const dirty = await runGit(target, ['status', '--porcelain=v1', '-z', '--untracked-files=all'])
1103
+ if (dirty.code !== 0) throw new PanelError('worktree-unreadable', 'could not inspect the worktree: ' + target, 409)
1104
+ const hasChanges = dirty.stdout !== ''
1105
+ if (hasChanges === true && payload.force !== true) throw new PanelError('worktree-dirty', 'worktree has uncommitted changes; force removal deletes them: ' + target, 409)
1106
+ const args = ['worktree', 'remove']
1107
+ if (payload.force === true) args.push('--force')
1108
+ args.push(target)
1109
+ await git(root, args)
1110
+ return { path: target, forced: payload.force === true, dirty: hasChanges }
1111
+ }
1112
+
1113
+ async function worktreePrune(payload) {
1114
+ const cwd = cwdOf(payload)
1115
+ const root = await repoRootOf(cwd, payload)
1116
+ if (payload.confirm !== true) throw badRequest('pruning worktrees requires confirm: true')
1117
+ const output = await git(root, ['worktree', 'prune', '-v'])
1118
+ return { output: output.trim() }
1119
+ }
1120
+
1028
1121
  /* Upstream management (issue #18): rebind or drop a branch's tracking remote.
1029
1122
  git refuses --set-upstream-to with a ref it cannot resolve, so a typo dies
1030
1123
  here instead of silently writing branch.<name>.merge. */
@@ -2051,6 +2144,9 @@ const METHODS = {
2051
2144
  branchRename,
2052
2145
  'branch-upstream': branchUpstream,
2053
2146
  branchDelete,
2147
+ worktreeAdd,
2148
+ worktreeRemove,
2149
+ worktreePrune,
2054
2150
  merge,
2055
2151
  rebase,
2056
2152
  cherryPick,