@amsterdamdatalabs/adl-agent-control-linux-x64 0.1.10 → 0.1.12
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/bin/adl +2 -2
- package/bin/git +1 -1
- package/bin/git-ado +2 -2
- package/bin/lib/bun-compile-build.sh +1 -1
- package/bin/lib/fleet-host.sh +59 -0
- package/bin/lib/lean-ctx-observe-hooks.sh +9 -9
- package/bin/reconcile +222 -249
- package/bin/reconcile-host +111 -0
- package/bin/setup +216 -0
- package/bin/sync-and-reconcile +52 -39
- package/bin/validate-manifest +15 -44
- package/package.json +1 -3
- package/packages/agent-hooks/package.json +1 -2
- package/packages/agent-hooks/src/rules/impl/fs-trash.ts +2 -2
- package/packages/agent-settings/assets/GLOBAL-AGENTS.md +4 -2
- package/packages/agent-settings/src/global-surface-sweep.ts +5 -15
- package/packages/agent-skills/package.json +1 -1
- package/packages/azdo/package.json +1 -2
- package/packages/fleet/manifest.json +5 -24
- package/packages/fleet/manifest.schema.json +8 -58
- package/packages/fleet/src/install-embedding-model.ts +1 -1
- package/packages/fleet/src/repo-maintain.ts +403 -0
- package/packages/fleet/src/scratch-reap.ts +1 -1
- package/packages/repo-controls/package.json +0 -1
- package/packages/fleet/jobs/git-maintain/src/main.ts +0 -848
- package/packages/fleet/systemd/adl-azure-agent-update.service +0 -13
- package/packages/fleet/systemd/adl-azure-agent-update.timer +0 -10
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repository maintenance: the repository step of `adl reconcile`
|
|
3
|
+
* (`adl maintain --location DIR`). It replaces the retired git-maintain
|
|
4
|
+
* LaunchAgent (2026-09-30, Tarun: "one binary controlling everything").
|
|
5
|
+
*
|
|
6
|
+
* Scope is exactly ONE directory, the host's `repo_location` from the fleet
|
|
7
|
+
* manifest, and only its IMMEDIATE children that carry `adl-config.toml` and
|
|
8
|
+
* are git top-levels. Nothing else is ever touched or mentioned: every child
|
|
9
|
+
* is resolved (symlinks followed) and must still be a direct child of the
|
|
10
|
+
* location BEFORE any file inside it is stat'ed, so a symlink can never make
|
|
11
|
+
* this step read, fetch into or install into anything outside the location.
|
|
12
|
+
* A directory without the marker is not this step's business at all.
|
|
13
|
+
*
|
|
14
|
+
* Per repository, in order:
|
|
15
|
+
* 1. `git fetch origin --prune`;
|
|
16
|
+
* 2. fast-forward the local `integration` and `main` (whichever exist on
|
|
17
|
+
* both sides) -- only ever a true fast-forward. A branch checked out in a
|
|
18
|
+
* worktree (this one or another) is moved only when that worktree is
|
|
19
|
+
* clean (`merge --ff-only`), else WARN and skip; a branch checked out
|
|
20
|
+
* nowhere is moved with a compare-and-swap `update-ref`;
|
|
21
|
+
* 3. the safe-only local branch prune (ported from git-maintain): protected
|
|
22
|
+
* integration/main/master/develop and HEAD are never deleted, the KEEP
|
|
23
|
+
* most recent branches are kept, and only "[gone]" upstreams (-D) or
|
|
24
|
+
* branches merged into the baseline (-d) are deleted;
|
|
25
|
+
* 4. `adl install --repo <repo>` -- the existing per-repo enrolment.
|
|
26
|
+
*
|
|
27
|
+
* This runs as the device administrator, never root (the caller,
|
|
28
|
+
* bin/reconcile-host, drops privileges and `adl maintain --expect-user`
|
|
29
|
+
* refuses otherwise). It never migrates enact-config.toml: a repo that has
|
|
30
|
+
* both markers is a named failure.
|
|
31
|
+
*
|
|
32
|
+
* Reporting: plain progress lines go to `out`; every warning and failure goes
|
|
33
|
+
* to `err` as `WARN repo-maintain: ...` / `ERROR repo-maintain: ...` (the
|
|
34
|
+
* level word `adl logs` filters on). A failure never stops the other
|
|
35
|
+
* repositories; the run returns `ok: false` when any occurred.
|
|
36
|
+
*/
|
|
37
|
+
import { spawnSync } from 'node:child_process'
|
|
38
|
+
import { readdirSync, realpathSync, statSync, type Dirent } from 'node:fs'
|
|
39
|
+
import { basename, dirname, isAbsolute, join } from 'node:path'
|
|
40
|
+
import { NO_AGENT_IDENTITY_MESSAGE, resolveAgentIdentity } from '../../azdo/src/lib/agent-identity.ts'
|
|
41
|
+
|
|
42
|
+
export const TCC_DENIED_MARKER = 'TCC denied'
|
|
43
|
+
|
|
44
|
+
/** The file whose presence puts a repository in this step's scope. */
|
|
45
|
+
export const MARKER = 'adl-config.toml'
|
|
46
|
+
/** The retired marker; a repo with both is ambiguous and fails loud. */
|
|
47
|
+
const LEGACY_MARKER = 'enact-config.toml'
|
|
48
|
+
|
|
49
|
+
/** Branches fast-forwarded, in this order. */
|
|
50
|
+
const SYNCED_BRANCHES = ['integration', 'main'] as const
|
|
51
|
+
const PROTECTED = new Set(['integration', 'main', 'master', 'develop'])
|
|
52
|
+
const DEFAULT_KEEP = 3
|
|
53
|
+
|
|
54
|
+
/** Case-insensitive, by name and by realpath: never an Azure Pipelines agent install. */
|
|
55
|
+
const FORBIDDEN_PATH_PATTERN = /azagent|vsts\.agent/i
|
|
56
|
+
|
|
57
|
+
export interface MaintainOptions {
|
|
58
|
+
/** Absolute path of the host's `repo_location`. */
|
|
59
|
+
readonly location: string
|
|
60
|
+
/** The running adl binary, named in the Full Disk Access remedy. */
|
|
61
|
+
readonly executable: string
|
|
62
|
+
/** How to run `adl`: `<command> <...leadingArgs> install --repo <repo>`. */
|
|
63
|
+
readonly install: { readonly command: string; readonly leadingArgs: readonly string[] }
|
|
64
|
+
/** Most recent non-protected local branches kept by the prune. Default 3. */
|
|
65
|
+
readonly keep?: number
|
|
66
|
+
/** Report only: fetches, but never moves a ref, deletes a branch or runs install. */
|
|
67
|
+
readonly dryRun?: boolean
|
|
68
|
+
/** Environment for identity resolution and every child. Default process.env. */
|
|
69
|
+
readonly env?: NodeJS.ProcessEnv
|
|
70
|
+
readonly out?: (line: string) => void
|
|
71
|
+
readonly err?: (line: string) => void
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface MaintainResult {
|
|
75
|
+
readonly ok: boolean
|
|
76
|
+
readonly repositories: number
|
|
77
|
+
readonly failures: readonly string[]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Lines explaining why `root` could not be listed. EPERM from readdir is what
|
|
82
|
+
* macOS privacy protection (TCC) returns -- plain permission bits give EACCES
|
|
83
|
+
* -- so it names the exact binary the owner must grant Full Disk Access.
|
|
84
|
+
*/
|
|
85
|
+
export function listFailureLines(root: string, error: unknown, executable: string): readonly string[] {
|
|
86
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
87
|
+
const code = (error as { code?: unknown } | null)?.code
|
|
88
|
+
if (code === 'ENOTDIR') return [`${root}: ${message}`]
|
|
89
|
+
const lines = [`${root}: cannot list: ${message}`]
|
|
90
|
+
if (code === 'EPERM') {
|
|
91
|
+
lines.push(
|
|
92
|
+
`${root}: ${TCC_DENIED_MARKER}: macOS privacy protection blocked ${executable}; ` +
|
|
93
|
+
`one-time fix: System Settings › Privacy & Security › Full Disk Access › + › add ${executable}`,
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
return lines
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface GitResult {
|
|
100
|
+
readonly ok: boolean
|
|
101
|
+
readonly stdout: string
|
|
102
|
+
readonly stderr: string
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function errorCode(error: unknown): string | undefined {
|
|
106
|
+
const code = (error as { code?: unknown } | null)?.code
|
|
107
|
+
return typeof code === 'string' ? code : undefined
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function lines(output: string): string[] {
|
|
111
|
+
return output.split('\n').filter((line) => line.length > 0)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isForbiddenAzurePipelinesPath(path: string): boolean {
|
|
115
|
+
if (FORBIDDEN_PATH_PATTERN.test(path)) return true
|
|
116
|
+
try {
|
|
117
|
+
return FORBIDDEN_PATH_PATTERN.test(realpathSync(path))
|
|
118
|
+
} catch {
|
|
119
|
+
return false
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** `packages/<name>/enact-config.toml` -- the per-package copies of the retired marker. */
|
|
124
|
+
function legacyMarkers(repo: string): string[] {
|
|
125
|
+
const found: string[] = []
|
|
126
|
+
const tryStat = (relative: string): void => {
|
|
127
|
+
try {
|
|
128
|
+
if (statSync(join(repo, relative)).isFile()) found.push(relative)
|
|
129
|
+
} catch (error) {
|
|
130
|
+
if (errorCode(error) !== 'ENOENT' && errorCode(error) !== 'ENOTDIR') throw error
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
tryStat(LEGACY_MARKER)
|
|
134
|
+
let packages: Dirent[] = []
|
|
135
|
+
try {
|
|
136
|
+
packages = readdirSync(join(repo, 'packages'), { withFileTypes: true })
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (errorCode(error) !== 'ENOENT' && errorCode(error) !== 'ENOTDIR') throw error
|
|
139
|
+
}
|
|
140
|
+
for (const entry of packages.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
141
|
+
if (entry.isDirectory()) tryStat(join('packages', entry.name, LEGACY_MARKER))
|
|
142
|
+
}
|
|
143
|
+
return found
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function maintainRepositories(options: MaintainOptions): MaintainResult {
|
|
147
|
+
const env = options.env ?? process.env
|
|
148
|
+
const keep = options.keep ?? DEFAULT_KEEP
|
|
149
|
+
const dry = options.dryRun ?? false
|
|
150
|
+
const out = options.out ?? ((line: string) => void process.stdout.write(`${line}\n`))
|
|
151
|
+
const errOut = options.err ?? ((line: string) => void process.stderr.write(`${line}\n`))
|
|
152
|
+
const info = (message: string): void => out(`repo-maintain: ${message}`)
|
|
153
|
+
const warn = (message: string): void => errOut(`WARN repo-maintain: ${message}`)
|
|
154
|
+
const failures: string[] = []
|
|
155
|
+
const fail = (message: string, subject: string): void => {
|
|
156
|
+
errOut(`ERROR repo-maintain: ${message}`)
|
|
157
|
+
failures.push(subject)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let identityEnv: NodeJS.ProcessEnv = {}
|
|
161
|
+
const childEnv = (): NodeJS.ProcessEnv => ({ ...env, ...identityEnv })
|
|
162
|
+
const git = (repo: string, ...args: string[]): GitResult => {
|
|
163
|
+
const result = spawnSync('git', ['-C', repo, ...args], { encoding: 'utf8', env: childEnv() })
|
|
164
|
+
if (result.error !== undefined) throw result.error
|
|
165
|
+
return { ok: result.status === 0, stdout: result.stdout, stderr: result.stderr ?? '' }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const { location } = options
|
|
169
|
+
if (!isAbsolute(location)) {
|
|
170
|
+
fail(`location must be an absolute path: ${location}`, location)
|
|
171
|
+
return { ok: false, repositories: 0, failures }
|
|
172
|
+
}
|
|
173
|
+
if (isForbiddenAzurePipelinesPath(location)) {
|
|
174
|
+
fail(`${location}: refusing an Azure Pipelines agent path`, location)
|
|
175
|
+
return { ok: false, repositories: 0, failures }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Resolve the Azure DevOps agent identity ONCE, before touching any repo
|
|
179
|
+
// (F6): this job runs with no TTY and no ADL_AGENT_ID_AGENT, and bin/git-ado
|
|
180
|
+
// fails closed without one. A real resolution error is fatal; "nothing
|
|
181
|
+
// configured" is only fatal for Azure DevOps remotes, which are skipped
|
|
182
|
+
// loudly below rather than fetched with no identity.
|
|
183
|
+
let agent: string | undefined
|
|
184
|
+
try {
|
|
185
|
+
agent = resolveAgentIdentity(env).agent
|
|
186
|
+
} catch (error) {
|
|
187
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
188
|
+
if (message !== NO_AGENT_IDENTITY_MESSAGE) {
|
|
189
|
+
fail(`cannot resolve an Azure DevOps agent identity: ${message}`, 'identity')
|
|
190
|
+
return { ok: false, repositories: 0, failures }
|
|
191
|
+
}
|
|
192
|
+
warn(
|
|
193
|
+
'no Azure DevOps agent identity resolved (no ADL_AGENT_ID_AGENT, no default-agent file): ' +
|
|
194
|
+
'Azure DevOps repos will be skipped this run, not silently fetched with no identity',
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
if (agent !== undefined) identityEnv = { ADL_AGENT_ID_AGENT: agent }
|
|
198
|
+
|
|
199
|
+
info(`start location=${location} keep=${keep} dry=${dry ? 1 : 0}`)
|
|
200
|
+
|
|
201
|
+
let entries: Dirent[]
|
|
202
|
+
let locationReal: string
|
|
203
|
+
try {
|
|
204
|
+
locationReal = realpathSync(location)
|
|
205
|
+
entries = readdirSync(locationReal, { withFileTypes: true })
|
|
206
|
+
} catch (error) {
|
|
207
|
+
for (const line of listFailureLines(location, error, options.executable)) errOut(`ERROR repo-maintain: ${line}`)
|
|
208
|
+
failures.push(location)
|
|
209
|
+
return { ok: false, repositories: 0, failures }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const seen = new Set<string>()
|
|
213
|
+
let repositories = 0
|
|
214
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
215
|
+
// Files and other non-directories (a symlink is the one kind resolved below).
|
|
216
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
|
|
217
|
+
const candidate = join(locationReal, entry.name)
|
|
218
|
+
|
|
219
|
+
// 1. Resolve, then scope-check, BEFORE looking inside. A broken symlink
|
|
220
|
+
// points nowhere and so carries no marker; any other failure to
|
|
221
|
+
// resolve is reported, because it may hide an in-scope repository.
|
|
222
|
+
let real: string
|
|
223
|
+
try {
|
|
224
|
+
real = realpathSync(candidate)
|
|
225
|
+
} catch (error) {
|
|
226
|
+
if (errorCode(error) === 'ENOENT') continue
|
|
227
|
+
fail(`${candidate}: cannot resolve: ${error instanceof Error ? error.message : String(error)}`, candidate)
|
|
228
|
+
continue
|
|
229
|
+
}
|
|
230
|
+
if (dirname(real) !== locationReal) continue
|
|
231
|
+
if (seen.has(real)) continue
|
|
232
|
+
|
|
233
|
+
// 2. The marker decides scope. Not there: not this step's business, and
|
|
234
|
+
// silent. Anything other than "not there" is a named failure.
|
|
235
|
+
try {
|
|
236
|
+
if (!statSync(join(real, MARKER)).isFile()) continue
|
|
237
|
+
} catch (error) {
|
|
238
|
+
const code = errorCode(error)
|
|
239
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') continue
|
|
240
|
+
fail(`${real}: cannot read ${MARKER}: ${error instanceof Error ? error.message : String(error)}`, real)
|
|
241
|
+
continue
|
|
242
|
+
}
|
|
243
|
+
seen.add(real)
|
|
244
|
+
|
|
245
|
+
// 3. In scope from here on.
|
|
246
|
+
let legacy: string[]
|
|
247
|
+
try {
|
|
248
|
+
legacy = legacyMarkers(real)
|
|
249
|
+
} catch (error) {
|
|
250
|
+
fail(`${real}: cannot read: ${error instanceof Error ? error.message : String(error)}`, real)
|
|
251
|
+
continue
|
|
252
|
+
}
|
|
253
|
+
if (legacy.length > 0) {
|
|
254
|
+
fail(`${real}: duplicate markers: ${MARKER} and ${LEGACY_MARKER} (${legacy.join(', ')}); migrate or remove one`, real)
|
|
255
|
+
continue
|
|
256
|
+
}
|
|
257
|
+
if (!isGitToplevel(real)) {
|
|
258
|
+
warn(`${real}: ${MARKER} but not a git top-level; skipped`)
|
|
259
|
+
continue
|
|
260
|
+
}
|
|
261
|
+
repositories += 1
|
|
262
|
+
maintainRepo(real)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
info(`done (${repositories} repositories, ${failures.length} failed)`)
|
|
266
|
+
return { ok: failures.length === 0, repositories, failures }
|
|
267
|
+
|
|
268
|
+
function isGitToplevel(directory: string): boolean {
|
|
269
|
+
const top = spawnSync('git', ['-C', directory, 'rev-parse', '--show-toplevel'], { encoding: 'utf8', env: childEnv() })
|
|
270
|
+
if (top.error !== undefined || top.status !== 0) return false
|
|
271
|
+
try {
|
|
272
|
+
return realpathSync(top.stdout.trim()) === directory
|
|
273
|
+
} catch {
|
|
274
|
+
return false
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function baselineFor(repo: string): string {
|
|
279
|
+
for (const branch of ['integration', 'main', 'master']) {
|
|
280
|
+
if (git(repo, 'show-ref', '--verify', '--quiet', `refs/remotes/origin/${branch}`).ok) return branch
|
|
281
|
+
}
|
|
282
|
+
return ''
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function maintainRepo(repo: string): void {
|
|
286
|
+
const name = basename(repo)
|
|
287
|
+
|
|
288
|
+
// An Azure DevOps remote needs an agent identity; without one the fetch
|
|
289
|
+
// would only fail (or be refused) far from the cause.
|
|
290
|
+
if (agent === undefined) {
|
|
291
|
+
const url = git(repo, 'remote', 'get-url', 'origin')
|
|
292
|
+
if (url.ok && /^https:\/\/[^/]*dev\.azure\.com\//.test(url.stdout.trim())) {
|
|
293
|
+
fail(`${name}: skipping: Azure DevOps remote but no agent identity resolved (see the start-of-run warning)`, repo)
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const head = git(repo, 'symbolic-ref', '--short', '-q', 'HEAD')
|
|
299
|
+
const current = head.ok ? head.stdout.trim() : '(detached)'
|
|
300
|
+
|
|
301
|
+
// 1. fetch + prune. Report what git SAID, never a guess at what it meant.
|
|
302
|
+
const fetched = git(repo, 'fetch', 'origin', '--prune', '--quiet')
|
|
303
|
+
if (!fetched.ok) {
|
|
304
|
+
const reason = fetched.stderr.trim().split('\n').pop() || 'no stderr from git'
|
|
305
|
+
fail(`${name}: fetch failed, skipping repo: ${reason}`, repo)
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// 2. fast-forward integration and main.
|
|
310
|
+
for (const branch of SYNCED_BRANCHES) fastForward(repo, name, branch)
|
|
311
|
+
|
|
312
|
+
// 3. guarded prune.
|
|
313
|
+
prune(repo, name, current)
|
|
314
|
+
|
|
315
|
+
// 4. the per-repo enrolment.
|
|
316
|
+
const argv = [...options.install.leadingArgs, 'install', '--repo', repo]
|
|
317
|
+
const shown = `adl install --repo ${repo}`
|
|
318
|
+
if (dry) {
|
|
319
|
+
info(`${name}: [dry] would run ${shown}`)
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
const result = spawnSync(options.install.command, argv, { encoding: 'utf8', env: childEnv() })
|
|
323
|
+
if (result.error !== undefined) {
|
|
324
|
+
fail(`${name}: could not run ${options.install.command}: ${result.error.message}`, repo)
|
|
325
|
+
} else if (result.status !== 0) {
|
|
326
|
+
fail(`${name}: ${shown} exited ${result.status}: ${(result.stderr ?? '').trim()}`, repo)
|
|
327
|
+
} else {
|
|
328
|
+
info(`${name}: ${shown}: ok`)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function fastForward(repo: string, name: string, branch: string): void {
|
|
333
|
+
const local = `refs/heads/${branch}`
|
|
334
|
+
const remote = `refs/remotes/origin/${branch}`
|
|
335
|
+
if (!git(repo, 'show-ref', '--verify', '--quiet', local).ok) return
|
|
336
|
+
if (!git(repo, 'show-ref', '--verify', '--quiet', remote).ok) return
|
|
337
|
+
const localSha = git(repo, 'rev-parse', local).stdout.trim()
|
|
338
|
+
const remoteSha = git(repo, 'rev-parse', remote).stdout.trim()
|
|
339
|
+
if (localSha === remoteSha) return
|
|
340
|
+
if (!git(repo, 'merge-base', '--is-ancestor', local, remote).ok) {
|
|
341
|
+
// Not a fast-forward (local has commits origin lacks). Never moved.
|
|
342
|
+
if (!git(repo, 'merge-base', '--is-ancestor', remote, local).ok) {
|
|
343
|
+
warn(`${name}: ${branch} has diverged from origin/${branch}; not moved`)
|
|
344
|
+
}
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
// Where is it checked out, if anywhere? (`%(worktreepath)` covers this
|
|
348
|
+
// worktree and every linked one.)
|
|
349
|
+
const worktree = git(repo, 'for-each-ref', local, '--format=%(worktreepath)').stdout.trim()
|
|
350
|
+
if (worktree !== '') {
|
|
351
|
+
if (git(worktree, 'status', '--porcelain').stdout.trim() !== '') {
|
|
352
|
+
warn(`${name}: ${branch} checked out but dirty; skipped fast-forward`)
|
|
353
|
+
} else if (dry) {
|
|
354
|
+
info(`${name}: [dry] would fast-forward ${branch} (checked out)`)
|
|
355
|
+
} else if (git(worktree, 'merge', '--ff-only', `origin/${branch}`, '--quiet').ok) {
|
|
356
|
+
info(`${name}: ${branch} fast-forwarded (checked out)`)
|
|
357
|
+
} else {
|
|
358
|
+
fail(`${name}: ${branch} could not be fast-forwarded (checked out)`, repo)
|
|
359
|
+
}
|
|
360
|
+
return
|
|
361
|
+
}
|
|
362
|
+
if (dry) {
|
|
363
|
+
info(`${name}: [dry] would fast-forward ${branch} (background)`)
|
|
364
|
+
} else if (git(repo, 'update-ref', '-m', `adl maintain: fast-forward ${branch}`, local, remoteSha, localSha).ok) {
|
|
365
|
+
info(`${name}: ${branch} ref fast-forwarded (background)`)
|
|
366
|
+
} else {
|
|
367
|
+
fail(`${name}: ${branch} ref could not be fast-forwarded (background)`, repo)
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function prune(repo: string, name: string, current: string): void {
|
|
372
|
+
const base = baselineFor(repo)
|
|
373
|
+
const keptRecent = new Set(
|
|
374
|
+
lines(git(repo, 'for-each-ref', '--sort=-committerdate', 'refs/heads/', '--format=%(refname:short)').stdout)
|
|
375
|
+
.filter((branch) => !PROTECTED.has(branch) && branch !== current)
|
|
376
|
+
.slice(0, keep),
|
|
377
|
+
)
|
|
378
|
+
const merged =
|
|
379
|
+
base === ''
|
|
380
|
+
? new Set<string>()
|
|
381
|
+
: new Set(lines(git(repo, 'branch', '--merged', `origin/${base}`, '--format=%(refname:short)').stdout))
|
|
382
|
+
|
|
383
|
+
for (const line of lines(git(repo, 'for-each-ref', 'refs/heads/', '--format=%(refname:short)|%(upstream:track)').stdout)) {
|
|
384
|
+
const separator = line.indexOf('|')
|
|
385
|
+
const branch = line.slice(0, separator)
|
|
386
|
+
const track = line.slice(separator + 1)
|
|
387
|
+
if (PROTECTED.has(branch) || branch === current || keptRecent.has(branch)) continue
|
|
388
|
+
|
|
389
|
+
if (track.includes('[gone]')) {
|
|
390
|
+
// remote branch deleted after merge -> proven safe
|
|
391
|
+
if (dry) info(`${name}: [dry] would delete (upstream gone) ${branch}`)
|
|
392
|
+
else if (git(repo, 'branch', '-D', branch).ok) info(`${name}: deleted (upstream gone) ${branch}`)
|
|
393
|
+
else warn(`${name}: could not delete (upstream gone) ${branch}`)
|
|
394
|
+
} else if (base !== '' && merged.has(branch)) {
|
|
395
|
+
if (dry) info(`${name}: [dry] would delete (merged into ${base}) ${branch}`)
|
|
396
|
+
else if (git(repo, 'branch', '-d', branch).ok) info(`${name}: deleted (merged into ${base}) ${branch}`)
|
|
397
|
+
else info(`${name}: kept (${branch}: -d refused)`)
|
|
398
|
+
} else {
|
|
399
|
+
info(`${name}: kept (unconfirmed merged) ${branch}`)
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* `git worktree prune` is not a substitute for any of this. It only clears
|
|
21
21
|
* metadata for worktrees whose directory has ALREADY been deleted.
|
|
22
22
|
*
|
|
23
|
-
* Invoked by reconcile's
|
|
23
|
+
* Invoked by reconcile's 12-hourly pass (bin/reconcile), gated on
|
|
24
24
|
* config.scratch_reap.enabled and the per-device scratch_repos list. Devices
|
|
25
25
|
* that declare no scratch_repos are never touched — a developer machine's
|
|
26
26
|
* scratch worktrees are working state, not fleet state.
|
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
"scripts": {
|
|
15
15
|
"typecheck": "tsc --noEmit",
|
|
16
16
|
"test": "bun test tests --coverage --timeout 30000",
|
|
17
|
-
"test:coverage": "bun test tests --coverage --timeout 30000",
|
|
18
17
|
"check": "tsc --noEmit && bun test tests --coverage --timeout 30000",
|
|
19
18
|
"coverage:ratchet": "bun workflows/scripts/coverage/ci-ratchet.ts",
|
|
20
19
|
"coverage:produce": "bun test tests --coverage --timeout 30000 && bun workflows/scripts/coverage/command.ts"
|