dsh-ide-git 0.21.2 → 0.23.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
 
@@ -84,6 +84,11 @@ function railConfigSchema(schema) {
84
84
  back and said "设置没写进去". Any new row-Config field the client writes MUST
85
85
  be declared (and volatile) here, or the write is refused before it lands. */
86
86
  shape.composerStatus = live(schema.boolean().default(true))
87
+ /* Inline diff preview (issue #22): ON by default so the panel's own diff
88
+ pane — with its split / wrap switches — stays reachable even when
89
+ dsh-better-sidebar or the native preview seat is installed. OFF hands the
90
+ preview back to those external tabs, exactly the pre-0.23 behaviour. */
91
+ shape.inlineDiff = live(schema.boolean().default(true))
87
92
  return schema.object(shape)
88
93
  }
89
94
 
@@ -429,25 +434,54 @@ function sameDirectory(left, right) {
429
434
  try { return realpathSync(one) === realpathSync(two) } catch { return false }
430
435
  }
431
436
 
437
+ /* `--porcelain -z` is the only parseable form: a path may contain spaces and
438
+ newlines, and git realpaths the main worktree (/var -> /private/var on macOS),
439
+ so every record is NUL-terminated and an empty record closes the current
440
+ block. The flags describe the ADMIN view: `locked` and `prunable` say
441
+ whether removal is blocked or the directory is already gone. */
432
442
  async function worktreesOf(root) {
433
- const result = await runGit(root, ['worktree', 'list', '--porcelain'])
443
+ const result = await runGit(root, ['worktree', 'list', '--porcelain', '-z'])
434
444
  if (result.code !== 0) return []
435
445
  const trees = []
436
446
  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 }
447
+ const push = () => { if (current !== null) trees.push(current); current = null }
448
+ for (const record of result.stdout.split('\u0000')) {
449
+ if (record === '') { push(); continue }
450
+ if (record.startsWith('worktree ')) {
451
+ push()
452
+ current = {
453
+ path: record.slice('worktree '.length),
454
+ branch: null,
455
+ head: null,
456
+ detached: false,
457
+ bare: false,
458
+ locked: false,
459
+ lockReason: null,
460
+ prunable: false,
461
+ pruneReason: null,
462
+ }
441
463
  continue
442
464
  }
443
465
  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
466
+ if (record.startsWith('HEAD ')) current.head = record.slice('HEAD '.length)
467
+ else if (record.startsWith('branch ')) current.branch = record.slice('branch '.length).replace('refs/heads/', '')
468
+ else if (record === 'detached') current.detached = true
469
+ else if (record === 'bare') current.bare = true
470
+ else if (record.startsWith('locked')) {
471
+ current.locked = true
472
+ const reason = record.slice('locked'.length).trim()
473
+ current.lockReason = reason === '' ? null : reason
474
+ } else if (record.startsWith('prunable')) {
475
+ current.prunable = true
476
+ const reason = record.slice('prunable'.length).trim()
477
+ current.pruneReason = reason === '' ? null : reason
478
+ }
448
479
  }
449
- if (current !== null) trees.push(current)
450
- return trees
480
+ push()
481
+ return trees.map((tree, index) => Object.assign(tree, {
482
+ primary: index === 0,
483
+ current: sameDirectory(tree.path, root),
484
+ }))
451
485
  }
452
486
 
453
487
  /* ============================== safety ============================== */
