dsh-ide-git 0.23.0 → 0.24.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/dsh.plugin.json +1 -1
- package/package.json +2 -1
- package/src/client.js +153 -12
- package/src/host/commit-message.js +354 -0
- package/src/host/config.js +71 -0
- package/src/host/core.js +121 -0
- package/src/host/git.js +423 -0
- package/src/host/http.js +237 -0
- package/src/host/read.js +348 -0
- package/src/host/repos.js +222 -0
- package/src/host/undo.js +207 -0
- package/src/host/write.js +551 -0
- package/src/index.js +11 -2325
package/src/host/undo.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/** dsh-ide-git host: the short-lived undo stack and its one-shot handlers. */
|
|
2
|
+
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
|
|
5
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
6
|
+
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
|
|
9
|
+
import { PanelError, cwdOf, requireRef } from './core.js'
|
|
10
|
+
|
|
11
|
+
import { git, repoRootOf, runGit } from './git.js'
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
/* Deleting is a real delete — but the host remembers how to bring it back for a
|
|
16
|
+
while, so the panel can offer an undo. Entries are per repository, newest
|
|
17
|
+
last, capped and time-limited. */
|
|
18
|
+
const UNDO_LIMIT = 20
|
|
19
|
+
const UNDO_TTL_MS = 30 * 60 * 1000
|
|
20
|
+
const undoStacks = new Map()
|
|
21
|
+
let undoSeq = 0
|
|
22
|
+
|
|
23
|
+
/** Branches whose deletion is never a routine action. */
|
|
24
|
+
const PROTECTED_BRANCHES = new Set(['main', 'master', 'trunk'])
|
|
25
|
+
|
|
26
|
+
function undoEntriesOf(root) {
|
|
27
|
+
const stored = undoStacks.get(root) === undefined ? [] : undoStacks.get(root)
|
|
28
|
+
const alive = stored.filter((entry) => Date.now() - entry.at < UNDO_TTL_MS)
|
|
29
|
+
if (alive.length !== stored.length) undoStacks.set(root, alive)
|
|
30
|
+
return alive
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function pushUndo(root, entry) {
|
|
34
|
+
const list = undoEntriesOf(root)
|
|
35
|
+
undoSeq += 1
|
|
36
|
+
const record = Object.assign({ id: 'u' + undoSeq.toString(36) + Date.now().toString(36), at: Date.now() }, entry)
|
|
37
|
+
list.push(record)
|
|
38
|
+
while (list.length > UNDO_LIMIT) list.shift()
|
|
39
|
+
undoStacks.set(root, list)
|
|
40
|
+
return { id: record.id, kind: record.kind, label: record.label }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* Undoing a discard means writing the old bytes back, so the host has to keep
|
|
44
|
+
them: a checkout/clean is the one moment they still exist. Snapshots stay
|
|
45
|
+
deliberately conservative — regular files only, capped in count and in total
|
|
46
|
+
size — and anything that cannot be reproduced faithfully (a symlink, a
|
|
47
|
+
special file, a huge tree) yields NO undo handle at all rather than a promise
|
|
48
|
+
the host cannot keep. */
|
|
49
|
+
const UNDO_MAX_FILES = 400
|
|
50
|
+
const UNDO_MAX_SNAPSHOT_BYTES = 4 * 1024 * 1024
|
|
51
|
+
|
|
52
|
+
function hashOf(data) {
|
|
53
|
+
return createHash('sha1').update(data).digest('hex')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Repo-relative POSIX path of a target, or null when it escapes the repo. */
|
|
57
|
+
function relInside(root, target) {
|
|
58
|
+
const rel = path.relative(root, target)
|
|
59
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return null
|
|
60
|
+
return rel.split(path.sep).join('/')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Read every regular file the given paths cover (each path may be a file or a
|
|
65
|
+
* directory). Returns null when the snapshot would not be faithful, which makes
|
|
66
|
+
* the caller skip the undo handle instead of offering one it cannot honour.
|
|
67
|
+
*/
|
|
68
|
+
function snapshotForUndo(root, paths) {
|
|
69
|
+
const files = []
|
|
70
|
+
let bytes = 0
|
|
71
|
+
const collect = (target) => {
|
|
72
|
+
const rel = relInside(root, target)
|
|
73
|
+
if (rel === null) return true
|
|
74
|
+
let stat
|
|
75
|
+
try {
|
|
76
|
+
stat = lstatSync(target)
|
|
77
|
+
} catch (error) {
|
|
78
|
+
// Already gone ('D' in the working tree) is itself a state worth undoing
|
|
79
|
+
// to, so record the absence. Any other failure is not ours to guess at.
|
|
80
|
+
if (error.code === 'ENOENT') { files.push({ rel: rel, data: null }); return true }
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
if (stat.isSymbolicLink()) return false
|
|
84
|
+
if (stat.isDirectory()) {
|
|
85
|
+
if (path.basename(target) === '.git') return true
|
|
86
|
+
let names
|
|
87
|
+
try { names = readdirSync(target) } catch { return false }
|
|
88
|
+
for (const name of names) if (!collect(path.join(target, name))) return false
|
|
89
|
+
return true
|
|
90
|
+
}
|
|
91
|
+
if (!stat.isFile()) return false
|
|
92
|
+
if (files.length >= UNDO_MAX_FILES) return false
|
|
93
|
+
let data
|
|
94
|
+
try { data = readFileSync(target) } catch { return false }
|
|
95
|
+
bytes += data.length
|
|
96
|
+
if (bytes > UNDO_MAX_SNAPSHOT_BYTES) return false
|
|
97
|
+
files.push({ rel: rel, data: data })
|
|
98
|
+
return true
|
|
99
|
+
}
|
|
100
|
+
for (const entry of paths) if (!collect(path.resolve(root, entry))) return null
|
|
101
|
+
return files
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The shape the snapshot targets have right now: a content hash, or null when
|
|
106
|
+
* the path is absent. The undo compares against this before writing anything,
|
|
107
|
+
* so content typed after the discard is never silently overwritten.
|
|
108
|
+
*/
|
|
109
|
+
function snapshotState(root, files) {
|
|
110
|
+
return files.map((file) => {
|
|
111
|
+
try {
|
|
112
|
+
return { rel: file.rel, hash: hashOf(readFileSync(path.join(root, file.rel))) }
|
|
113
|
+
} catch {
|
|
114
|
+
return { rel: file.rel, hash: null }
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** .git markers that mean a multi-step operation is still in progress. */
|
|
120
|
+
async function operationOf(root) {
|
|
121
|
+
const result = await runGit(root, ['rev-parse', '--absolute-git-dir'])
|
|
122
|
+
if (result.code !== 0) return null
|
|
123
|
+
const gitDir = result.stdout.trim()
|
|
124
|
+
if (gitDir === '') return null
|
|
125
|
+
const markers = [
|
|
126
|
+
['merge', 'MERGE_HEAD'],
|
|
127
|
+
['rebase', 'rebase-merge'],
|
|
128
|
+
['rebase', 'rebase-apply'],
|
|
129
|
+
['cherry-pick', 'CHERRY_PICK_HEAD'],
|
|
130
|
+
['revert', 'REVERT_HEAD'],
|
|
131
|
+
['bisect', 'BISECT_LOG'],
|
|
132
|
+
]
|
|
133
|
+
for (const marker of markers) {
|
|
134
|
+
if (existsSync(path.join(gitDir, marker[1]))) return marker[0]
|
|
135
|
+
}
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** What this repository can still undo right now (newest last). */
|
|
140
|
+
async function undoList(payload) {
|
|
141
|
+
const cwd = cwdOf(payload)
|
|
142
|
+
const root = await repoRootOf(cwd, payload)
|
|
143
|
+
const items = undoEntriesOf(root).map((entry) => ({ id: entry.id, kind: entry.kind, label: entry.label, at: entry.at }))
|
|
144
|
+
return { items }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Undo one remembered action. Nothing here is a 'soft delete': the branch (or
|
|
149
|
+
* stash entry) really was gone, and the undo recreates it from the object id
|
|
150
|
+
* recorded before the deletion; a discard really overwrote the working tree,
|
|
151
|
+
* and the undo writes the snapshotted bytes back. It is one-shot and fails
|
|
152
|
+
* loudly when the world moved on — the name is taken again, or the discarded
|
|
153
|
+
* files were edited since.
|
|
154
|
+
*/
|
|
155
|
+
async function undoApply(payload) {
|
|
156
|
+
const cwd = cwdOf(payload)
|
|
157
|
+
const root = await repoRootOf(cwd, payload)
|
|
158
|
+
const id = requireRef(payload.id, 'id')
|
|
159
|
+
const list = undoEntriesOf(root)
|
|
160
|
+
const index = list.findIndex((entry) => entry.id === id)
|
|
161
|
+
if (index < 0) throw new PanelError('undo-gone', 'this action can no longer be undone', 409)
|
|
162
|
+
const entry = list[index]
|
|
163
|
+
if (entry.kind === 'branch-delete') {
|
|
164
|
+
/* Batch handles carry `branches: [{name, hash}]`; older single-branch
|
|
165
|
+
handles only have `name`/`hash` — normalise both to one list. */
|
|
166
|
+
const restores = Array.isArray(entry.branches) ? entry.branches : [{ name: entry.name, hash: entry.hash }]
|
|
167
|
+
for (const item of restores) {
|
|
168
|
+
const exists = await runGit(root, ['show-ref', '--verify', '--quiet', 'refs/heads/' + item.name])
|
|
169
|
+
if (exists.code === 0) throw new PanelError('undo-conflict', 'branch "' + item.name + '" already exists again', 409)
|
|
170
|
+
await git(root, ['branch', item.name, item.hash])
|
|
171
|
+
}
|
|
172
|
+
} else if (entry.kind === 'stash-drop') {
|
|
173
|
+
await git(root, ['stash', 'store', '-m', 'dsh-ide-git: restored ' + entry.label, entry.hash])
|
|
174
|
+
} else if (entry.kind === 'discard') {
|
|
175
|
+
// Refuse to trample anything typed since the discard: every target must
|
|
176
|
+
// still look exactly the way the discard left it.
|
|
177
|
+
const now = snapshotState(root, entry.files)
|
|
178
|
+
for (let position = 0; position < now.length; position += 1) {
|
|
179
|
+
if (now[position].hash !== entry.after[position].hash) {
|
|
180
|
+
throw new PanelError('undo-conflict', 'these files changed after the discard', 409)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
for (const file of entry.files) {
|
|
184
|
+
const target = path.join(root, file.rel)
|
|
185
|
+
if (file.data === null) {
|
|
186
|
+
// The file was absent before the discard: absent is what we restore.
|
|
187
|
+
try { unlinkSync(target) } catch { /* already gone */ }
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
mkdirSync(path.dirname(target), { recursive: true })
|
|
191
|
+
writeFileSync(target, file.data)
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
throw new PanelError('undo-unsupported', 'this action cannot be undone', 500)
|
|
195
|
+
}
|
|
196
|
+
list.splice(index, 1)
|
|
197
|
+
undoStacks.set(root, list)
|
|
198
|
+
return { kind: entry.kind, label: entry.label }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export {
|
|
202
|
+
|
|
203
|
+
PROTECTED_BRANCHES, undoEntriesOf, pushUndo, snapshotForUndo, snapshotState,
|
|
204
|
+
|
|
205
|
+
operationOf, undoList, undoApply,
|
|
206
|
+
|
|
207
|
+
}
|