create-pmos 0.132.0 → 0.134.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/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/bin/pmos.js +18 -2
- package/workspace/guide/index.md +1 -0
- package/workspace/guide/notes.md +19 -1
- package/workspace/guide/welcome.md +38 -0
- package/workspace/lib/import-overlay.js +32 -0
- package/workspace/lib/notes-audio.js +28 -9
- package/workspace/lib/notes-capture.js +128 -0
- package/workspace/lib/notes-live-guard.js +117 -0
- package/workspace/lib/notes-whisper.js +132 -16
- package/workspace/lib/notes.js +4 -2
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-notes.js +60 -8
- package/workspace/lib/routes-onboarding.js +215 -0
- package/workspace/lib/vendor/scripts/anchor-resolve.js +351 -0
- package/workspace/lib/vendor/scripts/delivery-index.js +356 -0
- package/workspace/lib/vendor/scripts/intake.js +78 -0
- package/workspace/lib/vendor/scripts/lane-defs.js +226 -0
- package/workspace/lib/vendor/scripts/migrate-layout.js +775 -0
- package/workspace/lib/vendor/scripts/nine-stages.js +81 -0
- package/workspace/lib/vendor/scripts/onboarding/apply.js +110 -0
- package/workspace/lib/vendor/scripts/onboarding/brief.js +154 -0
- package/workspace/lib/vendor/scripts/onboarding/first-briefing.js +41 -0
- package/workspace/lib/vendor/scripts/onboarding/first-look.js +84 -0
- package/workspace/lib/vendor/scripts/onboarding/infer-brief.js +70 -0
- package/workspace/lib/vendor/scripts/onboarding/proposals.js +98 -0
- package/workspace/lib/vendor/scripts/onboarding/scan-worker.js +27 -0
- package/workspace/lib/vendor/scripts/onboarding/scan.js +859 -0
- package/workspace/lib/vendor/scripts/onboarding/structure.js +217 -0
- package/workspace/lib/vendor/scripts/plan-items-stage.js +271 -0
- package/workspace/lib/vendor/scripts/read-jsonl.js +53 -0
- package/workspace/lib/vendor/scripts/signals/layout-paths.js +43 -0
- package/workspace/lib/vendor/scripts/status-facts.js +214 -0
- package/workspace/lib/vendor/scripts/trust/import-guard.js +36 -0
- package/workspace/lib/vendor/scripts/trust/ledger.js +53 -0
- package/workspace/lib/vendor/scripts/workflow-state.js +1888 -0
- package/workspace/lib/vendor/workspace/lib/actor.js +91 -0
- package/workspace/lib/vendor/workspace/lib/board-cache.js +156 -0
- package/workspace/lib/vendor/workspace/lib/console-state.js +295 -0
- package/workspace/lib/vendor/workspace/lib/doc-category.js +227 -0
- package/workspace/lib/vendor/workspace/lib/factory-join.js +158 -0
- package/workspace/lib/vendor/workspace/lib/git-info.js +306 -0
- package/workspace/lib/vendor/workspace/lib/plan-items.js +922 -0
- package/workspace/lib/vendor/workspace/lib/plan-tree.js +419 -0
- package/workspace/lib/vendor/workspace/lib/review-gate.js +39 -0
- package/workspace/lib/vendor/workspace/lib/sink.js +321 -0
- package/workspace/lib/vendor/workspace/lib/state-root.js +587 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/calendar-views.js +25 -12
- package/workspace/public/pm/components.css +280 -0
- package/workspace/public/pm/console3.css +19 -0
- package/workspace/public/pm/shell-recording.js +122 -12
- package/workspace/public/pm/view-notes.js +7 -2
- package/workspace/public/pm/welcome.js +283 -0
- package/workspace/public/welcome.html +20 -0
- package/workspace/server.js +2 -2
- package/workspace/test-files.json +2 -0
- package/workspace/test-lists/meeting-notes.json +5 -3
- package/workspace/test-lists/onboarding-firstrun.json +15 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Read-mostly git helpers for the workspace (branch awareness + select/create at session start).
|
|
3
|
+
// Everything here shells `git` via spawnSync with an ARGS ARRAY — never a shell string — so no
|
|
4
|
+
// caller input is ever interpolated into a command line.
|
|
5
|
+
//
|
|
6
|
+
// Two truths this module exists to keep straight:
|
|
7
|
+
// 1. A workspace's working tree is SHARED by every session in it (D62), so `checkout` is a
|
|
8
|
+
// property of the REPO, not a session: switching moves every session in that repo — and every
|
|
9
|
+
// OTHER workspace registered under the same repo too. Callers surface that; we never pretend.
|
|
10
|
+
// 2. The way OUT of that shared-tree constraint is a linked worktree: `addWorktree` gives a
|
|
11
|
+
// branch its own directory, so a new session can start on a new branch while every running
|
|
12
|
+
// session stays exactly where it was. That is the isolated path, and it is the default the
|
|
13
|
+
// UI offers for "+ new branch".
|
|
14
|
+
//
|
|
15
|
+
// "Is this a repo?" is answered by `git rev-parse`, NOT by looking for a `.git` entry: a workspace
|
|
16
|
+
// registered at a SUBDIRECTORY of a repo (common for a tenant product repo added by path) is
|
|
17
|
+
// inside git, and reporting "not a git repository" there was a lie that cost the PM real time.
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const { spawnSync } = require('child_process');
|
|
21
|
+
|
|
22
|
+
const GIT_TIMEOUT = 5000; // never let a slow/hung repo (huge tree, network remote) block the server
|
|
23
|
+
|
|
24
|
+
function isDir(p) { try { return fs.statSync(p).isDirectory(); } catch { return false; } }
|
|
25
|
+
|
|
26
|
+
function git(root, args) {
|
|
27
|
+
const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: GIT_TIMEOUT });
|
|
28
|
+
return { code: r.status, out: (r.stdout || '').trim(), err: (r.stderr || '').trim(), failed: !!r.error };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The repo's top-level directory, or null when `root` is not inside a repo. Works from any depth,
|
|
32
|
+
// which is the whole point: a workspace at repo/packages/app is still in git.
|
|
33
|
+
function repoRoot(root) {
|
|
34
|
+
if (!root || !isDir(root)) return null;
|
|
35
|
+
const r = git(root, ['rev-parse', '--show-toplevel']);
|
|
36
|
+
if (r.failed || r.code !== 0 || !r.out) return null;
|
|
37
|
+
const top = r.out.split('\n')[0].trim();
|
|
38
|
+
return top ? path.resolve(top) : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isRepo(root) {
|
|
42
|
+
if (!root) return false;
|
|
43
|
+
if (repoRoot(root)) return true;
|
|
44
|
+
// git absent from PATH: fall back to the on-disk marker so we don't call a real repo "not a
|
|
45
|
+
// repo". A normal repo has a `.git` dir; a linked worktree has a `.git` FILE — both count.
|
|
46
|
+
try { return fs.existsSync(path.join(root, '.git')); } catch { return false; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// git check-ref-format for a BRANCH name, re-implemented defensively so nothing dubious ever
|
|
50
|
+
// reaches `git` (defense in depth — `git` itself is the final authority and rejects the rest).
|
|
51
|
+
// Rejects: empty, whitespace/control, the special chars ~ ^ : ? * [ \ , the sequences .. and @{,
|
|
52
|
+
// a leading '-' (would be read as a flag), leading/trailing '/' or '.', doubled '/', a trailing
|
|
53
|
+
// '.lock', and the lone '@'. Allows the ordinary set A–Z a–z 0–9 . _ / -, e.g. initiative/foo-bar.
|
|
54
|
+
function validBranchName(name) {
|
|
55
|
+
if (typeof name !== 'string') return false;
|
|
56
|
+
const n = name.trim();
|
|
57
|
+
if (!n || n.length > 200) return false;
|
|
58
|
+
if (/[\x00-\x20~^:?*[\\\s]/.test(n)) return false; // control/space + git's forbidden set
|
|
59
|
+
if (n.includes('..') || n.includes('@{') || n.includes('//')) return false;
|
|
60
|
+
if (n === '@') return false;
|
|
61
|
+
if (n.startsWith('-') || n.startsWith('/') || n.startsWith('.')) return false;
|
|
62
|
+
if (n.endsWith('/') || n.endsWith('.') || n.endsWith('.lock')) return false;
|
|
63
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(n)) return false;
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Every working tree attached to this repo: [{ dir, branch|null }]. Used for two honest answers —
|
|
68
|
+
// "this branch is already open over there" (git will refuse a second checkout of it, so the picker
|
|
69
|
+
// says so instead of letting the PM hit a raw git error) and "here is where an isolated session
|
|
70
|
+
// would land".
|
|
71
|
+
function worktrees(root) {
|
|
72
|
+
const top = repoRoot(root);
|
|
73
|
+
return top ? worktreesAt(top) : [];
|
|
74
|
+
}
|
|
75
|
+
// same, for a caller that already resolved the top (saves a `git rev-parse` per call)
|
|
76
|
+
function worktreesAt(top) {
|
|
77
|
+
const r = git(top, ['worktree', 'list', '--porcelain']);
|
|
78
|
+
if (r.failed || r.code !== 0 || !r.out) return [];
|
|
79
|
+
const out = [];
|
|
80
|
+
let cur = null;
|
|
81
|
+
for (const line of r.out.split('\n')) {
|
|
82
|
+
if (line.startsWith('worktree ')) { cur = { dir: line.slice(9).trim(), branch: null }; out.push(cur); }
|
|
83
|
+
else if (cur && line.startsWith('branch ')) cur.branch = line.slice(7).trim().replace(/^refs\/heads\//, '');
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The URL `origin` fetches from, or null when there is no origin (or no git at all). This is the
|
|
89
|
+
// ONLY thing that can answer "did this working tree come from somewhere, or is it a folder on this
|
|
90
|
+
// machine?" — the presence of a `.git` directory answers a different question and answering with it
|
|
91
|
+
// would put a provenance label on a row that no read supports.
|
|
92
|
+
// `git remote get-url` rather than `git config remote.origin.url`: it resolves insteadOf/pushInsteadOf
|
|
93
|
+
// rewrites, so what comes back is the URL git would actually contact.
|
|
94
|
+
function originUrl(root) {
|
|
95
|
+
if (!root || !isDir(root)) return null;
|
|
96
|
+
const r = git(root, ['remote', 'get-url', 'origin']);
|
|
97
|
+
if (r.failed || r.code !== 0 || !r.out) return null;
|
|
98
|
+
return r.out.split('\n')[0].trim() || null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Is this remote a GitHub one? Both URL shapes git accepts: scp-style (git@github.com:o/r.git) and
|
|
102
|
+
// a real URL (https://github.com/o/r, ssh://git@github.com/o/r). Any other host — GitLab, Gitea, a
|
|
103
|
+
// bare path, a local mirror — is NOT github, and saying otherwise would be the unsupported label
|
|
104
|
+
// this function exists to avoid. It answers about the URL only; the caller decides what to call it.
|
|
105
|
+
function isGithubRemote(url) {
|
|
106
|
+
const u = String(url || '').trim();
|
|
107
|
+
if (!u) return false;
|
|
108
|
+
const scp = u.match(/^[^/@]+@([^:/]+):/); // git@host:owner/repo.git
|
|
109
|
+
const host = scp ? scp[1] : (u.match(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]*@)?([^/:]+)/i) || [])[1];
|
|
110
|
+
if (!host) return false;
|
|
111
|
+
const h = host.toLowerCase();
|
|
112
|
+
return h === 'github.com' || h.endsWith('.github.com');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Where an isolated worktree lands: a sibling of the repo, gathered under one `<repo>-worktrees/`
|
|
116
|
+
// folder. Deliberately OUTSIDE the repo — nothing new appears inside the tree, so no `git status`
|
|
117
|
+
// noise and nothing for .gitignore to chase. The path is derived from a name already passed
|
|
118
|
+
// through validBranchName (charset A–Za–z0–9._/-, no `..`, no leading `.`), so the only
|
|
119
|
+
// transformation needed is '/' → '-' to keep it one directory segment.
|
|
120
|
+
function worktreePath(root, branch) {
|
|
121
|
+
const top = repoRoot(root);
|
|
122
|
+
if (!top || !validBranchName(branch)) return null;
|
|
123
|
+
return path.join(path.dirname(top), path.basename(top) + '-worktrees', branch.replace(/\//g, '-'));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// The start point of a NEW branch: an existing branch name or a commit id, nothing else. A name that
|
|
127
|
+
// passes validBranchName cannot begin with `-`, so it cannot be read by git as an option; it must
|
|
128
|
+
// also resolve to a commit, so a typo is refused here with a reason and not by git's own message.
|
|
129
|
+
// No base means "where this tree is now" — git's default.
|
|
130
|
+
function baseRef(root, from) {
|
|
131
|
+
if (from === null || from === undefined || from === '') return { ok: true, ref: null };
|
|
132
|
+
const ref = String(from);
|
|
133
|
+
if (!validBranchName(ref) && !/^[0-9a-f]{7,40}$/i.test(ref)) return { ok: false, error: 'invalid base' };
|
|
134
|
+
const r = git(root, ['rev-parse', '--verify', '--quiet', ref + '^{commit}']);
|
|
135
|
+
if (r.failed) return { ok: false, error: 'git not available' };
|
|
136
|
+
if (r.code !== 0) return { ok: false, error: `base '${ref}' does not exist` };
|
|
137
|
+
return { ok: true, ref };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Check out an existing branch, or create one with -b. THIS MOVES THE WHOLE REPO — every session
|
|
141
|
+
// in every workspace under it. Confined to `root` by cwd; the name is validated first so nothing
|
|
142
|
+
// malformed reaches git. On any failure returns { ok:false, error } and leaves the caller to NOT
|
|
143
|
+
// spawn a session on the wrong branch (a failed checkout must never spawn).
|
|
144
|
+
function checkout(root, branch, { create = false, from = null } = {}) {
|
|
145
|
+
if (!isRepo(root)) return { ok: false, error: 'not a git repository' };
|
|
146
|
+
if (!validBranchName(branch)) return { ok: false, error: 'invalid branch name' };
|
|
147
|
+
const base = baseRef(root, from);
|
|
148
|
+
if (!base.ok) return base;
|
|
149
|
+
const args = create ? ['checkout', '-b', branch, ...(base.ref ? [base.ref] : []), '--'] : ['checkout', branch, '--'];
|
|
150
|
+
const r = git(root, args);
|
|
151
|
+
if (r.failed) return { ok: false, error: 'git not available' };
|
|
152
|
+
if (r.code !== 0) return { ok: false, error: (r.err || r.out || 'checkout failed').split('\n')[0].slice(0, 200) };
|
|
153
|
+
const info = branchInfo(root);
|
|
154
|
+
return { ok: true, current: info.ok ? info.current : branch };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Put `branch` in its OWN working tree, so a session can start on it while every already-running
|
|
158
|
+
// session stays untouched. `create` makes the branch (`-b`); otherwise the branch must exist and
|
|
159
|
+
// must not already be checked out somewhere else (git refuses — we return that plainly).
|
|
160
|
+
// Returns { ok, dir, branch, reused } or { ok:false, error }.
|
|
161
|
+
function addWorktree(root, branch, { create = false, from = null } = {}) {
|
|
162
|
+
const top = repoRoot(root);
|
|
163
|
+
if (!top) return { ok: false, error: 'not a git repository' };
|
|
164
|
+
if (!validBranchName(branch)) return { ok: false, error: 'invalid branch name' };
|
|
165
|
+
const base = baseRef(top, from);
|
|
166
|
+
if (!base.ok) return base;
|
|
167
|
+
|
|
168
|
+
// Already checked out somewhere? Say where, rather than letting git's message be the first the
|
|
169
|
+
// PM sees — and if it is exactly the worktree we would have made, reuse it instead of failing.
|
|
170
|
+
const dir = worktreePath(top, branch);
|
|
171
|
+
const existing = worktreesAt(top).find((w) => w.branch === branch);
|
|
172
|
+
if (existing) {
|
|
173
|
+
if (create) return { ok: false, error: `branch '${branch}' already exists (checked out at ${existing.dir})` };
|
|
174
|
+
if (path.resolve(existing.dir) === path.resolve(dir)) return { ok: true, dir, branch, reused: true };
|
|
175
|
+
return { ok: false, error: `branch '${branch}' is already checked out at ${existing.dir} — git allows one working tree per branch` };
|
|
176
|
+
}
|
|
177
|
+
if (fs.existsSync(dir)) return { ok: false, error: `${dir} already exists — move or remove it, or pick another branch name` };
|
|
178
|
+
|
|
179
|
+
try { fs.mkdirSync(path.dirname(dir), { recursive: true }); }
|
|
180
|
+
catch (e) { return { ok: false, error: `cannot create ${path.dirname(dir)}: ${e.code || e.message}` }; }
|
|
181
|
+
|
|
182
|
+
const args = create ? ['worktree', 'add', '-b', branch, dir, ...(base.ref ? [base.ref] : [])] : ['worktree', 'add', dir, branch];
|
|
183
|
+
const r = git(top, args);
|
|
184
|
+
if (r.failed) return { ok: false, error: 'git not available' };
|
|
185
|
+
if (r.code !== 0) {
|
|
186
|
+
// never leave a half-made directory behind to block the next attempt
|
|
187
|
+
try { if (fs.existsSync(dir) && fs.readdirSync(dir).length === 0) fs.rmdirSync(dir); } catch {}
|
|
188
|
+
return { ok: false, error: (r.err || r.out || 'worktree add failed').split('\n').filter(Boolean).pop().slice(0, 200) };
|
|
189
|
+
}
|
|
190
|
+
return { ok: true, dir, branch, reused: false };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Everything needed to decide whether a worktree can be removed WITHOUT losing work. Two hazards
|
|
194
|
+
// live in git itself, and each is reported BY NAME so the UI can say why rather than just greying
|
|
195
|
+
// out a button:
|
|
196
|
+
// dirty — uncommitted or untracked files sitting in the tree
|
|
197
|
+
// unique commits — commits on this branch that no other branch, remote or tag holds, so removing
|
|
198
|
+
// the tree is the last thing standing between them and `git gc`
|
|
199
|
+
// Anything unreadable counts as unsafe: this gates a delete, so "I could not check" must never
|
|
200
|
+
// read as "fine". (The caller adds the two hazards git cannot see: live sessions in the tree, and
|
|
201
|
+
// "this is the repo's main worktree".)
|
|
202
|
+
function worktreeSafety(dir, branch) {
|
|
203
|
+
const reasons = [];
|
|
204
|
+
const st = git(dir, ['status', '--porcelain']);
|
|
205
|
+
const dirty = st.failed || st.code !== 0 ? true : st.out.length > 0;
|
|
206
|
+
if (dirty) reasons.push(st.code === 0 ? 'uncommitted changes' : 'its state could not be read');
|
|
207
|
+
|
|
208
|
+
let unique = 0;
|
|
209
|
+
if (!branch) {
|
|
210
|
+
unique = -1;
|
|
211
|
+
reasons.push('detached HEAD — no branch names its commits');
|
|
212
|
+
} else {
|
|
213
|
+
// "commits reachable from this branch and from nothing else". --exclude applies to the NEXT
|
|
214
|
+
// ref-listing option only, so --branches drops this branch while --remotes and --tags still
|
|
215
|
+
// count: a copy on a remote means the commits survive the removal.
|
|
216
|
+
const rl = git(dir, ['rev-list', '--count', 'refs/heads/' + branch,
|
|
217
|
+
'--not', '--exclude=' + branch, '--branches', '--remotes', '--tags']);
|
|
218
|
+
unique = rl.code === 0 && /^\d+$/.test(rl.out) ? parseInt(rl.out, 10) : -1;
|
|
219
|
+
if (unique > 0) reasons.push(`${unique} commit${unique === 1 ? '' : 's'} not on any other branch or remote`);
|
|
220
|
+
if (unique < 0) reasons.push('could not verify its commits exist elsewhere');
|
|
221
|
+
}
|
|
222
|
+
return { removable: reasons.length === 0, reasons, dirty, unique };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Remove a linked worktree, refusing anything that would lose work. `dir` arrives from a client, so
|
|
226
|
+
// it is never passed through as given: it must match one of THIS repo's linked worktrees (git's own
|
|
227
|
+
// list is the allowlist), and the main worktree is unreachable by construction. The BRANCH is left
|
|
228
|
+
// alone — a branch is a cheap pointer, and deleting one is a different decision than reclaiming a
|
|
229
|
+
// directory.
|
|
230
|
+
function removeWorktree(root, dir) {
|
|
231
|
+
const top = repoRoot(root);
|
|
232
|
+
if (!top) return { ok: false, error: 'not a git repository' };
|
|
233
|
+
const trees = worktreesAt(top);
|
|
234
|
+
// index 0 is always the main worktree — sliced off, so it can never be the target
|
|
235
|
+
const target = trees.slice(1).find((w) => path.resolve(w.dir) === path.resolve(String(dir || '')));
|
|
236
|
+
if (!target) return { ok: false, error: 'not a linked worktree of this repository' };
|
|
237
|
+
|
|
238
|
+
// Gone from disk already (the PM deleted it by hand): there is nothing to protect, just stale
|
|
239
|
+
// bookkeeping to clear.
|
|
240
|
+
if (!isDir(target.dir)) {
|
|
241
|
+
const p = git(top, ['worktree', 'prune']);
|
|
242
|
+
if (p.code !== 0) return { ok: false, error: (p.err || 'prune failed').split('\n')[0].slice(0, 200) };
|
|
243
|
+
return { ok: true, dir: target.dir, pruned: true };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const safety = worktreeSafety(target.dir, target.branch);
|
|
247
|
+
if (!safety.removable) return { ok: false, error: 'kept — ' + safety.reasons.join('; '), safety };
|
|
248
|
+
const r = git(top, ['worktree', 'remove', target.dir]);
|
|
249
|
+
if (r.failed) return { ok: false, error: 'git not available' };
|
|
250
|
+
if (r.code !== 0) return { ok: false, error: (r.err || r.out || 'worktree remove failed').split('\n').filter(Boolean).pop().slice(0, 200) };
|
|
251
|
+
return { ok: true, dir: target.dir, branch: target.branch, pruned: false };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// { ok, current, detached } — only the HEAD half of branchInfo, with the same failure codes. The
|
|
255
|
+
// workspace list and the session heartbeat need the branch name and nothing else; branchInfo also
|
|
256
|
+
// lists every branch, runs `git status --porcelain` and lists the worktrees, which on a large repo
|
|
257
|
+
// is most of the cost and was paid for every workspace every 15 seconds. `top` may be passed when
|
|
258
|
+
// the caller already resolved the repo root, saving one more shell-out.
|
|
259
|
+
function headInfo(root, top) {
|
|
260
|
+
const t = top === undefined ? repoRoot(root) : top;
|
|
261
|
+
if (!t) {
|
|
262
|
+
if (!isRepo(root)) return { ok: false, code: 'no-git', error: 'not a git repository' };
|
|
263
|
+
return { ok: false, code: 'git-error', error: 'git not available' };
|
|
264
|
+
}
|
|
265
|
+
const head = git(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
266
|
+
if (head.failed) return { ok: false, code: 'git-error', error: 'git not available' };
|
|
267
|
+
if (head.code !== 0) return { ok: false, code: 'git-error', error: head.err || 'could not read HEAD' };
|
|
268
|
+
const detached = head.out === 'HEAD';
|
|
269
|
+
return { ok: true, current: detached ? null : head.out, detached };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// { ok, current, branches[], detached, dirty, repoRoot, subdir, linked, worktrees[] }. A non-repo
|
|
273
|
+
// or a failed git returns ok:false with a reason — NEVER a fabricated branch (a wrong branch chip
|
|
274
|
+
// is worse than none; anti-drift). `subdir` is the workspace's path INSIDE the repo ('' at the
|
|
275
|
+
// top): non-empty means a checkout here moves a tree larger than this workspace, which the UI says
|
|
276
|
+
// out loud.
|
|
277
|
+
function branchInfo(root) {
|
|
278
|
+
// `code` lets a caller tell the two failures apart without matching on prose: `no-git` is a
|
|
279
|
+
// perfectly normal folder (a tenant product repo with no git at all) and deserves no alarm;
|
|
280
|
+
// `git-error` is something the PM may want to look at.
|
|
281
|
+
const top = repoRoot(root);
|
|
282
|
+
if (!top) {
|
|
283
|
+
if (!isRepo(root)) return { ok: false, code: 'no-git', error: 'not a git repository' };
|
|
284
|
+
return { ok: false, code: 'git-error', error: 'git not available' };
|
|
285
|
+
}
|
|
286
|
+
const head = git(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
287
|
+
if (head.failed) return { ok: false, code: 'git-error', error: 'git not available' };
|
|
288
|
+
if (head.code !== 0) return { ok: false, code: 'git-error', error: head.err || 'could not read HEAD' };
|
|
289
|
+
const detached = head.out === 'HEAD';
|
|
290
|
+
const current = detached ? null : head.out;
|
|
291
|
+
const refs = git(root, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']);
|
|
292
|
+
const branches = refs.code === 0 && refs.out ? refs.out.split('\n').filter(Boolean) : [];
|
|
293
|
+
const status = git(root, ['status', '--porcelain']);
|
|
294
|
+
const dirty = status.code === 0 ? status.out.length > 0 : false;
|
|
295
|
+
const rel = path.relative(top, path.resolve(root));
|
|
296
|
+
const trees = worktreesAt(top);
|
|
297
|
+
const linked = path.resolve(top) !== path.resolve(trees[0] ? trees[0].dir : top);
|
|
298
|
+
return {
|
|
299
|
+
ok: true, current, branches, detached, dirty,
|
|
300
|
+
repoRoot: top, subdir: rel === '' || rel === '.' ? '' : rel, linked,
|
|
301
|
+
worktrees: trees.map((w) => ({ dir: w.dir, branch: w.branch })),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
module.exports = { isRepo, repoRoot, validBranchName, branchInfo, headInfo, checkout, worktrees, worktreePath,
|
|
306
|
+
addWorktree, worktreeSafety, removeWorktree, originUrl, isGithubRemote };
|