k0-board 0.3.0 → 0.5.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.
Files changed (82) hide show
  1. package/.claude/skills/k0-changelog/SKILL.md +2 -2
  2. package/.claude/skills/k0-discuss/SKILL.md +105 -0
  3. package/.claude/skills/k0-discuss/references/rounds.md +280 -0
  4. package/.claude/skills/k0-epic/SKILL.md +196 -0
  5. package/.claude/skills/k0-links/.claude-plugin/plugin.json +6 -0
  6. package/.claude/skills/k0-links/hooks/hooks.json +1 -0
  7. package/.claude/skills/k0-links/hooks/links.js +128 -0
  8. package/.claude/skills/k0-links/hooks/register.js +204 -0
  9. package/.claude/skills/k0-next/SKILL.md +82 -0
  10. package/.claude/skills/k0-order/SKILL.md +123 -0
  11. package/.claude/skills/k0-plan/SKILL.md +156 -0
  12. package/.claude/skills/k0-split/SKILL.md +169 -0
  13. package/.claude/skills/k0-story/SKILL.md +108 -0
  14. package/.claude/skills/k0-ultracode/SKILL.md +251 -0
  15. package/.claude/skills/k0-ultracode/references/agent-brief.md +71 -0
  16. package/.claude/skills/k0-ultracode/references/modes.md +49 -0
  17. package/.claude/skills/k0-verify/SKILL.md +219 -0
  18. package/.claude/skills/k0-whatsnew/SKILL.md +95 -0
  19. package/.claude/skills/k0-work/SKILL.md +212 -0
  20. package/CHANGELOG.md +426 -1
  21. package/README.md +1021 -119
  22. package/bin/k0-board.js +81 -11
  23. package/menubar/K0MenuBar.swift +35 -5
  24. package/package.json +13 -1
  25. package/platform/contract.js +37 -6
  26. package/platform/darwin/index.js +1 -0
  27. package/platform/darwin/shell.js +4 -1
  28. package/platform/darwin/terminal.js +230 -56
  29. package/platform/index.js +3 -1
  30. package/platform/linux/index.js +14 -0
  31. package/platform/linux/shell.js +10 -1
  32. package/platform/linux/tray.py +10 -10
  33. package/platform/shared/command.js +19 -3
  34. package/platform/shared/tmux.js +32 -14
  35. package/platform/win32/index.js +6 -2
  36. package/platform/win32/shell.js +4 -1
  37. package/platform/win32/terminal.js +24 -8
  38. package/platform/win32/tray.ps1 +13 -13
  39. package/server/backlog.js +618 -0
  40. package/server/changelog.js +13 -13
  41. package/server/db.js +1800 -146
  42. package/server/files.js +156 -26
  43. package/server/git.js +53 -14
  44. package/server/idle.js +15 -13
  45. package/server/index.js +1286 -189
  46. package/server/launcher.js +122 -14
  47. package/server/machine.js +10 -1
  48. package/server/mirror.js +1048 -0
  49. package/server/prices.js +59 -0
  50. package/server/projects.js +57 -12
  51. package/server/servers.js +22 -1
  52. package/server/sessions.js +5 -5
  53. package/server/settings.js +25 -2
  54. package/server/update.js +324 -0
  55. package/server/usage.js +642 -0
  56. package/server/watcher.js +67 -13
  57. package/server/worktree.js +600 -0
  58. package/server/writer.js +98 -26
  59. package/web/awake.js +62 -0
  60. package/web/base.css +4 -0
  61. package/web/board.css +404 -16
  62. package/web/board.js +1182 -152
  63. package/web/changelog.js +3 -3
  64. package/web/conf.js +118 -0
  65. package/web/files.css +206 -1
  66. package/web/files.html +10 -0
  67. package/web/files.js +533 -75
  68. package/web/fuzzy.js +73 -21
  69. package/web/index.html +109 -12
  70. package/web/json.js +145 -0
  71. package/web/list.css +536 -0
  72. package/web/list.js +1015 -0
  73. package/web/mentions.js +27 -9
  74. package/web/recency.js +10 -10
  75. package/web/units.js +1 -1
  76. package/web/usage.css +169 -0
  77. package/web/usage.html +78 -0
  78. package/web/usage.js +447 -0
  79. package/web/view.js +22 -3
  80. package/web/whatsnew.css +211 -0
  81. package/web/whatsnew.html +49 -0
  82. package/web/whatsnew.js +339 -0
