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.
@@ -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
+ }