dsh-ide-git 0.21.2 → 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/README.md +4 -4
- package/README_EN.md +5 -4
- package/dsh.plugin.json +2 -2
- package/package.json +3 -3
- package/src/client.js +865 -8
- package/src/index.js +109 -13
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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 (
|
|
445
|
-
else if (
|
|
446
|
-
else if (
|
|
447
|
-
else if (
|
|
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
|
-
|
|
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,
|