@@ -0,0 +1,600 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { run } from '../platform/shared/run.js'
4
+ // One reading of `status --porcelain=v2` for the whole server, and one place that knows where git
5
+ // is. git.js already has both, and two of either would drift until the day they disagreed and
6
+ // nobody noticed.
7
+ import { parseStatus, gitPath } from './git.js'
8
+ // A worktree directory and a `.k0/` file are named for the same story by the same rule. Two
9
+ // spellings of "the same rule" is what the comment above is about: mirror.js trims the dashes off
10
+ // before it cuts at forty characters, and a copy that cut first was already a character out.
11
+ import { slug } from './mirror.js'
12
+ import { getStory, patchSession } from './db.js'
13
+
14
+ // ── The worktree ─────────────────────────────────────────────────────────────
15
+ // A session that is going to change code can have a working copy of its own: a git worktree
16
+ // under `<repo>/.claude/worktrees/`, on a branch of its own, opened from the branch you are
17
+ // standing on right now. You keep working in the repository; the session works next door; at the
18
+ // end its branch comes back in as one merge commit and the copy is destroyed.
19
+ //
20
+ // Three things this module will never do, each of which has already cost somebody a day:
21
+ //
22
+ // It never pushes, and never opens a pull request. The merge stops on this machine. Publishing
23
+ // is a decision you make when you are ready to make it, and a button that quietly made it for
24
+ // you would be the last time you trusted the button.
25
+ //
26
+ // It never runs tests, a build, or a dev server — not here, not as a favour on the way past. A
27
+ // worktree copies the code but not the untracked files around it, so the `.env` somebody copies
28
+ // across to make it run carries the BASE repository's port. A test runner that finds a server
29
+ // already listening there uses it, goes green, and reports that your new code passes when what
30
+ // it really tested was the old checkout. That is a silent false pass, which is why this is a
31
+ // rule and not a preference: tests run on the base branch, after the merge, where the server
32
+ // serves the code you just wrote.
33
+ //
34
+ // That rule is about what THIS module runs, and it has one edge worth being straight about,
35
+ // because the two rules of this file meet there and cannot both be comfortable. Merging commits
36
+ // whatever the session left pending, a commit runs the repository's own hooks, and a `pre-commit`
37
+ // hook that runs the whole suite is normal in the repositories k0 is used on — so on the ordinary
38
+ // merge path a test suite does run inside the copy, with the wrong `.env` next to it. Stepping
39
+ // round it with `--no-verify` is not the answer: the hooks are the repository's own opinion of
40
+ // what may be committed, and a commit that only went through because k0 avoided them has not been
41
+ // checked at all. So the hook runs, and the answer here is to say so rather than to be quiet
42
+ // about it: `merge` reports `hooks: true` whenever it made a commit, and whoever shows the result
43
+ // tells the user their pre-commit hook ran next door.
44
+ //
45
+ // It never resolves a conflict. There is no way for a program to tell a conflict it has resolved
46
+ // correctly from one it has resolved plausibly, and taking one side to make the command succeed
47
+ // throws away work that somebody typed. A conflict stops everything and says which files.
48
+
49
+ // Reading something git already knows: a status, a branch name, a count.
50
+ const OPTS = { timeout: 10000, maxBuffer: 1 << 20 }
51
+ // Moving files about: `worktree add`, `merge`, `worktree remove`. Slower, and worth waiting for.
52
+ const MOVE = { timeout: 120000, maxBuffer: 1 << 22 }
53
+ // A commit runs the repository's own hooks, and a pre-commit hook that runs the whole test suite
54
+ // is normal in the repositories k0 is used on. Cutting that off after a few seconds would leave a
55
+ // half-made commit and make it look like git's fault.
56
+ const COMMIT = { timeout: 15 * 60 * 1000, maxBuffer: 1 << 24 }
57
+
58
+ const git = (args, opts = OPTS) => run(gitPath(), args, opts)
59
+
60
+ const MAX_SUBJECT = 72
61
+ const MAX_DETAIL = 2000
62
+
63
+ // ── Failures ─────────────────────────────────────────────────────────────────
64
+ // Everything here answers with a sentence, never with git's own words alone. `fatal: 'wt-K42' is
65
+ // already checked out at …` tells you nothing about what k0 was trying to do or what to do next.
66
+ // What git said is kept, in `detail`, for the person who does want to read it.
67
+
68
+ function fail(error, detail = '') {
69
+ return { ok: false, error, detail: String(detail).trim().slice(-MAX_DETAIL) }
70
+ }
71
+
72
+ /**
73
+ * What git actually said, out of a rejected child process. Exported so it can be proved on its
74
+ * own: the failures it exists for are the ones a test cannot afford to wait for.
75
+ */
76
+ export function words(err) {
77
+ const said = `${err?.stderr ?? ''}\n${err?.stdout ?? ''}`.trim()
78
+ if (said) return said.split('\n').filter(Boolean).slice(-12).join('\n')
79
+ // execFile puts the whole command line in `message`, which is noise here: the caller already
80
+ // knows what was being run, and the paths in it are long enough to hide the reason.
81
+ //
82
+ // A git that ran out of time is the one failure worth naming, and it does not arrive carrying
83
+ // `ETIMEDOUT`: that is what the SYNCHRONOUS calls set. `execFile` gives up by killing the child,
84
+ // and all that says so afterwards is `killed`. Reading only the code meant every timeout — the
85
+ // failure most likely to leave nothing on stderr at all — reported git's own command line back.
86
+ if (err?.code === 'ETIMEDOUT' || err?.killed) return 'git took too long and was given up on.'
87
+ return String(err?.message ?? '')
88
+ }
89
+
90
+ // ── Names ────────────────────────────────────────────────────────────────────
91
+
92
+ /**
93
+ * The title, made safe for a directory name — the rule mirror.js names `.k0/` files by, and the
94
+ * same call, not the same idea written out twice. A title with nothing alphanumeric in it slugs to
95
+ * nothing, and a directory has to be called something.
96
+ */
97
+ export const dirName = (title) => slug(title) || 'story'
98
+
99
+ // ── Looking around ───────────────────────────────────────────────────────────
100
+
101
+ /**
102
+ * The branch and how much is pending, in one process.
103
+ *
104
+ * `--untracked-files=no` is the interesting half. A worktree lives INSIDE the repository, so
105
+ * counting untracked files would count the worktree itself, and every check here would refuse
106
+ * for a directory k0 made. Untracked files are also the ones a merge cannot destroy. So safety
107
+ * is judged on tracked changes; only "is there anything to commit in here" asks for `all`.
108
+ */
109
+ async function statusOf(dir, untracked = 'no') {
110
+ const out = await git([
111
+ '--no-optional-locks',
112
+ '-C',
113
+ dir,
114
+ 'status',
115
+ '--porcelain=v2',
116
+ '--branch',
117
+ `--untracked-files=${untracked}`,
118
+ ])
119
+ return parseStatus(out)
120
+ }
121
+
122
+ // A repository stopped halfway through something. Merging into it, or committing out of it,
123
+ // would finish somebody else's operation for them with the wrong content.
124
+ const HALFWAY = [
125
+ ['MERGE_HEAD', 'a merge'],
126
+ ['CHERRY_PICK_HEAD', 'a cherry-pick'],
127
+ ['REVERT_HEAD', 'a revert'],
128
+ ['rebase-merge', 'a rebase'],
129
+ ['rebase-apply', 'a rebase'],
130
+ ['BISECT_LOG', 'a bisect'],
131
+ ]
132
+
133
+ /** The operation this directory is in the middle of, or null when it is idle. */
134
+ async function halfway(dir) {
135
+ const where = await git(['-C', dir, 'rev-parse', '--git-dir'])
136
+ .then((out) => path.resolve(dir, out.trim()))
137
+ .catch(() => null)
138
+ if (!where) return null
139
+ for (const [marker, name] of HALFWAY) if (fs.existsSync(path.join(where, marker))) return name
140
+ return null
141
+ }
142
+
143
+ /**
144
+ * The repository a worktree belongs to. `--git-common-dir` is the shared `.git` of the whole
145
+ * repository, which is the only thing that points home: the worktree's own `.git` is a file
146
+ * holding a path, and its `project_path` on the story could have been renamed since.
147
+ */
148
+ async function repoOf(worktree) {
149
+ const [own, common] = await Promise.all([
150
+ git(['-C', worktree, 'rev-parse', '--git-dir']).then((o) => path.resolve(worktree, o.trim())),
151
+ git(['-C', worktree, 'rev-parse', '--git-common-dir']).then((o) => path.resolve(worktree, o.trim())),
152
+ ]).catch(() => [null, null])
153
+ if (!own || !common) return null
154
+ if (own === common) return null // the repository itself, not a worktree of it
155
+ if (path.basename(common) !== '.git') return null // a bare repository has no branch to merge into
156
+ return path.dirname(common)
157
+ }
158
+
159
+ /**
160
+ * Which branch a worktree branch came from.
161
+ *
162
+ * It is remembered as the branch's upstream, and that is not a trick: git already has one field
163
+ * per branch for "where this belongs", it dies with the branch, and `git status` inside the
164
+ * worktree reads it out loud — *ahead of 'feat/backlog' by 3 commits*. A note in a file of our
165
+ * own would survive the branch it describes and be wrong the second time.
166
+ */
167
+ async function baseOf(repo, branch) {
168
+ const name = await git(['-C', repo, 'rev-parse', '--abbrev-ref', '--symbolic-full-name', `${branch}@{upstream}`])
169
+ .then((out) => out.trim())
170
+ .catch(() => '')
171
+ if (!name) return null
172
+ // It has to be a branch on this machine. An upstream on a remote would mean merging something
173
+ // that is not here, and k0 does not go to the network for anything.
174
+ const local = await git(['-C', repo, 'show-ref', '--verify', '--quiet', `refs/heads/${name}`]).then(
175
+ () => true,
176
+ () => false
177
+ )
178
+ return local ? name : null
179
+ }
180
+
181
+ /** How many commits the worktree has that its base branch has not. */
182
+ async function ahead(repo, base, branch) {
183
+ const out = await git(['-C', repo, 'rev-list', '--count', `${base}..${branch}`]).catch(() => '0')
184
+ return Number(out.trim()) || 0
185
+ }
186
+
187
+ const branchExists = (repo, branch) =>
188
+ git(['-C', repo, 'show-ref', '--verify', '--quiet', `refs/heads/${branch}`]).then(
189
+ () => true,
190
+ () => false
191
+ )
192
+
193
+ /**
194
+ * Keep the worktrees folder out of the user's way.
195
+ *
196
+ * The worktree sits inside the repository, so without this every `git status` the user runs
197
+ * grows an untracked directory they never made, and the board's own dirty count grows with it.
198
+ * The line goes in `.git/info/exclude` and never in `.gitignore`: `.gitignore` is committed and
199
+ * shared with the user's colleagues, and k0's private working copies are none of their business.
200
+ */
201
+ async function keepOutOfSight(repo, dir) {
202
+ // `check-ignore` exits 1 when the path is not ignored, which arrives here as a rejection.
203
+ const already = await git(['-C', repo, 'check-ignore', '-q', dir]).then(
204
+ () => true,
205
+ () => false
206
+ )
207
+ if (already) return
208
+ try {
209
+ const common = await git(['-C', repo, 'rev-parse', '--git-common-dir']).then((o) => path.resolve(repo, o.trim()))
210
+ const file = path.join(common, 'info', 'exclude')
211
+ const line = '/.claude/worktrees/'
212
+ const text = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''
213
+ if (text.split('\n').some((l) => l.trim() === line)) return
214
+ fs.mkdirSync(path.dirname(file), { recursive: true })
215
+ const gap = text && !text.endsWith('\n') ? '\n' : ''
216
+ fs.appendFileSync(file, `${gap}# k0 keeps a session's working copy here.\n${line}\n`)
217
+ } catch {
218
+ // A repository that will not take the line still works; the folder just shows up as
219
+ // untracked. Not worth refusing to open a worktree over.
220
+ }
221
+ }
222
+
223
+ // ── Opening ──────────────────────────────────────────────────────────────────
224
+
225
+ /**
226
+ * A working copy for this session, on a branch of its own.
227
+ *
228
+ * From the branch you are on, deliberately — not `main`, not `origin/main`. Work started on a
229
+ * feature branch belongs on that feature branch, and a worktree that quietly branched off main
230
+ * would come back at the end carrying every difference between the two.
231
+ *
232
+ * @param {object} session the `session` row: `id`, `story_id`, `work_path`
233
+ * @param {object} [story] its story, when the caller already has it
234
+ */
235
+ export async function open(session, story = null) {
236
+ if (!gitPath()) return fail('k0 cannot find git on this machine, so it cannot open a worktree.')
237
+ if (!session?.id) return fail('A worktree belongs to a session, and this request does not name one.')
238
+
239
+ const s = story ?? (session.story_id ? getStory(session.story_id) : null)
240
+ if (!s) return fail('That session no longer belongs to a story, so there is nothing to open a worktree for.')
241
+
242
+ const repo = s.project_path
243
+ if (!repo || !fs.existsSync(path.join(repo, '.git'))) {
244
+ return fail(`${repo || 'That folder'} is not a git repository, so k0 cannot open a worktree in it.`)
245
+ }
246
+ if (session.work_path && fs.existsSync(session.work_path)) {
247
+ return fail(`This session is already working in ${session.work_path}. Merge that back before opening another.`)
248
+ }
249
+
250
+ const key = `K${s.key_num}`
251
+ const dir = path.join(repo, '.claude', 'worktrees', `${key}-${dirName(s.title)}`)
252
+ const branch = `wt-${key}`
253
+ if (fs.existsSync(dir)) {
254
+ return fail(`There is already a folder at ${dir}. Move it out of the way, or remove it, and ask again.`)
255
+ }
256
+ if (await branchExists(repo, branch)) {
257
+ return fail(`The branch ${branch} is still here from an earlier worktree for ${key}. Merge it or delete it first.`)
258
+ }
259
+
260
+ const here = await statusOf(repo).catch(() => null)
261
+ if (!here) return fail(`git would not say what state ${repo} is in, so k0 stopped rather than guess.`)
262
+ if (!here.branch) {
263
+ return fail(`${repo} is not on a branch right now, so there is nothing for the worktree to branch off.`)
264
+ }
265
+ const busy = await halfway(repo)
266
+ if (busy) {
267
+ return fail(`${repo} is in the middle of ${busy}. Finish that first: a worktree opened now would inherit it.`)
268
+ }
269
+ if (here.dirty) {
270
+ return fail(
271
+ `There ${here.dirty === 1 ? 'is 1 change' : `are ${here.dirty} changes`} not committed in ${repo}. ` +
272
+ 'The worktree would start from the last commit and not see them, and they would be in the way of the ' +
273
+ 'merge at the end. Commit or stash them first.'
274
+ )
275
+ }
276
+
277
+ const base = here.branch
278
+ try {
279
+ await git(['-C', repo, 'worktree', 'add', '-b', branch, dir, base], MOVE)
280
+ } catch (err) {
281
+ return fail(`git would not open a worktree at ${dir}.`, words(err))
282
+ }
283
+
284
+ // Where it came from, written down before anything else can happen to it. Without this the
285
+ // merge at the end has nowhere to go, so a worktree that cannot record it is not left standing.
286
+ try {
287
+ await git(['-C', repo, 'branch', `--set-upstream-to=${base}`, branch])
288
+ } catch (err) {
289
+ await git(['-C', repo, 'worktree', 'remove', '--force', dir], MOVE).catch(() => {})
290
+ await git(['-C', repo, 'branch', '-D', branch]).catch(() => {})
291
+ return fail(
292
+ `k0 could not record that ${branch} came from ${base}, so it undid the worktree rather than leave one ` +
293
+ 'it would not know how to merge back.',
294
+ words(err)
295
+ )
296
+ }
297
+
298
+ await keepOutOfSight(repo, dir)
299
+ patchSession(session.id, { work_path: dir })
300
+ return { ok: true, path: dir, branch, base, repo }
301
+ }
302
+
303
+ // ── The commit message ───────────────────────────────────────────────────────
304
+
305
+ /**
306
+ * The files in the staged diff, out of `--name-status -z`.
307
+ *
308
+ * `-z` because git quotes and escapes paths that are not plain ASCII otherwise, and a rename
309
+ * carries two paths where everything else carries one.
310
+ */
311
+ export function parseNameStatus(out) {
312
+ const parts = String(out ?? '').split('\0')
313
+ const files = []
314
+ for (let i = 0; i < parts.length; i++) {
315
+ const code = parts[i]
316
+ if (!code) continue
317
+ const letter = code[0]
318
+ const renamed = letter === 'R' || letter === 'C'
319
+ const p = parts[i + (renamed ? 2 : 1)]
320
+ if (p) files.push({ status: letter, path: p })
321
+ i += renamed ? 2 : 1
322
+ }
323
+ return files
324
+ }
325
+
326
+ // `.txt` is deliberately not here. In a repository a stray `.txt` is a fixture, a data file or a
327
+ // note far more often than it is documentation, and one of them is enough to make a whole commit
328
+ // call itself `docs:` — so a story named "fix the invoicing API" ends up in `git log` as a
329
+ // documentation change. `.md`, `.mdx`, `.adoc` and anything under `docs/` are what documentation
330
+ // actually looks like here.
331
+ const DOC = /\.(md|mdx|adoc)$/i
332
+ const CHORE = /^(\.claude|\.github|\.vscode|\.editorconfig|\.gitignore|package(-lock)?\.json)/
333
+ const VERB = { A: 'add', D: 'remove', R: 'rename', C: 'copy' }
334
+
335
+ /**
336
+ * Which Conventional Commits type this diff is.
337
+ *
338
+ * A guess, and it says so: only the person who wrote the code knows whether it fixed anything.
339
+ * But the alternative is a commit with no type at all, and the shape of the diff is right often
340
+ * enough — documentation is documentation, a new file is usually a feature — that the guess is
341
+ * worth making. Nobody should be blocked from committing over a word.
342
+ */
343
+ function commitType(files) {
344
+ const paths = files.map((f) => f.path)
345
+ if (!paths.length) return 'chore'
346
+ if (paths.every((p) => DOC.test(p) || p.startsWith('docs/'))) return 'docs'
347
+ if (paths.every((p) => p.startsWith('test/') || p.includes('.test.'))) return 'test'
348
+ if (paths.every((p) => CHORE.test(p))) return 'chore'
349
+ if (files.some((f) => f.status === 'A')) return 'feat'
350
+ return 'fix'
351
+ }
352
+
353
+ /** The longest directory every one of these paths is under, with its trailing slash. */
354
+ function commonDir(paths) {
355
+ const first = paths[0].split('/').slice(0, -1)
356
+ let n = first.length
357
+ for (const p of paths) {
358
+ const parts = p.split('/').slice(0, -1)
359
+ let i = 0
360
+ while (i < n && i < parts.length && parts[i] === first[i]) i++
361
+ n = i
362
+ }
363
+ return n ? `${first.slice(0, n).join('/')}/` : ''
364
+ }
365
+
366
+ /** What moved, when there is nothing better to say than what moved. */
367
+ function describe(files) {
368
+ if (!files.length) return 'save the work in progress'
369
+ if (files.length <= 3) return files.map((f) => `${VERB[f.status] ?? 'update'} ${f.path}`).join(', ')
370
+ const dir = commonDir(files.map((f) => f.path))
371
+ return dir ? `update ${files.length} files under ${dir}` : `update ${files.length} files`
372
+ }
373
+
374
+ /** A title as the first words of a sentence: no full stop, and not shouting unless it means to. */
375
+ function subjectOf(story) {
376
+ const t = String(story?.title ?? '')
377
+ .trim()
378
+ .replace(/\s+/g, ' ')
379
+ .replace(/[.\s]+$/, '')
380
+ if (!t) return ''
381
+ // `API` and `k0` are written the way they are on purpose; `Fix the parser` is just a heading.
382
+ return /^[A-Z][a-z]/.test(t) ? t[0].toLowerCase() + t.slice(1) : t
383
+ }
384
+
385
+ function cut(text, n) {
386
+ if (text.length <= n) return text
387
+ const space = text.lastIndexOf(' ', n)
388
+ return text.slice(0, space > 20 ? space : n).trim()
389
+ }
390
+
391
+ /**
392
+ * One line, imperative, English — the message for whatever the session left uncommitted.
393
+ *
394
+ * The diff decides the type. The subject is the story's title, when there is a story: a list of
395
+ * paths says what moved, not what changed, and `git log` is read by people. The title is the one
396
+ * sentence in the whole database that a person wrote about this piece of work. Without a story
397
+ * the diff has to speak for itself.
398
+ */
399
+ export function commitMessage(files, story = null) {
400
+ const type = commitType(files)
401
+ const subject = subjectOf(story) || describe(files)
402
+ return `${type}: ${cut(subject, MAX_SUBJECT - type.length - 2)}`
403
+ }
404
+
405
+ // ── Merging back ─────────────────────────────────────────────────────────────
406
+
407
+ /**
408
+ * Commit whatever is pending in the worktree, merge the branch into the one it came from, and
409
+ * destroy the copy. Local, always: no push, no pull request, no remote of any kind.
410
+ *
411
+ * @param {object} session the `session` row, carrying `work_path`
412
+ */
413
+ export async function merge(session) {
414
+ if (!gitPath()) return fail('k0 cannot find git on this machine, so it cannot merge this worktree back.')
415
+ if (!session?.id) return fail('A worktree belongs to a session, and this request does not name one.')
416
+ const dir = session.work_path
417
+ if (!dir) return fail('This session has no worktree, so there is nothing to merge back.')
418
+ if (!fs.existsSync(path.join(dir, '.git'))) {
419
+ return fail(`The worktree at ${dir} is not there any more, so there is nothing left to merge.`)
420
+ }
421
+
422
+ const repo = await repoOf(dir)
423
+ if (!repo) return fail(`${dir} is not a worktree of a repository k0 can merge into, so it stopped.`)
424
+
425
+ const here = await statusOf(dir, 'all').catch(() => null)
426
+ if (!here) return fail(`git would not say what is in ${dir}, so k0 stopped rather than guess.`)
427
+ if (!here.branch) return fail(`The worktree at ${dir} is not on a branch, so there is nothing to merge.`)
428
+ const branch = here.branch
429
+ const busy = await halfway(dir)
430
+ if (busy) return fail(`The worktree is in the middle of ${busy}. Finish that inside it, then merge.`)
431
+
432
+ const base = await baseOf(repo, branch)
433
+ if (!base) {
434
+ return fail(
435
+ `k0 cannot tell which branch ${branch} came from, so it will not guess where to merge it. ` +
436
+ 'Merge it by hand, or delete it if the work is gone.'
437
+ )
438
+ }
439
+
440
+ const story = session.story_id ? getStory(session.story_id) : null
441
+ let message = null
442
+ // Whether a commit was made in the copy, which is the same question as whether the repository's
443
+ // hooks ran in the copy. See the header: the module does not run the suite, but a `pre-commit`
444
+ // hook does, and the caller is told rather than left to wonder why the merge took four minutes.
445
+ let hooks = false
446
+ if (here.dirty) {
447
+ try {
448
+ await git(['-C', dir, 'add', '-A'], MOVE)
449
+ } catch (err) {
450
+ return fail(`Nothing in ${dir} could be staged, so there is nothing safe to commit.`, words(err))
451
+ }
452
+ const staged = await git(['-C', dir, 'diff', '--cached', '--name-status', '-z'], MOVE).catch(() => '')
453
+ const files = parseNameStatus(staged)
454
+ if (files.length) {
455
+ message = commitMessage(files, story)
456
+ hooks = true
457
+ try {
458
+ // No `--no-verify`, ever. The hooks are the repository's own opinion of what may be
459
+ // committed, and a session that only passes because k0 stepped round them has not passed.
460
+ // This is the line the header's third rule brushes against: the hook runs here, in the
461
+ // copy, with the copied `.env` beside it. It is the repository's hook and its decision.
462
+ await git(['-C', dir, 'commit', '-m', message], COMMIT)
463
+ } catch (err) {
464
+ return {
465
+ ...fail(
466
+ `The commit in ${dir} was refused, so the merge did not happen and nothing was lost. ` +
467
+ 'A commit hook usually says why below — and it ran inside the worktree, not in the ' +
468
+ 'repository, so a test it ran there tested the copy.',
469
+ words(err)
470
+ ),
471
+ hooks,
472
+ branch,
473
+ base,
474
+ path: dir,
475
+ }
476
+ }
477
+ }
478
+ }
479
+
480
+ const commits = await ahead(repo, base, branch)
481
+
482
+ const there = await statusOf(repo).catch(() => null)
483
+ if (!there) return fail(`git would not say what state ${repo} is in, so k0 stopped rather than merge blind.`)
484
+ if (there.branch !== base) {
485
+ return fail(
486
+ `${repo} is on ${there.branch ?? 'no branch'} now, and this work belongs on ${base}. ` +
487
+ `Go back to ${base} and ask again — k0 does not change the branch you are standing on.`
488
+ )
489
+ }
490
+ const alsoBusy = await halfway(repo)
491
+ if (alsoBusy) return fail(`${repo} is in the middle of ${alsoBusy}. Finish that first, then merge.`)
492
+ if (there.dirty) {
493
+ return fail(
494
+ `There ${there.dirty === 1 ? 'is 1 change' : `are ${there.dirty} changes`} not committed in ${repo}. ` +
495
+ 'A merge on top of them either refuses halfway or buries them. Commit or stash them first.'
496
+ )
497
+ }
498
+
499
+ if (commits) {
500
+ try {
501
+ // `--no-ff` so the session stays one shape in the history: a merge commit naming the
502
+ // branch, with its own commits underneath. `--no-edit` because nothing here can open an
503
+ // editor and wait for somebody.
504
+ await git(['-C', repo, 'merge', '--no-ff', '--no-edit', branch], MOVE)
505
+ } catch (err) {
506
+ // Read the conflicted files BEFORE backing out: the abort is what makes them disappear.
507
+ const conflicts = await git(['-C', repo, 'diff', '--name-only', '--diff-filter=U'])
508
+ .then((out) => out.split('\n').filter(Boolean))
509
+ .catch(() => [])
510
+ await git(['-C', repo, 'merge', '--abort'], MOVE).catch(() => {})
511
+ if (conflicts.length) {
512
+ return {
513
+ ...fail(
514
+ `${branch} and ${base} both changed ${conflicts.length === 1 ? 'a file' : `${conflicts.length} files`}, ` +
515
+ 'and k0 will not pick a side. The merge was backed out and everything is still where it was: ' +
516
+ `the worktree at ${dir} is untouched. Merge it by hand.`
517
+ ),
518
+ conflicts,
519
+ branch,
520
+ base,
521
+ path: dir,
522
+ }
523
+ }
524
+ return fail(`The merge of ${branch} into ${base} would not go through, so it was backed out.`, words(err))
525
+ }
526
+ }
527
+
528
+ // Plain `remove`, never `--force`: git refuses when there is anything in there it does not
529
+ // know about, and a file k0 did not put there is a file k0 does not get to delete.
530
+ try {
531
+ await git(['-C', repo, 'worktree', 'remove', dir], MOVE)
532
+ } catch (err) {
533
+ return {
534
+ ...fail(
535
+ `The work is on ${base} — that part went through — but the copy at ${dir} could not be removed. ` +
536
+ 'There is probably something in it git does not know about. Remove it by hand when you have looked.',
537
+ words(err)
538
+ ),
539
+ merged: true,
540
+ commits,
541
+ commit: message,
542
+ branch,
543
+ base,
544
+ path: dir,
545
+ }
546
+ }
547
+
548
+ // `-d`, not `-D`: it refuses to delete a branch whose commits are not merged anywhere, which
549
+ // is the last check that the merge above really did what it said.
550
+ const kept = await git(['-C', repo, 'branch', '-d', branch]).then(
551
+ () => null,
552
+ (err) => words(err)
553
+ )
554
+ await git(['-C', repo, 'worktree', 'prune']).catch(() => {})
555
+ patchSession(session.id, { work_path: null })
556
+
557
+ return {
558
+ ok: true,
559
+ merged: commits > 0,
560
+ commits,
561
+ commit: message,
562
+ hooks,
563
+ branch,
564
+ base,
565
+ path: dir,
566
+ repo,
567
+ note: kept ? `The worktree is gone, but the branch ${branch} is still here and had to be left alone.` : null,
568
+ }
569
+ }
570
+
571
+ // ── Where it is ──────────────────────────────────────────────────────────────
572
+
573
+ /**
574
+ * What this session's worktree is, right now: where it is, what is sitting in it, and whether it
575
+ * is still on disk at all. A worktree removed by hand from a terminal has to read as gone rather
576
+ * than as empty, or the board would offer to merge something that is not there.
577
+ */
578
+ export async function state(session) {
579
+ const dir = session?.work_path || null
580
+ const empty = { path: null, repo: null, exists: false, branch: null, base: null, dirty: 0, commits: 0, error: null }
581
+ if (!dir) return empty
582
+ if (!gitPath()) return { ...empty, path: dir, error: 'k0 cannot find git on this machine, so it cannot look inside.' }
583
+ if (!fs.existsSync(path.join(dir, '.git'))) return { ...empty, path: dir }
584
+
585
+ const here = await statusOf(dir, 'all').catch(() => null)
586
+ if (!here) return { ...empty, path: dir, exists: true, error: `git would not say what is in ${dir}.` }
587
+
588
+ const repo = await repoOf(dir)
589
+ const base = repo && here.branch ? await baseOf(repo, here.branch) : null
590
+ return {
591
+ path: dir,
592
+ repo,
593
+ exists: true,
594
+ branch: here.branch,
595
+ base,
596
+ dirty: here.dirty,
597
+ commits: repo && base && here.branch ? await ahead(repo, base, here.branch) : 0,
598
+ error: null,
599
+ }
600
+ }