@@ -471,7 +505,7 @@ function withRepoLock(key, work) {
471
505
 
472
506
  const WRITE_METHODS = new Set([
473
507
  'stage', 'unstage', 'discard', 'commit', 'checkout', 'branchCreate', 'branchRename', 'branch-upstream',
474
- 'branchDelete', 'merge', 'rebase', 'cherryPick', 'revert', 'reset', 'fetch', 'pull',
508
+ 'branchDelete', 'worktreeAdd', 'worktreeRemove', 'worktreePrune', 'merge', 'rebase', 'cherryPick', 'revert', 'reset', 'fetch', 'pull',
475
509
  'push', 'stashPush', 'stashApply', 'stashDrop', 'stashClear', 'tagCreate', 'tagDelete', 'tagDeleteRemote', 'pushTags', 'clone', 'remoteAdd', 'remoteRemove', 'branchDeleteRemote', 'undoApply',
476
510
  ])
477
511
 
@@ -1025,6 +1059,70 @@ async function branchCreate(payload) {
1025
1059
  return { branch: name }
1026
1060
  }
1027
1061
 
1062
+ /* ---------- worktrees (v0.22, alpha.2) ----------
1063
+ A worktree is a second checkout of the same repository. These three verbs
1064
+ are the whole lifecycle the panel offers: create a checkout (on a new or an
1065
+ existing branch), remove one, and prune the admin records of directories
1066
+ that are already gone.
1067
+
1068
+ Removal is deliberately strict. The current and primary checkouts are
1069
+ refused outright (removing the tree the panel is reading is never what a
1070
+ click meant), a locked worktree must be unlocked first, and a dirty one
1071
+ needs a second request with force:true. The dirty check runs INSIDE the
1072
+ target, not the repository root: git status at the root says nothing
1073
+ about the linked checkout's files. */
1074
+ async function worktreeAdd(payload) {
1075
+ const cwd = cwdOf(payload)
1076
+ const root = await repoRootOf(cwd, payload)
1077
+ const target = requireAbsolute(payload.path, 'path')
1078
+ if (sameDirectory(target, root)) throw badRequest('path must differ from the current worktree')
1079
+ if (existsSync(target)) throw new PanelError('worktree-exists', 'path already exists: ' + target, 409)
1080
+ const branch = requireRef(payload.branch, 'branch')
1081
+ const checked = await runGit(root, ['check-ref-format', '--branch', branch])
1082
+ if (checked.code !== 0) throw badRequest('invalid branch name: ' + branch)
1083
+ const createBranch = payload.createBranch !== false
1084
+ const args = ['worktree', 'add']
1085
+ if (createBranch === true) {
1086
+ args.push('-b', branch, target)
1087
+ if (typeof payload.base === 'string' && payload.base.trim() !== '') args.push(requireRef(payload.base, 'base'))
1088
+ } else {
1089
+ args.push(target, branch)
1090
+ }
1091
+ await git(root, args)
1092
+ return { path: target, branch: branch, created: createBranch }
1093
+ }
1094
+
1095
+ async function worktreeRemove(payload) {
1096
+ const cwd = cwdOf(payload)
1097
+ const root = await repoRootOf(cwd, payload)
1098
+ if (payload.confirm !== true) throw badRequest('removing a worktree requires confirm: true')
1099
+ const target = requireAbsolute(payload.path, 'path')
1100
+ const trees = await worktreesOf(root)
1101
+ const tree = trees.find((entry) => sameDirectory(entry.path, target))
1102
+ if (tree === undefined) throw new PanelError('worktree-missing', 'not a worktree of this repository: ' + target, 409)
1103
+ if (tree.current === true) throw new PanelError('protected-worktree', 'refusing to remove the worktree this panel is showing', 409)
1104
+ if (tree.primary === true) throw new PanelError('protected-worktree', 'refusing to remove the primary worktree', 409)
1105
+ if (tree.locked === true) throw new PanelError('worktree-locked', 'worktree is locked; unlock it before removing: ' + target, 409)
1106
+ if (tree.prunable === true || existsSync(target) === false) throw new PanelError('worktree-missing', 'worktree directory is gone; prune the record instead: ' + target, 409)
1107
+ const dirty = await runGit(target, ['status', '--porcelain=v1', '-z', '--untracked-files=all'])
1108
+ if (dirty.code !== 0) throw new PanelError('worktree-unreadable', 'could not inspect the worktree: ' + target, 409)
1109
+ const hasChanges = dirty.stdout !== ''
1110
+ if (hasChanges === true && payload.force !== true) throw new PanelError('worktree-dirty', 'worktree has uncommitted changes; force removal deletes them: ' + target, 409)
1111
+ const args = ['worktree', 'remove']
1112
+ if (payload.force === true) args.push('--force')
1113
+ args.push(target)
1114
+ await git(root, args)
1115
+ return { path: target, forced: payload.force === true, dirty: hasChanges }
1116
+ }
1117
+
1118
+ async function worktreePrune(payload) {
1119
+ const cwd = cwdOf(payload)
1120
+ const root = await repoRootOf(cwd, payload)
1121
+ if (payload.confirm !== true) throw badRequest('pruning worktrees requires confirm: true')
1122
+ const output = await git(root, ['worktree', 'prune', '-v'])
1123
+ return { output: output.trim() }
1124
+ }
1125
+
1028
1126
  /* Upstream management (issue #18): rebind or drop a branch's tracking remote.
1029
1127
  git refuses --set-upstream-to with a ref it cannot resolve, so a typo dies
1030
1128
  here instead of silently writing branch.<name>.merge. */
@@ -2051,6 +2149,9 @@ const METHODS = {
2051
2149
  branchRename,
2052
2150
  'branch-upstream': branchUpstream,
2053
2151
  branchDelete,
2152
+ worktreeAdd,
2153
+ worktreeRemove,
2154
+ worktreePrune,
2054
2155
  merge,
2055
2156
  rebase,
2056
2157
  cherryPick,