@ericsanchezok/synergy-workbench 0.0.0-stage → 3.1.1
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/AGENTS.md +30 -0
- package/README.md +16 -2
- package/dist/agents.js +11 -0
- package/dist/cli-adapter.js +8 -0
- package/dist/component.js +65 -0
- package/dist/config-schema.js +265 -0
- package/dist/execution/activity.js +78 -0
- package/dist/execution/content.js +184 -0
- package/dist/execution/json-sections.js +231 -0
- package/dist/execution/presentation.js +79 -0
- package/dist/execution/preview.js +13 -0
- package/dist/execution/process.js +125 -0
- package/dist/execution/projection.js +327 -0
- package/dist/execution/query.js +18 -0
- package/dist/execution/routes.js +189 -0
- package/dist/execution/schema.js +322 -0
- package/dist/execution/service.js +1993 -0
- package/dist/features.js +22 -0
- package/dist/http.js +65 -0
- package/dist/performance/analysis.js +330 -0
- package/dist/performance/catalog.js +391 -0
- package/dist/performance/dashboard.js +483 -0
- package/dist/performance/error.js +15 -0
- package/dist/performance/index.js +6 -0
- package/dist/performance/inflight.js +43 -0
- package/dist/performance/projection.js +138 -0
- package/dist/performance/prompt/base.txt +38 -0
- package/dist/performance/prompt/builder.js +21 -0
- package/dist/performance/routes/performance-route.js +439 -0
- package/dist/performance/schema.js +628 -0
- package/dist/performance/timeline.js +98 -0
- package/dist/performance/trace-detail.js +91 -0
- package/dist/presentation/service.js +270 -0
- package/dist/presentation/tools/workbench-view.js +65 -0
- package/dist/presentation/tools.js +10 -0
- package/dist/project/directories.js +363 -0
- package/dist/project/git-health.js +498 -0
- package/dist/project/ide.js +66 -0
- package/dist/project/migration.js +22 -0
- package/dist/project/routes/directories.js +89 -0
- package/dist/project/routes/git.js +30 -0
- package/dist/project/routes/task-defaults.js +42 -0
- package/dist/project/session-health.js +9 -0
- package/dist/project/startup.js +28 -0
- package/dist/project/task-defaults.js +68 -0
- package/dist/project/tools/session-control.js +664 -0
- package/dist/project/tools/session-control.txt +60 -0
- package/dist/project/tools/worktree-archive.js +76 -0
- package/dist/project/tools/worktree-enter.js +175 -0
- package/dist/project/tools/worktree-leave.js +119 -0
- package/dist/project/tools/worktree-list.js +52 -0
- package/dist/project/tools.js +26 -0
- package/dist/project/vcs-branch-watcher.js +76 -0
- package/dist/project/vcs.js +114 -0
- package/dist/project/worktree-janitor.js +140 -0
- package/dist/project/worktrees.js +98 -0
- package/dist/push/bridge.js +171 -0
- package/dist/push/index.js +4 -0
- package/dist/push/routes/push.js +224 -0
- package/dist/push/service.js +93 -0
- package/dist/push/store.js +121 -0
- package/dist/push/types.js +58 -0
- package/dist/review/git.js +226 -0
- package/dist/review/routes.js +45 -0
- package/dist/review/schema.js +32 -0
- package/dist/review/state.js +79 -0
- package/dist/stats/aggregator.js +252 -0
- package/dist/stats/canonical.js +89 -0
- package/dist/stats/cli/stats.js +302 -0
- package/dist/stats/engine.js +176 -0
- package/dist/stats/index.js +5 -0
- package/dist/stats/rollup.js +460 -0
- package/dist/stats/routes/stats.js +94 -0
- package/dist/stats/routes/usage.js +83 -0
- package/dist/stats/storage.js +86 -0
- package/dist/stats/types.js +267 -0
- package/dist/worker.js +6 -0
- package/package.json +232 -4
- package/src/agents.ts +15 -0
- package/src/cli-adapter.ts +11 -0
- package/src/component.ts +73 -0
- package/src/config-schema.ts +289 -0
- package/src/execution/activity.ts +83 -0
- package/src/execution/content.ts +189 -0
- package/src/execution/json-sections.ts +230 -0
- package/src/execution/presentation.ts +91 -0
- package/src/execution/preview.ts +12 -0
- package/src/execution/process.ts +118 -0
- package/src/execution/projection.ts +330 -0
- package/src/execution/query.ts +19 -0
- package/src/execution/routes.ts +324 -0
- package/src/execution/schema.ts +336 -0
- package/src/execution/service.ts +2149 -0
- package/src/features.ts +22 -0
- package/src/http.ts +75 -0
- package/src/performance/analysis.ts +356 -0
- package/src/performance/catalog.ts +902 -0
- package/src/performance/dashboard.ts +521 -0
- package/src/performance/error.ts +32 -0
- package/src/performance/index.ts +6 -0
- package/src/performance/inflight.ts +46 -0
- package/src/performance/projection.ts +143 -0
- package/src/performance/prompt/base.txt +38 -0
- package/src/performance/prompt/builder.ts +24 -0
- package/src/performance/routes/performance-route.ts +541 -0
- package/src/performance/schema.ts +695 -0
- package/src/performance/timeline.ts +100 -0
- package/src/performance/trace-detail.ts +97 -0
- package/src/presentation/service.ts +303 -0
- package/src/presentation/tools/workbench-view.ts +75 -0
- package/src/presentation/tools.ts +11 -0
- package/src/project/directories.ts +391 -0
- package/src/project/git-health.ts +577 -0
- package/src/project/ide.ts +80 -0
- package/src/project/migration.ts +24 -0
- package/src/project/routes/directories.ts +124 -0
- package/src/project/routes/git.ts +42 -0
- package/src/project/routes/task-defaults.ts +50 -0
- package/src/project/session-health.ts +10 -0
- package/src/project/startup.ts +28 -0
- package/src/project/task-defaults.ts +70 -0
- package/src/project/tools/session-control.ts +725 -0
- package/src/project/tools/session-control.txt +60 -0
- package/src/project/tools/worktree-archive.ts +92 -0
- package/src/project/tools/worktree-enter.ts +227 -0
- package/src/project/tools/worktree-leave.ts +140 -0
- package/src/project/tools/worktree-list.ts +68 -0
- package/src/project/tools.ts +29 -0
- package/src/project/vcs-branch-watcher.ts +81 -0
- package/src/project/vcs.ts +124 -0
- package/src/project/worktree-janitor.ts +153 -0
- package/src/project/worktrees.ts +110 -0
- package/src/push/bridge.ts +182 -0
- package/src/push/index.ts +4 -0
- package/src/push/routes/push.ts +267 -0
- package/src/push/service.ts +108 -0
- package/src/push/store.ts +120 -0
- package/src/push/types.ts +67 -0
- package/src/review/git.ts +221 -0
- package/src/review/routes.ts +65 -0
- package/src/review/schema.ts +34 -0
- package/src/review/state.ts +77 -0
- package/src/stats/aggregator.ts +277 -0
- package/src/stats/canonical.ts +93 -0
- package/src/stats/cli/stats.ts +348 -0
- package/src/stats/engine.ts +197 -0
- package/src/stats/index.ts +5 -0
- package/src/stats/rollup.ts +522 -0
- package/src/stats/routes/stats.ts +107 -0
- package/src/stats/routes/usage.ts +103 -0
- package/src/stats/storage.ts +78 -0
- package/src/stats/types.ts +351 -0
- package/src/worker.ts +7 -0
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
import { RuntimeContext } from "@ericsanchezok/synergy-harness/lifecycle/context"
|
|
2
|
+
import path from "path"
|
|
3
|
+
import fs from "fs"
|
|
4
|
+
|
|
5
|
+
export namespace GitHealth {
|
|
6
|
+
export interface Issue {
|
|
7
|
+
dimension:
|
|
8
|
+
| "diff_lines"
|
|
9
|
+
| "diff_files"
|
|
10
|
+
| "untracked"
|
|
11
|
+
| "large_files"
|
|
12
|
+
| "extra_branches"
|
|
13
|
+
| "detached_head"
|
|
14
|
+
| "gc_needed"
|
|
15
|
+
| "unpushed"
|
|
16
|
+
level: "warn" | "critical"
|
|
17
|
+
message: string
|
|
18
|
+
detail: Record<string, unknown>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface CacheEntry {
|
|
22
|
+
issues: Issue[]
|
|
23
|
+
ts: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface RepoInfo {
|
|
27
|
+
root: string
|
|
28
|
+
gitDir: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Cache — per-directory with 5-minute TTL
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
const runtimeState = RuntimeContext.state(() => ({
|
|
35
|
+
_cache: new Map<string, CacheEntry>(),
|
|
36
|
+
_refreshing: new Map<string, Promise<Issue[]>>(),
|
|
37
|
+
_aliases: new Map<string, string>(),
|
|
38
|
+
_lastDir: undefined as string | undefined,
|
|
39
|
+
_generation: 0,
|
|
40
|
+
_revisions: new Map<string, number>(),
|
|
41
|
+
_dirty: new Set<string>(),
|
|
42
|
+
_failed: new Set<string>(),
|
|
43
|
+
}))
|
|
44
|
+
|
|
45
|
+
const CACHE_TTL_MS = 5 * 60 * 1000
|
|
46
|
+
const GIT_COMMAND_TIMEOUT_MS = 2_000
|
|
47
|
+
const SCAN_TIMEOUT_MS = 3_000
|
|
48
|
+
const LARGE_FILE_STAT_BATCH_SIZE = 64
|
|
49
|
+
const DEFAULT_GC_AUTO = 6700
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Helpers
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
function normalizeDir(cwd: string): string {
|
|
56
|
+
return path.resolve(cwd)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function cacheKey(cwd: string): string {
|
|
60
|
+
const instanceState = runtimeState()
|
|
61
|
+
|
|
62
|
+
const dir = normalizeDir(cwd)
|
|
63
|
+
return instanceState._aliases.get(dir) ?? dir
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function remainingMs(deadline: number): number {
|
|
67
|
+
return Math.max(0, deadline - Date.now())
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function withDeadline<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
71
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
72
|
+
const fallback = new Promise<T>((_resolve, reject) => {
|
|
73
|
+
timer = setTimeout(() => reject(new Error("Git health scan timed out")), ms)
|
|
74
|
+
})
|
|
75
|
+
return Promise.race([promise, fallback]).finally(() => {
|
|
76
|
+
if (timer) clearTimeout(timer)
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function gitText(
|
|
81
|
+
cwd: string,
|
|
82
|
+
args: string[],
|
|
83
|
+
options?: { allowedExit?: number[]; notRepo?: boolean },
|
|
84
|
+
): Promise<string | undefined> {
|
|
85
|
+
const proc = Bun.spawn(["git", ...args], {
|
|
86
|
+
cwd,
|
|
87
|
+
stdout: "pipe",
|
|
88
|
+
stderr: "pipe",
|
|
89
|
+
signal: AbortSignal.timeout(GIT_COMMAND_TIMEOUT_MS),
|
|
90
|
+
})
|
|
91
|
+
const stdout = new Response(proc.stdout).text()
|
|
92
|
+
const stderr = new Response(proc.stderr).text().catch(() => "")
|
|
93
|
+
const [output, exitCode] = await Promise.all([stdout, proc.exited])
|
|
94
|
+
const errorText = await stderr
|
|
95
|
+
if (exitCode !== 0) {
|
|
96
|
+
if (options?.allowedExit?.includes(exitCode) || (options?.notRepo && /not a git repository/i.test(errorText)))
|
|
97
|
+
return undefined
|
|
98
|
+
throw new Error(`Git health probe failed (${args[0]}, exit ${exitCode})`)
|
|
99
|
+
}
|
|
100
|
+
return output
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function resolveRepo(cwd: string): Promise<RepoInfo | undefined> {
|
|
104
|
+
const dir = normalizeDir(cwd)
|
|
105
|
+
const inside = await gitText(dir, ["rev-parse", "--is-inside-work-tree"], { notRepo: true })
|
|
106
|
+
if (inside?.trim() !== "true") return undefined
|
|
107
|
+
|
|
108
|
+
const [rootText, gitDirText] = await Promise.all([
|
|
109
|
+
gitText(dir, ["rev-parse", "--show-toplevel"]),
|
|
110
|
+
gitText(dir, ["rev-parse", "--git-dir"]),
|
|
111
|
+
])
|
|
112
|
+
const root = rootText?.trim()
|
|
113
|
+
const gitDir = gitDirText?.trim()
|
|
114
|
+
if (!root || !gitDir) return undefined
|
|
115
|
+
|
|
116
|
+
const resolvedRoot = normalizeDir(root)
|
|
117
|
+
return {
|
|
118
|
+
root: resolvedRoot,
|
|
119
|
+
gitDir: path.isAbsolute(gitDir) ? gitDir : path.resolve(dir, gitDir),
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function render(issues: Issue[]): string | undefined {
|
|
124
|
+
const active = issues.filter((i) => i.level === "warn" || i.level === "critical")
|
|
125
|
+
if (active.length === 0) return undefined
|
|
126
|
+
|
|
127
|
+
const lines = [
|
|
128
|
+
"<git-health>",
|
|
129
|
+
"Latest completed diagnostic check; this is a bounded historical observation, not a live guarantee of repository state.",
|
|
130
|
+
]
|
|
131
|
+
for (const issue of active) {
|
|
132
|
+
const label = issue.level === "critical" ? "Critical" : "Warning"
|
|
133
|
+
lines.push(`${label}: ${issue.message}`)
|
|
134
|
+
}
|
|
135
|
+
lines.push("</git-health>")
|
|
136
|
+
return lines.join("\n")
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function parseNumstat(text: string): { additions: number; deletions: number; files: Set<string> } {
|
|
140
|
+
let additions = 0
|
|
141
|
+
let deletions = 0
|
|
142
|
+
const files = new Set<string>()
|
|
143
|
+
|
|
144
|
+
for (const line of text.split("\n")) {
|
|
145
|
+
const trimmed = line.trim()
|
|
146
|
+
if (!trimmed) continue
|
|
147
|
+
const parts = trimmed.split("\t")
|
|
148
|
+
const add = parseInt(parts[0])
|
|
149
|
+
const del = parseInt(parts[1])
|
|
150
|
+
if (!isNaN(add)) additions += add
|
|
151
|
+
if (!isNaN(del)) deletions += del
|
|
152
|
+
if (parts.length >= 3 && parts[2]) files.add(parts[2])
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { additions, deletions, files }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function countLooseObjectsOnDisk(gitDir: string): number {
|
|
159
|
+
const objectsDir = path.join(gitDir, "objects")
|
|
160
|
+
let count = 0
|
|
161
|
+
try {
|
|
162
|
+
for (const entry of fs.readdirSync(objectsDir)) {
|
|
163
|
+
if (entry === "pack" || entry === "info") continue
|
|
164
|
+
const subDir = path.join(objectsDir, entry)
|
|
165
|
+
const subStat = fs.statSync(subDir, { throwIfNoEntry: false })
|
|
166
|
+
if (subStat?.isDirectory()) {
|
|
167
|
+
count += fs.readdirSync(subDir).length
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
} catch {}
|
|
171
|
+
return count
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Dimensions 1+2: diff_lines and diff_files (single pass)
|
|
176
|
+
// diff_lines thresholds: 200→warn, 800→critical
|
|
177
|
+
// diff_files thresholds: 10→warn, 25→critical
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
async function checkDiff(cwd: string): Promise<Issue[]> {
|
|
180
|
+
const head = await gitText(cwd, ["rev-parse", "--verify", "--quiet", "HEAD"], { allowedExit: [1] })
|
|
181
|
+
const reference = head ? ["HEAD"] : []
|
|
182
|
+
const [unstaged, staged] = await Promise.all([
|
|
183
|
+
gitText(cwd, ["diff", "--numstat", ...reference]).then((x) => x ?? ""),
|
|
184
|
+
gitText(cwd, ["diff", "--cached", "--numstat", ...reference]).then((x) => x ?? ""),
|
|
185
|
+
])
|
|
186
|
+
|
|
187
|
+
const unstagedParsed = parseNumstat(unstaged)
|
|
188
|
+
const stagedParsed = parseNumstat(staged)
|
|
189
|
+
|
|
190
|
+
const issues: Issue[] = []
|
|
191
|
+
|
|
192
|
+
// diff_lines
|
|
193
|
+
const unstagedLines = unstagedParsed.additions + unstagedParsed.deletions
|
|
194
|
+
const stagedLines = stagedParsed.additions + stagedParsed.deletions
|
|
195
|
+
const total = unstagedLines + stagedLines
|
|
196
|
+
|
|
197
|
+
if (total >= 200) {
|
|
198
|
+
issues.push({
|
|
199
|
+
dimension: "diff_lines",
|
|
200
|
+
level: total >= 800 ? "critical" : "warn",
|
|
201
|
+
message: `${total} lines of uncommitted changes — commit now to avoid losing work`,
|
|
202
|
+
detail: { lines: total, staged: stagedLines, unstaged: unstagedLines },
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// diff_files
|
|
207
|
+
const allFiles = new Set([...unstagedParsed.files, ...stagedParsed.files])
|
|
208
|
+
const count = allFiles.size
|
|
209
|
+
|
|
210
|
+
if (count >= 10) {
|
|
211
|
+
issues.push({
|
|
212
|
+
dimension: "diff_files",
|
|
213
|
+
level: count >= 25 ? "critical" : "warn",
|
|
214
|
+
message: `${count} files modified but not committed — split into focused commits while intent is fresh`,
|
|
215
|
+
detail: { files: count },
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return issues
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// Dimension 3: untracked
|
|
224
|
+
// Thresholds: 15→warn, 80→critical
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
async function checkUntracked(cwd: string): Promise<Issue | undefined> {
|
|
227
|
+
const result = (await gitText(cwd, ["ls-files", "--others", "--exclude-standard"])) ?? ""
|
|
228
|
+
|
|
229
|
+
const trimmed = result.trim()
|
|
230
|
+
const count = trimmed ? trimmed.split("\n").length : 0
|
|
231
|
+
|
|
232
|
+
if (count < 15) return undefined
|
|
233
|
+
|
|
234
|
+
const level: Issue["level"] = count >= 80 ? "critical" : "warn"
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
dimension: "untracked",
|
|
238
|
+
level,
|
|
239
|
+
message: `${count} untracked files — review with git status: add, ignore, or clean up`,
|
|
240
|
+
detail: { count },
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// Dimension 4: large_files
|
|
246
|
+
// Thresholds: >10MB→warn, >50MB→critical
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
async function checkLargeFiles(cwd: string, deadline: number): Promise<Issue | undefined> {
|
|
249
|
+
const result = (await gitText(cwd, ["ls-files", "-z"])) ?? ""
|
|
250
|
+
|
|
251
|
+
const fileNames = result.split("\0").filter(Boolean)
|
|
252
|
+
if (fileNames.length === 0) return undefined
|
|
253
|
+
// Cap to avoid dominating check time on repos with hundreds of thousands of files.
|
|
254
|
+
const cappedNames = fileNames.length > 10000 ? fileNames.slice(0, 10000) : fileNames
|
|
255
|
+
if (cappedNames.length === 0) return undefined
|
|
256
|
+
|
|
257
|
+
const large: { path: string; size: number }[] = []
|
|
258
|
+
|
|
259
|
+
for (let i = 0; i < cappedNames.length; i += LARGE_FILE_STAT_BATCH_SIZE) {
|
|
260
|
+
if (Date.now() >= deadline) throw new Error("Git health file scan timed out")
|
|
261
|
+
const batch = cappedNames.slice(i, i + LARGE_FILE_STAT_BATCH_SIZE)
|
|
262
|
+
const stats = await withDeadline(
|
|
263
|
+
Promise.all(
|
|
264
|
+
batch.map(async (fileName) => {
|
|
265
|
+
const filePath = path.join(cwd, fileName)
|
|
266
|
+
const stat = await fs.promises.lstat(filePath).catch((error: NodeJS.ErrnoException) => {
|
|
267
|
+
if (error.code === "ENOENT") return undefined
|
|
268
|
+
throw error
|
|
269
|
+
})
|
|
270
|
+
if (!stat || stat.isSymbolicLink()) return undefined
|
|
271
|
+
return { fileName, stat }
|
|
272
|
+
}),
|
|
273
|
+
),
|
|
274
|
+
remainingMs(deadline),
|
|
275
|
+
)
|
|
276
|
+
for (const result of stats) {
|
|
277
|
+
if (result && result.stat.size > 10 * 1024 * 1024) {
|
|
278
|
+
large.push({ path: result.fileName, size: result.stat.size })
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (large.length === 0) return undefined
|
|
284
|
+
|
|
285
|
+
large.sort((a, b) => b.size - a.size)
|
|
286
|
+
const largest = large[0]
|
|
287
|
+
|
|
288
|
+
const level: Issue["level"] = largest.size >= 50 * 1024 * 1024 ? "critical" : "warn"
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
dimension: "large_files",
|
|
292
|
+
level,
|
|
293
|
+
message: `${large.length} large file(s) tracked — largest is ${largest.path} (${Math.round(largest.size / (1024 * 1024))}MB). If committed by accident, add to .gitignore and remove from tracking`,
|
|
294
|
+
detail: {
|
|
295
|
+
count: large.length,
|
|
296
|
+
largest: largest.path,
|
|
297
|
+
largestSize: largest.size,
|
|
298
|
+
files: large.slice(0, 5).map((f) => f.path),
|
|
299
|
+
},
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
// Dimension 5: extra_branches
|
|
305
|
+
// Thresholds: 10→warn, 40→critical
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
async function checkExtraBranches(cwd: string): Promise<Issue | undefined> {
|
|
308
|
+
const [headBranch, branchesText] = await Promise.all([
|
|
309
|
+
gitText(cwd, ["symbolic-ref", "--quiet", "--short", "HEAD"], { allowedExit: [1] }).then((x) => x?.trim() ?? ""),
|
|
310
|
+
gitText(cwd, ["for-each-ref", "--format=%(refname:short)", "refs/heads/"]).then((x) => x ?? ""),
|
|
311
|
+
])
|
|
312
|
+
|
|
313
|
+
const branches = branchesText.trim().split("\n").filter(Boolean)
|
|
314
|
+
if (branches.length === 0) return undefined
|
|
315
|
+
|
|
316
|
+
const count = branches.filter((b) => b !== headBranch).length
|
|
317
|
+
if (count < 10) return undefined
|
|
318
|
+
|
|
319
|
+
const level: Issue["level"] = count >= 40 ? "critical" : "warn"
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
dimension: "extra_branches",
|
|
323
|
+
level,
|
|
324
|
+
message: `${count} non-current branches — archive or remove them via the worktree cleanup path; deleting is deferred there because squash- or rebase-merged branches are not detected as merged by ancestry alone, which needs content comparison`,
|
|
325
|
+
detail: { count },
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
// Dimension 6: detached_head
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
async function checkDetachedHead(cwd: string): Promise<Issue | undefined> {
|
|
333
|
+
const symRef = await gitText(cwd, ["symbolic-ref", "--quiet", "HEAD"], { allowedExit: [1] })
|
|
334
|
+
if (symRef !== undefined) return undefined
|
|
335
|
+
|
|
336
|
+
const headCommit = (await gitText(cwd, ["rev-parse", "HEAD"]))?.trim() ?? ""
|
|
337
|
+
if (!headCommit) return undefined
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
dimension: "detached_head",
|
|
341
|
+
level: "critical",
|
|
342
|
+
message: "Detached HEAD — commits will be lost unless you create a branch",
|
|
343
|
+
detail: { headCommit },
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
// Dimension 7: gc_needed
|
|
349
|
+
// Thresholds derive from the repo's effective gc.auto (git's documented
|
|
350
|
+
// default is 6700): git auto-gcs once loose objects exceed gc.auto, so warn at
|
|
351
|
+
// a quarter of that. Uses both git count-objects and filesystem count (max of
|
|
352
|
+
// the two); git count-objects only counts valid objects, while the filesystem
|
|
353
|
+
// fallback catches hand-crafted test fixture objects.
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
async function checkGcNeeded(cwd: string, gitDir: string, deadline: number): Promise<Issue | undefined> {
|
|
356
|
+
const [output, gcAutoText] = await Promise.all([
|
|
357
|
+
gitText(cwd, ["count-objects", "-v"]).then((x) => x ?? ""),
|
|
358
|
+
gitText(cwd, ["config", "--get", "gc.auto"], { allowedExit: [1] }),
|
|
359
|
+
])
|
|
360
|
+
|
|
361
|
+
const countMatch = output.match(/count:\s*(\d+)/)
|
|
362
|
+
const sizeMatch = output.match(/size:\s*(\d+)/)
|
|
363
|
+
|
|
364
|
+
const gitCount = countMatch ? parseInt(countMatch[1]) : 0
|
|
365
|
+
const diskCount = Date.now() >= deadline ? 0 : countLooseObjectsOnDisk(gitDir)
|
|
366
|
+
const looseObjects = Math.max(gitCount, diskCount)
|
|
367
|
+
const size = sizeMatch ? parseInt(sizeMatch[1]) : 0
|
|
368
|
+
|
|
369
|
+
// gc.auto <= 0 disables auto-gc, so treat it as absent rather than as a threshold.
|
|
370
|
+
const configuredGcAuto = parseInt(gcAutoText?.trim() ?? "")
|
|
371
|
+
const gcAuto = Number.isFinite(configuredGcAuto) && configuredGcAuto > 0 ? configuredGcAuto : DEFAULT_GC_AUTO
|
|
372
|
+
|
|
373
|
+
if (looseObjects < Math.floor(gcAuto / 4)) return undefined
|
|
374
|
+
|
|
375
|
+
const level: Issue["level"] = looseObjects >= gcAuto ? "critical" : "warn"
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
dimension: "gc_needed",
|
|
379
|
+
level,
|
|
380
|
+
message: `Git gc recommended — ${looseObjects} loose objects (gc.auto ${gcAuto}). Run git gc to reclaim space`,
|
|
381
|
+
detail: { looseObjects, size },
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
// Dimension 8: unpushed
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
async function checkUnpushed(cwd: string): Promise<Issue | undefined> {
|
|
389
|
+
// With no remote-tracking refs `--not --remotes` subtracts nothing and the
|
|
390
|
+
// count degenerates to the entire local history, so only report when the
|
|
391
|
+
// repo actually tracks a remote.
|
|
392
|
+
const remoteRefs = await gitText(cwd, ["for-each-ref", "--count=1", "--format=%(refname)", "refs/remotes/"])
|
|
393
|
+
if (!remoteRefs?.trim()) return undefined
|
|
394
|
+
if (!(await gitText(cwd, ["rev-parse", "--verify", "--quiet", "HEAD"], { allowedExit: [1] }))) return undefined
|
|
395
|
+
|
|
396
|
+
const output = await gitText(cwd, ["rev-list", "--count", "HEAD", "--not", "--remotes"])
|
|
397
|
+
|
|
398
|
+
const count = parseInt(output?.trim() ?? "")
|
|
399
|
+
if (!Number.isFinite(count) || count <= 0) return undefined
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
dimension: "unpushed",
|
|
403
|
+
level: "warn",
|
|
404
|
+
message: `${count} commit(s) not on any remote — push to preserve this work`,
|
|
405
|
+
detail: { unpushed: count },
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
// Public API
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
|
|
413
|
+
async function scan(cwd: string): Promise<{ dir: string; issues: Issue[] }> {
|
|
414
|
+
const fallbackDir = normalizeDir(cwd)
|
|
415
|
+
const deadline = Date.now() + SCAN_TIMEOUT_MS
|
|
416
|
+
const repo = await resolveRepo(fallbackDir)
|
|
417
|
+
if (!repo) return { dir: fallbackDir, issues: [] }
|
|
418
|
+
|
|
419
|
+
const remaining = remainingMs(deadline)
|
|
420
|
+
if (remaining === 0) throw new Error("Git health repository probe timed out")
|
|
421
|
+
|
|
422
|
+
const checks: Promise<Issue | Issue[] | undefined>[] = [
|
|
423
|
+
checkDiff(repo.root),
|
|
424
|
+
checkUntracked(repo.root),
|
|
425
|
+
checkLargeFiles(repo.root, deadline),
|
|
426
|
+
checkExtraBranches(repo.root),
|
|
427
|
+
checkDetachedHead(repo.root),
|
|
428
|
+
checkGcNeeded(repo.root, repo.gitDir, deadline),
|
|
429
|
+
checkUnpushed(repo.root),
|
|
430
|
+
]
|
|
431
|
+
const results = await withDeadline(Promise.all(checks), remaining)
|
|
432
|
+
|
|
433
|
+
const issues = results.flat().filter((i): i is Issue => i !== undefined)
|
|
434
|
+
return { dir: repo.root, issues }
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Live repository probe shared with the env prompt block. Deliberately
|
|
439
|
+
* bypasses the scan cache: callers (per-turn env assembly) need the current
|
|
440
|
+
* answer, and a stale repo probe is exactly the bug this replaces.
|
|
441
|
+
*/
|
|
442
|
+
export async function isGitRepo(cwd?: string): Promise<boolean> {
|
|
443
|
+
const repo = await resolveRepo(normalizeDir(cwd ?? process.cwd())).catch(() => undefined)
|
|
444
|
+
return repo !== undefined
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export function refresh(cwd?: string): Promise<Issue[]> {
|
|
448
|
+
const instanceState = runtimeState()
|
|
449
|
+
|
|
450
|
+
const inputDir = normalizeDir(cwd ?? process.cwd())
|
|
451
|
+
const key = cacheKey(inputDir)
|
|
452
|
+
const existing = instanceState._refreshing.get(key) ?? instanceState._refreshing.get(inputDir)
|
|
453
|
+
if (existing) return existing
|
|
454
|
+
|
|
455
|
+
const generation = instanceState._generation
|
|
456
|
+
const revisions = new Map(instanceState._revisions)
|
|
457
|
+
const unchanged = (dir: string) => (revisions.get(dir) ?? 0) === (instanceState._revisions.get(dir) ?? 0)
|
|
458
|
+
let promise!: Promise<Issue[]>
|
|
459
|
+
promise = scan(inputDir)
|
|
460
|
+
.then((result) => {
|
|
461
|
+
const instanceState = runtimeState()
|
|
462
|
+
|
|
463
|
+
if (generation === instanceState._generation) instanceState._aliases.set(inputDir, result.dir)
|
|
464
|
+
if (generation === instanceState._generation && unchanged(key) && unchanged(result.dir)) {
|
|
465
|
+
instanceState._cache.set(result.dir, { issues: result.issues, ts: Date.now() })
|
|
466
|
+
instanceState._lastDir = result.dir
|
|
467
|
+
instanceState._dirty.delete(key)
|
|
468
|
+
instanceState._dirty.delete(result.dir)
|
|
469
|
+
instanceState._dirty.delete(inputDir)
|
|
470
|
+
instanceState._failed.delete(key)
|
|
471
|
+
instanceState._failed.delete(result.dir)
|
|
472
|
+
}
|
|
473
|
+
return result.issues
|
|
474
|
+
})
|
|
475
|
+
.catch(() => {
|
|
476
|
+
if (generation === instanceState._generation && unchanged(key)) {
|
|
477
|
+
instanceState._failed.add(key)
|
|
478
|
+
instanceState._dirty.delete(key)
|
|
479
|
+
}
|
|
480
|
+
return instanceState._cache.get(key)?.issues ?? []
|
|
481
|
+
})
|
|
482
|
+
.finally(() => {
|
|
483
|
+
const instanceState = runtimeState()
|
|
484
|
+
|
|
485
|
+
if (instanceState._refreshing.get(key) === promise) instanceState._refreshing.delete(key)
|
|
486
|
+
if (instanceState._refreshing.get(inputDir) === promise) instanceState._refreshing.delete(inputDir)
|
|
487
|
+
})
|
|
488
|
+
.then((issues) =>
|
|
489
|
+
generation === instanceState._generation && (!unchanged(key) || !unchanged(cacheKey(inputDir)))
|
|
490
|
+
? refresh(inputDir)
|
|
491
|
+
: issues,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
instanceState._refreshing.set(key, promise)
|
|
495
|
+
if (key !== inputDir) instanceState._refreshing.set(inputDir, promise)
|
|
496
|
+
return promise
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export async function check(cwd?: string): Promise<Issue[]> {
|
|
500
|
+
const instanceState = runtimeState()
|
|
501
|
+
|
|
502
|
+
const dir = normalizeDir(cwd ?? process.cwd())
|
|
503
|
+
const key = cacheKey(dir)
|
|
504
|
+
const cached = instanceState._cache.get(key)
|
|
505
|
+
if (
|
|
506
|
+
cached &&
|
|
507
|
+
!instanceState._dirty.has(key) &&
|
|
508
|
+
!instanceState._failed.has(key) &&
|
|
509
|
+
Date.now() - cached.ts < CACHE_TTL_MS
|
|
510
|
+
) {
|
|
511
|
+
instanceState._lastDir = key
|
|
512
|
+
return cached.issues
|
|
513
|
+
}
|
|
514
|
+
return refresh(dir)
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export async function inject(cwd?: string): Promise<string | undefined> {
|
|
518
|
+
return render(await check(cwd))
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
export function injectCached(cwd?: string): string | undefined {
|
|
522
|
+
const observation = observeCached(cwd)
|
|
523
|
+
return observation.state === "present" ? observation.text : undefined
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
export function observeCached(
|
|
527
|
+
cwd?: string,
|
|
528
|
+
): { state: "present"; text: string } | { state: "cleared" | "unavailable" | "pending" } {
|
|
529
|
+
const instanceState = runtimeState()
|
|
530
|
+
|
|
531
|
+
const dir = normalizeDir(cwd ?? process.cwd())
|
|
532
|
+
const key = cacheKey(dir)
|
|
533
|
+
const cached = instanceState._cache.get(key)
|
|
534
|
+
if (
|
|
535
|
+
!cached ||
|
|
536
|
+
instanceState._dirty.has(key) ||
|
|
537
|
+
instanceState._failed.has(key) ||
|
|
538
|
+
Date.now() - cached.ts >= CACHE_TTL_MS
|
|
539
|
+
) {
|
|
540
|
+
refresh(dir).catch(() => {})
|
|
541
|
+
}
|
|
542
|
+
if (instanceState._failed.has(key)) return { state: "unavailable" }
|
|
543
|
+
if (!cached || instanceState._dirty.has(key) || Date.now() - cached.ts >= CACHE_TTL_MS) return { state: "pending" }
|
|
544
|
+
instanceState._lastDir = key
|
|
545
|
+
const text = render(cached.issues)
|
|
546
|
+
return text ? { state: "present", text } : { state: "cleared" }
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export function lastReport(): Issue[] | undefined {
|
|
550
|
+
const instanceState = runtimeState()
|
|
551
|
+
|
|
552
|
+
if (!instanceState._lastDir) return undefined
|
|
553
|
+
const cached = instanceState._cache.get(instanceState._lastDir)
|
|
554
|
+
return cached?.issues.length ? cached.issues : undefined
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export function invalidate(cwd?: string): void {
|
|
558
|
+
const instanceState = runtimeState()
|
|
559
|
+
|
|
560
|
+
if (cwd === undefined) {
|
|
561
|
+
instanceState._generation++
|
|
562
|
+
instanceState._refreshing.clear()
|
|
563
|
+
instanceState._cache.clear()
|
|
564
|
+
instanceState._aliases.clear()
|
|
565
|
+
instanceState._lastDir = undefined
|
|
566
|
+
instanceState._revisions.clear()
|
|
567
|
+
instanceState._dirty.clear()
|
|
568
|
+
instanceState._failed.clear()
|
|
569
|
+
return
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const dir = normalizeDir(cwd)
|
|
573
|
+
const key = cacheKey(dir)
|
|
574
|
+
instanceState._revisions.set(key, (instanceState._revisions.get(key) ?? 0) + 1)
|
|
575
|
+
instanceState._dirty.add(key)
|
|
576
|
+
}
|
|
577
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { RuntimeContext } from "@ericsanchezok/synergy-harness/lifecycle/context"
|
|
2
|
+
import { BusEvent } from "@ericsanchezok/synergy-harness/bus/bus-event"
|
|
3
|
+
import { Bus } from "@ericsanchezok/synergy-harness/bus"
|
|
4
|
+
import { spawn } from "bun"
|
|
5
|
+
import z from "zod"
|
|
6
|
+
import { NamedError } from "@ericsanchezok/synergy-util/error"
|
|
7
|
+
import { Log } from "@ericsanchezok/synergy-harness/util/log"
|
|
8
|
+
|
|
9
|
+
const SUPPORTED_IDES = [
|
|
10
|
+
{ name: "Windsurf" as const, cmd: "windsurf" },
|
|
11
|
+
{ name: "Visual Studio Code - Insiders" as const, cmd: "code-insiders" },
|
|
12
|
+
{ name: "Visual Studio Code" as const, cmd: "code" },
|
|
13
|
+
{ name: "Cursor" as const, cmd: "cursor" },
|
|
14
|
+
{ name: "VSCodium" as const, cmd: "codium" },
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
export namespace Ide {
|
|
18
|
+
const log = Log.create({ service: "ide" })
|
|
19
|
+
|
|
20
|
+
export const Event = {
|
|
21
|
+
Installed: BusEvent.define(
|
|
22
|
+
"ide.installed",
|
|
23
|
+
z.object({
|
|
24
|
+
ide: z.string(),
|
|
25
|
+
}),
|
|
26
|
+
),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const AlreadyInstalledError = NamedError.create("AlreadyInstalledError", z.object({}))
|
|
30
|
+
|
|
31
|
+
export const InstallFailedError = NamedError.create(
|
|
32
|
+
"InstallFailedError",
|
|
33
|
+
z.object({
|
|
34
|
+
stderr: z.string(),
|
|
35
|
+
}),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
export function ide() {
|
|
39
|
+
if (RuntimeContext.current().host.env["TERM_PROGRAM"] === "vscode") {
|
|
40
|
+
const v = RuntimeContext.current().host.env["GIT_ASKPASS"]
|
|
41
|
+
for (const ide of SUPPORTED_IDES) {
|
|
42
|
+
if (v?.includes(ide.name)) return ide.name
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return "unknown"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function alreadyInstalled() {
|
|
49
|
+
return (
|
|
50
|
+
RuntimeContext.current().host.env["SYNERGY_CALLER"] === "vscode" ||
|
|
51
|
+
RuntimeContext.current().host.env["SYNERGY_CALLER"] === "vscode-insiders"
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function install(ide: (typeof SUPPORTED_IDES)[number]["name"]) {
|
|
56
|
+
const cmd = SUPPORTED_IDES.find((i) => i.name === ide)?.cmd
|
|
57
|
+
if (!cmd) throw new Error(`Unknown IDE: ${ide}`)
|
|
58
|
+
|
|
59
|
+
const p = spawn([cmd, "--install-extension", "ericsanchezok.synergy"], {
|
|
60
|
+
stdout: "pipe",
|
|
61
|
+
stderr: "pipe",
|
|
62
|
+
})
|
|
63
|
+
await p.exited
|
|
64
|
+
const stdout = await new Response(p.stdout).text()
|
|
65
|
+
const stderr = await new Response(p.stderr).text()
|
|
66
|
+
|
|
67
|
+
log.info("installed", {
|
|
68
|
+
ide,
|
|
69
|
+
stdout,
|
|
70
|
+
stderr,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
if (p.exitCode !== 0) {
|
|
74
|
+
throw new InstallFailedError({ stderr })
|
|
75
|
+
}
|
|
76
|
+
if (stdout.includes("already installed")) {
|
|
77
|
+
throw new AlreadyInstalledError({})
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { MigrationRegistry } from "@ericsanchezok/synergy-harness/migration/registry"
|
|
2
|
+
import { Scope } from "@ericsanchezok/synergy-harness/scope"
|
|
3
|
+
import { ProjectDirectories } from "./directories"
|
|
4
|
+
|
|
5
|
+
export const projectMigrations = [
|
|
6
|
+
{
|
|
7
|
+
id: "20260929-project-directories",
|
|
8
|
+
scope: "global" as const,
|
|
9
|
+
execution: "startup" as const,
|
|
10
|
+
description: "Separate project folders from historical Worktrees without changing Scope identity",
|
|
11
|
+
async up(progress: (current: number, total: number) => void) {
|
|
12
|
+
const projects = await Scope.list()
|
|
13
|
+
progress(0, projects.length)
|
|
14
|
+
for (const [index, project] of projects.entries()) {
|
|
15
|
+
await ProjectDirectories.migrateProject(project)
|
|
16
|
+
progress(index + 1, projects.length)
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
export function registerProjectMigrations() {
|
|
23
|
+
MigrationRegistry.register("workbench-projects", projectMigrations)
|
|
24
|
+
}
|