@ucsandman/legcli 0.9.0 → 0.11.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/CHANGELOG.md +146 -0
- package/README.md +110 -11
- package/bin/leg.mjs +78 -15
- package/docs/ERRORS.md +187 -0
- package/docs/README.md +3 -1
- package/docs/ROADMAP-v2.md +24 -11
- package/docs/VOCABULARY.md +1 -0
- package/docs/adapters.md +93 -11
- package/docs/board-guide.md +20 -1
- package/docs/cli-contracts.md +50 -17
- package/docs/configuration.md +56 -5
- package/docs/history.md +172 -0
- package/docs/runtime-tap.md +156 -0
- package/fixtures/limits/grok/grok-balance-exhausted.json +11 -0
- package/fixtures/live/grok/cmd.txt +1 -1
- package/fixtures/live/grok/parsed.json +6 -3
- package/fixtures/live/grok/run.json +22 -10
- package/fixtures/verified.json +8 -1
- package/package.json +1 -1
- package/scripts/build-docs-site.mjs +11 -4
- package/scripts/probe.mjs +2 -1
- package/src/accounts.mjs +5 -2
- package/src/adapters/cli.mjs +130 -0
- package/src/adapters/custom.mjs +271 -0
- package/src/adapters/grok.mjs +51 -10
- package/src/adapters/index.mjs +34 -7
- package/src/attach.mjs +85 -13
- package/src/audit.mjs +118 -0
- package/src/board/audit.js +123 -0
- package/src/board/board.css +38 -1
- package/src/board/board.js +14 -2
- package/src/board/history.js +377 -0
- package/src/board/index.html +55 -0
- package/src/board/sessions.js +49 -7
- package/src/history/cli.mjs +159 -0
- package/src/history/common.mjs +119 -0
- package/src/history/index.mjs +429 -0
- package/src/history/providers/agy.mjs +91 -0
- package/src/history/providers/claude.mjs +161 -0
- package/src/history/providers/codex.mjs +133 -0
- package/src/history/providers/copilot.mjs +94 -0
- package/src/history/providers/grok.mjs +138 -0
- package/src/history/worktrees.mjs +116 -0
- package/src/redact.mjs +23 -5
- package/src/server.mjs +272 -28
- package/src/sessions.mjs +9 -0
- package/src/share.mjs +66 -6
- package/src/taps/claude.mjs +11 -4
- package/src/taps/grok.mjs +4 -0
- package/src/taps/mod.mjs +340 -0
- package/src/usage.mjs +21 -5
- package/src/worktree.mjs +1 -1
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// `leg history <verb>` and `leg worktrees` — the human surface of discovery.
|
|
2
|
+
// Every verb prints for a person by default and JSON with --json; every one
|
|
3
|
+
// of them is read-only except `continue`, which starts a normal `leg <agent>`
|
|
4
|
+
// session on a conversation the agent's own store holds. Exit codes follow
|
|
5
|
+
// the rest of the CLI: 0 fine, 1 internal error, 2 usage, 3 not found / not
|
|
6
|
+
// possible, 4 license required.
|
|
7
|
+
import { resolve } from 'node:path'
|
|
8
|
+
import { listHistory, findRecord, recordDetail, refreshIndex, resumeSpec, providerSupport, HistoryInputError, PROVIDER_NAMES, DEFAULT_LIMIT } from './index.mjs'
|
|
9
|
+
import { listWorktrees } from './worktrees.mjs'
|
|
10
|
+
import { ago } from '../resume.mjs'
|
|
11
|
+
import { attach } from '../attach.mjs'
|
|
12
|
+
import { entitlement, allows, describe as describeLicense } from '../license.mjs'
|
|
13
|
+
|
|
14
|
+
export const HELP = `leg history: every coding-agent conversation on this machine, Leg's own and the ones it only found
|
|
15
|
+
[ls] [--provider claude,codex,grok,agy,copilot] [--repo <path|name>] [--search <text>] [--limit n] [--offset n] [--all] [--json]
|
|
16
|
+
[--managed | --external] [--live] [--subagents] [--refresh]
|
|
17
|
+
newest first; the index refreshes itself when it is older than a minute
|
|
18
|
+
show <id> [--messages n] [--json] one conversation: where it ran, its last messages, whether Leg can continue it
|
|
19
|
+
continue <id> [agent args...] start leg <agent> on that conversation, in its own folder, supervised like any other
|
|
20
|
+
refresh [--full] [--json] re-stat every store now; --full drops the index and re-reads everything
|
|
21
|
+
providers [--json] what Leg can do for each agent: list, read the transcript, continue
|
|
22
|
+
An id is <provider>:<native id>; a unique prefix of the native id (4+ characters) is enough.
|
|
23
|
+
Nothing in an agent's own store is moved or changed; Leg writes only ${'$LEG_HOME'}/history/index.json.`
|
|
24
|
+
|
|
25
|
+
export const WORKTREES_HELP = `leg worktrees [--repo <path>] [--no-dirty] [--json]
|
|
26
|
+
every checkout git lists for the repositories Leg knows, Leg's own worktrees and the ones
|
|
27
|
+
discovered conversations were working in; read only (leg card rm / the board's Remove still own removal)`
|
|
28
|
+
|
|
29
|
+
// a Leg-only row's id is the session id whole: cut, it would not round-trip
|
|
30
|
+
const short = (id) => { const [p, n] = String(id).split(':', 2); if (p === 'leg') return String(id); return n ? `${p}:${n.slice(0, 8)}` : String(id).slice(0, 24) }
|
|
31
|
+
const when = (iso) => (iso ? ago(Date.now() - Date.parse(iso)) : '-')
|
|
32
|
+
const flag = (args, k) => args[k] === true || (typeof args[k] === 'string' && args[k] !== 'false')
|
|
33
|
+
|
|
34
|
+
export function fmtRow(r) {
|
|
35
|
+
const who = r.managed ? (r.live ? 'leg live' : 'leg') : (r.live ? 'external live' : 'external')
|
|
36
|
+
const where = `${r.repo_name ?? r.cwd ?? '-'}${r.branch ? '@' + r.branch : ''}${r.worktree ? ' (worktree)' : ''}`
|
|
37
|
+
return `${short(r.id).padEnd(16)} ${r.provider.padEnd(6)} ${who.padEnd(13)} ${where.slice(0, 40).padEnd(40)} ${when(r.updated_at).padEnd(20)} ${String(r.title ?? '').slice(0, 60)}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function printDetail(out, d, { requested = 8 } = {}) {
|
|
41
|
+
out(`${d.id} ${d.provider}${d.account !== 'default' ? '/' + d.account : ''} ${d.managed ? `Leg session ${d.leg_session_id} (${d.leg_status})` : 'discovered, not started by Leg'}${d.live ? ' LIVE' : ''}`)
|
|
42
|
+
out(` title: ${d.title ?? '-'}`)
|
|
43
|
+
out(` folder: ${d.cwd ?? '-'}${d.cwd_exists === false ? ' (gone)' : ''}`)
|
|
44
|
+
out(` repo: ${d.repo ?? '-'}${d.branch ? ` branch ${d.branch}` : ''}`)
|
|
45
|
+
if (d.worktree) out(` worktree: ${d.worktree.path}`)
|
|
46
|
+
out(` started: ${d.started_at ?? '-'}`)
|
|
47
|
+
out(` updated: ${d.updated_at ?? '-'} (${when(d.updated_at)})`)
|
|
48
|
+
out(` turns: ${d.turns ?? 'unknown'}`)
|
|
49
|
+
out(` transcript: ${d.transcript_path ?? '-'}${d.transcript === 'unsupported' ? ' (Leg cannot read this provider\'s transcript)' : ''}`)
|
|
50
|
+
out(` continue: ${d.resume.supported ? `leg history continue ${d.id}` : `not possible: ${d.resume.reason}`}`)
|
|
51
|
+
if (d.messages === null) out(' messages: not readable for this provider')
|
|
52
|
+
else if (requested === 0) out(' messages: not asked for (--messages 0)')
|
|
53
|
+
else if (!d.messages.length) out(' messages: none readable')
|
|
54
|
+
else {
|
|
55
|
+
out(' messages:')
|
|
56
|
+
for (const m of d.messages) out(` [${m.role === 'user' ? 'human' : 'agent'}${m.ts ? ' ' + String(m.ts).slice(0, 16).replace('T', ' ') : ''}] ${m.text.replace(/\s+/g, ' ').slice(0, 300)}`)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function historyCommand(cmd, args, { out, die, raw = [] }) {
|
|
61
|
+
const json = flag(args, 'json')
|
|
62
|
+
if (cmd === 'help' || cmd === '--help' || cmd === '-h' || flag(args, 'help') || flag(args, 'h')) { out(HELP); return 0 }
|
|
63
|
+
if (!cmd || cmd === 'ls') {
|
|
64
|
+
if (args.limit !== undefined) {
|
|
65
|
+
const n = Number(args.limit)
|
|
66
|
+
if (!Number.isInteger(n) || n < 1) return die(2, '--limit must be a positive integer (--all lists everything)')
|
|
67
|
+
}
|
|
68
|
+
if (args.offset !== undefined) {
|
|
69
|
+
const n = Number(args.offset)
|
|
70
|
+
if (!Number.isInteger(n) || n < 0) return die(2, '--offset must be a non-negative integer')
|
|
71
|
+
}
|
|
72
|
+
if (args.provider && String(args.provider).split(',').some((p) => !PROVIDER_NAMES.includes(p.trim()))) return die(2, `unknown provider in "${args.provider}" (${PROVIDER_NAMES.join('|')})`)
|
|
73
|
+
let r
|
|
74
|
+
try {
|
|
75
|
+
r = listHistory({
|
|
76
|
+
provider: args.provider ?? null, repo: args.repo ? (/[\\/]/.test(args.repo) ? resolve(String(args.repo)) : args.repo) : null, search: args.search ?? null,
|
|
77
|
+
limit: flag(args, 'all') ? 0 : (args.limit ? parseInt(args.limit, 10) : DEFAULT_LIMIT), offset: args.offset ? parseInt(args.offset, 10) : 0,
|
|
78
|
+
includeSubagents: flag(args, 'subagents'), refresh: flag(args, 'refresh') ? true : null,
|
|
79
|
+
managed: flag(args, 'managed') ? true : flag(args, 'external') ? false : null, live: flag(args, 'live') ? true : null,
|
|
80
|
+
})
|
|
81
|
+
} catch (err) { return die(1, `history: ${err.message}`) }
|
|
82
|
+
if (json) { out(JSON.stringify(r, null, 2)); return 0 }
|
|
83
|
+
// a failed refresh is said whatever the last index still lists
|
|
84
|
+
if (r.refresh_error) out(`refresh error: ${r.refresh_error} (showing the last index${r.refreshed_at ? ', from ' + when(r.refreshed_at) : ''})`)
|
|
85
|
+
if (!r.total) {
|
|
86
|
+
out('no conversations found.')
|
|
87
|
+
out('Leg looks in the Claude Code, Codex, Grok, Antigravity and Copilot homes on this machine, plus its own sessions. leg history providers lists them.')
|
|
88
|
+
return 0
|
|
89
|
+
}
|
|
90
|
+
for (const x of r.records) out(fmtRow(x))
|
|
91
|
+
if (r.total > r.records.length) out(`… ${r.total - r.records.length} more (--limit n, or --all)`)
|
|
92
|
+
return 0
|
|
93
|
+
}
|
|
94
|
+
if (cmd === 'refresh') {
|
|
95
|
+
const t = Date.now()
|
|
96
|
+
let r
|
|
97
|
+
try { r = refreshIndex({ force: flag(args, 'full') }) } catch (err) { return die(1, `history refresh: ${err.message}`) }
|
|
98
|
+
if (json) { out(JSON.stringify({ ms: Date.now() - t, refreshed_at: r.index.refreshed_at, stats: r.stats }, null, 2)); return 0 }
|
|
99
|
+
for (const s of r.stats) out(`${s.provider.padEnd(6)} ${s.account === 'default' ? '' : s.account.padEnd(10)} ${s.missing ? 'no store here' : s.error ? `ERROR ${s.error}` : `${s.records} conversation${s.records === 1 ? '' : 's'} (${s.scanned} scanned, ${s.parsed} read)`} ${s.root}`)
|
|
100
|
+
out(`refreshed in ${Date.now() - t} ms`)
|
|
101
|
+
return r.stats.some((s) => s.error) ? 1 : 0
|
|
102
|
+
}
|
|
103
|
+
if (cmd === 'providers') {
|
|
104
|
+
const p = providerSupport()
|
|
105
|
+
if (json) { out(JSON.stringify(p, null, 2)); return 0 }
|
|
106
|
+
out('provider list transcript continue live marker')
|
|
107
|
+
for (const x of p) out(`${x.name.padEnd(9)} yes ${x.transcript.padEnd(12)} ${x.resume.padEnd(12)} ${x.live === 'marker' ? 'yes' : 'no'}`)
|
|
108
|
+
return 0
|
|
109
|
+
}
|
|
110
|
+
if (cmd === 'show' || cmd === 'continue') {
|
|
111
|
+
const id = args._[0]
|
|
112
|
+
if (!id) return die(2, `usage: leg history ${cmd} <id>`)
|
|
113
|
+
let rec
|
|
114
|
+
try { rec = findRecord(id) } catch (err) { if (err instanceof HistoryInputError) return die(2, err.message); throw err }
|
|
115
|
+
if (!rec) return die(3, `no conversation matches "${id}" (leg history ls)`)
|
|
116
|
+
if (cmd === 'show') {
|
|
117
|
+
if (args.messages !== undefined) {
|
|
118
|
+
const n = Number(args.messages)
|
|
119
|
+
if (!Number.isInteger(n) || n < 0) return die(2, '--messages must be a non-negative integer')
|
|
120
|
+
}
|
|
121
|
+
const requested = args.messages !== undefined ? parseInt(args.messages, 10) : 8
|
|
122
|
+
const d = recordDetail(rec, { messages: requested })
|
|
123
|
+
if (json) { out(JSON.stringify(d, null, 2)); return 0 }
|
|
124
|
+
printDetail(out, d, { requested })
|
|
125
|
+
return 0
|
|
126
|
+
}
|
|
127
|
+
const spec = resumeSpec(rec)
|
|
128
|
+
if (!spec.supported) return die(3, `cannot continue ${rec.id}: ${spec.reason}`)
|
|
129
|
+
const ent = entitlement()
|
|
130
|
+
if (!allows(ent, 'run')) {
|
|
131
|
+
out(describeLicense(ent))
|
|
132
|
+
return 4
|
|
133
|
+
}
|
|
134
|
+
out(`continuing ${rec.id} with leg ${spec.agent} in ${spec.cwd}`)
|
|
135
|
+
// everything after the id is the agent's (and Leg's own --no-worktree,
|
|
136
|
+
// --no-auto-approve, which attach() strips as it does for leg <agent>)
|
|
137
|
+
const at = raw.indexOf(id)
|
|
138
|
+
const extra = at === -1 ? [] : raw.slice(at + 1)
|
|
139
|
+
return attach(spec.agent, [...spec.args, ...extra], { open: (process.env.LEG_NO_OPEN || process.env.BATON_NO_OPEN) !== '1', cwd: spec.cwd, continued: { id: rec.id, provider: rec.provider, native_id: rec.native_id, transcript_path: rec.transcript_path, title: rec.title } })
|
|
140
|
+
}
|
|
141
|
+
return die(2, `unknown history command "${cmd}" (ls|show|continue|refresh|providers)`)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function fmtWorktree(w) {
|
|
145
|
+
const owner = w.owner.kind === 'checkout' ? 'checkout' : w.owner.kind === 'session' ? `session ${w.owner.id}${w.owner.live ? ' (live)' : ''}` : w.owner.kind === 'card' ? `card ${w.owner.id}` : 'external'
|
|
146
|
+
const flags = [!w.exists ? 'MISSING' : null, w.orphaned ? 'orphaned' : null, w.stale ? 'stale' : null, w.dirty === null ? null : w.dirty ? `${w.dirty} dirty` : 'clean'].filter(Boolean).join(', ')
|
|
147
|
+
return `${(w.repo_name ?? '-').padEnd(18)} ${(w.branch ?? '(detached)').slice(0, 32).padEnd(32)} ${owner.padEnd(36)} ${String(w.conversations.count).padStart(3)} conv ${flags.padEnd(22)} ${w.path}`
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function worktreesCommand(cmd, args, { out, die }) {
|
|
151
|
+
if (cmd === 'help' || cmd === '--help' || cmd === '-h' || flag(args, 'help') || flag(args, 'h')) { out(WORKTREES_HELP); return 0 }
|
|
152
|
+
if (cmd && cmd !== 'ls') return die(2, `unknown worktrees command "${cmd}" (ls)`)
|
|
153
|
+
const r = listWorktrees({ dirty: !flag(args, 'no-dirty'), repo: args.repo ? resolve(String(args.repo)) : null })
|
|
154
|
+
if (flag(args, 'json')) { out(JSON.stringify(r, null, 2)); return 0 }
|
|
155
|
+
if (!r.worktrees.length) { out('no worktrees: Leg knows no repository yet (a session, a card or a discovered conversation names one).'); return 0 }
|
|
156
|
+
for (const w of r.worktrees) out(fmtWorktree(w))
|
|
157
|
+
out(`${r.worktrees.length} checkout${r.worktrees.length === 1 ? '' : 's'} across ${r.repos} repositor${r.repos === 1 ? 'y' : 'ies'}; dirty checked on ${r.dirty_checked}. Read only: leg card rm / leg sessions rm / the board's Remove own removal.`)
|
|
158
|
+
return 0
|
|
159
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// history/common — the small toolkit every discovery provider shares. A
|
|
2
|
+
// provider reads another agent's store and never writes into it, so every
|
|
3
|
+
// helper here is read-only, bounded (never the whole of a 200 MB transcript
|
|
4
|
+
// for one title) and forgiving (a torn last line, a BOM, a directory where a
|
|
5
|
+
// file was expected, all come back as "nothing", never as a throw that would
|
|
6
|
+
// take the other providers down with it).
|
|
7
|
+
import { existsSync, openSync, readSync, closeSync, fstatSync, statSync, readdirSync, readFileSync } from 'node:fs'
|
|
8
|
+
import { dirname, join, resolve, isAbsolute } from 'node:path'
|
|
9
|
+
import { redact } from '../redact.mjs'
|
|
10
|
+
import { canonPath } from '../fsx.mjs'
|
|
11
|
+
|
|
12
|
+
export const HEAD_BYTES = 256 * 1024
|
|
13
|
+
export const TAIL_BYTES = 256 * 1024
|
|
14
|
+
export const TITLE_MAX = 200
|
|
15
|
+
export const PROMPT_MAX = 300
|
|
16
|
+
|
|
17
|
+
export function safeStat(p) { try { return statSync(p) } catch { return null } }
|
|
18
|
+
export function safeList(dir) { try { return readdirSync(dir, { withFileTypes: true }) } catch { return [] } }
|
|
19
|
+
export function safeRead(p) { try { return readFileSync(p, 'utf8') } catch { return null } }
|
|
20
|
+
|
|
21
|
+
// The first `bytes` of a file as text. A BOM is dropped; a partial trailing
|
|
22
|
+
// line is left in (the caller splits on newline and ignores what fails to parse).
|
|
23
|
+
export function readHead(path, bytes = HEAD_BYTES) {
|
|
24
|
+
let fd = null
|
|
25
|
+
try {
|
|
26
|
+
fd = openSync(path, 'r')
|
|
27
|
+
const size = fstatSync(fd).size
|
|
28
|
+
const n = Math.min(bytes, size)
|
|
29
|
+
if (!n) return ''
|
|
30
|
+
const buf = Buffer.alloc(n)
|
|
31
|
+
readSync(fd, buf, 0, n, 0)
|
|
32
|
+
return buf.toString('utf8').replace(/^\uFEFF/, '')
|
|
33
|
+
} catch { return '' } finally { if (fd !== null) { try { closeSync(fd) } catch {} } }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The last `bytes` of a file as text, cut at the first newline so the first
|
|
37
|
+
// returned line is whole.
|
|
38
|
+
export function readTail(path, bytes = TAIL_BYTES) {
|
|
39
|
+
let fd = null
|
|
40
|
+
try {
|
|
41
|
+
fd = openSync(path, 'r')
|
|
42
|
+
const size = fstatSync(fd).size
|
|
43
|
+
if (!size) return ''
|
|
44
|
+
const n = Math.min(bytes, size)
|
|
45
|
+
const buf = Buffer.alloc(n)
|
|
46
|
+
readSync(fd, buf, 0, n, size - n)
|
|
47
|
+
const text = buf.toString('utf8')
|
|
48
|
+
if (n < size) { const nl = text.indexOf('\n'); return nl === -1 ? '' : text.slice(nl + 1) }
|
|
49
|
+
return text.replace(/^\uFEFF/, '')
|
|
50
|
+
} catch { return '' } finally { if (fd !== null) { try { closeSync(fd) } catch {} } }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// JSONL text → parsed objects; a line that is not JSON (torn, corrupt) is skipped.
|
|
54
|
+
export function jsonLines(text) {
|
|
55
|
+
const out = []
|
|
56
|
+
for (const line of String(text ?? '').split('\n')) {
|
|
57
|
+
const t = line.trim()
|
|
58
|
+
if (!t) continue
|
|
59
|
+
try { out.push(JSON.parse(t)) } catch { /* torn or corrupt line */ }
|
|
60
|
+
}
|
|
61
|
+
return out
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// One line of redacted, single-spaced text, cut to `max`. Everything a record
|
|
65
|
+
// carries as prose goes through here, so the on-disk index never holds a key
|
|
66
|
+
// an agent printed (by shape, or by being a value this process holds in a
|
|
67
|
+
// well-known variable) and never holds a whole message.
|
|
68
|
+
export function line(text, max = TITLE_MAX) {
|
|
69
|
+
const s = redact(String(text ?? '')).replace(/\s+/g, ' ').trim()
|
|
70
|
+
return s.length > max ? s.slice(0, max - 1) + '…' : s
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function isoOrNull(v) {
|
|
74
|
+
if (v === null || v === undefined || v === '') return null
|
|
75
|
+
const ms = typeof v === 'number' ? (v < 1e12 ? v * 1000 : v) : Date.parse(v)
|
|
76
|
+
return Number.isFinite(ms) ? new Date(ms).toISOString() : null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function isoFromMs(ms) { return Number.isFinite(ms) && ms > 0 ? new Date(ms).toISOString() : null }
|
|
80
|
+
|
|
81
|
+
// Where a checkout's git lives, without spawning git: walk up from `dir` to
|
|
82
|
+
// the first `.git` entry. A linked worktree has `.git` as a FILE naming the
|
|
83
|
+
// main repository's admin dir, so both the worktree and the repo it belongs to
|
|
84
|
+
// come out of one read. null when `dir` is not inside a repository, or does
|
|
85
|
+
// not exist at all (a conversation from a folder that has since been deleted
|
|
86
|
+
// still lists; it just has no repo to group under).
|
|
87
|
+
export function gitRootOf(dir) {
|
|
88
|
+
if (!dir || !isAbsolute(dir)) return null
|
|
89
|
+
let cur = resolve(dir)
|
|
90
|
+
if (!existsSync(cur)) return null
|
|
91
|
+
for (let i = 0; i < 64; i++) {
|
|
92
|
+
const dotGit = join(cur, '.git')
|
|
93
|
+
const st = safeStat(dotGit)
|
|
94
|
+
if (st?.isDirectory()) return { repo: cur, worktree: null }
|
|
95
|
+
if (st?.isFile()) {
|
|
96
|
+
const text = safeRead(dotGit) ?? ''
|
|
97
|
+
const m = /^gitdir:\s*(.+?)\s*$/m.exec(text)
|
|
98
|
+
if (!m) return { repo: cur, worktree: null }
|
|
99
|
+
const gitdir = resolve(cur, m[1].trim())
|
|
100
|
+
const wt = /^(.*)[\\/]worktrees[\\/][^\\/]+$/.exec(gitdir)
|
|
101
|
+
// <repo>/.git/worktrees/<name> → the repo is the parent of that .git
|
|
102
|
+
if (wt) return { repo: dirname(wt[1]), worktree: cur }
|
|
103
|
+
return { repo: cur, worktree: null }
|
|
104
|
+
}
|
|
105
|
+
const parent = dirname(cur)
|
|
106
|
+
if (parent === cur) return null
|
|
107
|
+
cur = parent
|
|
108
|
+
}
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function repoNameOf(repo) { return repo ? String(repo).split(/[\\/]/).filter(Boolean).pop() ?? null : null }
|
|
113
|
+
|
|
114
|
+
export function sameDir(a, b) {
|
|
115
|
+
if (!a || !b) return false
|
|
116
|
+
try { return canonPath(a) === canonPath(b) } catch { return String(a).toLowerCase() === String(b).toLowerCase() }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function canonOrNull(p) { if (!p) return null; try { return canonPath(p) } catch { return null } }
|