@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
|
@@ -1,848 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* git-maintain — keep local repos fresh and tidy.
|
|
3
|
-
*
|
|
4
|
-
* For each repo under $GIT_MAINTAIN_ROOT whose directory name matches
|
|
5
|
-
* $GIT_MAINTAIN_GLOB (and Git recognizes it as a worktree):
|
|
6
|
-
* 1. fetch origin --prune (drop stale remote-tracking refs)
|
|
7
|
-
* 2. fast-forward the local baseline branch (integration, else main/master)
|
|
8
|
-
* WITHOUT disturbing the working tree or current checkout; a checked-out
|
|
9
|
-
* baseline is fast-forwarded only when clean (`merge --ff-only`)
|
|
10
|
-
* 3. prune old local branches, keeping:
|
|
11
|
-
* - protected: integration / main / master / develop
|
|
12
|
-
* - current HEAD (never deleted)
|
|
13
|
-
* - the $KEEP most-recently-committed non-protected branches
|
|
14
|
-
* Deletion is SAFE-ONLY:
|
|
15
|
-
* - upstream "[gone]" (remote branch deleted after merge) -> git branch -D
|
|
16
|
-
* - true ancestor of baseline (--merged) -> git branch -d
|
|
17
|
-
* - anything else -> kept + logged
|
|
18
|
-
* It NEVER force-deletes a branch that cannot be confirmed merged.
|
|
19
|
-
*
|
|
20
|
-
* Env:
|
|
21
|
-
* GIT_MAINTAIN_ROOT (default: $HOME/Documents/Repos)
|
|
22
|
-
* GIT_MAINTAIN_GLOB (default: adl-*)
|
|
23
|
-
* GIT_MAINTAIN_KEEP (default: 3)
|
|
24
|
-
* GIT_MAINTAIN_DRY (1 = report only: fetches, but never moves a local
|
|
25
|
-
* ref, fast-forwards a checkout, or deletes a branch)
|
|
26
|
-
*
|
|
27
|
-
* CLI:
|
|
28
|
-
* --repo <path> (repeatable) an explicit, closed list of repos, as
|
|
29
|
-
* an alternative to the GIT_MAINTAIN_ROOT/GLOB
|
|
30
|
-
* recursive scan above -- see `parseArgv`'s own
|
|
31
|
-
* header comment for why this exists (the fleet's
|
|
32
|
-
* Linux VPS checkouts live at a fixed, root-owned
|
|
33
|
-
* path with no home-relative root to scan). When any
|
|
34
|
-
* `--repo` is given, the scan is skipped entirely.
|
|
35
|
-
* --arm-only used together with --repo. Runs ONLY `armHooks` for
|
|
36
|
-
* the declared repos -- no fetch, no fast-forward,
|
|
37
|
-
* no branch prune, no bundle-refresh installer, no
|
|
38
|
-
* network at all.
|
|
39
|
-
*
|
|
40
|
-
* ARM-ONLY, why it exists (coordinator decision, 2026-09-30): the Linux
|
|
41
|
-
* VPSes' `git_maintain_repos` names their OWN fleet checkout
|
|
42
|
-
* (`/opt/adl-azdo`), and running the FULL job there -- fetch, fast-forward,
|
|
43
|
-
* prune, bundle-refresh -- turned out to be new, unapproved, and risky
|
|
44
|
-
* behaviour once actually reasoned through, not merely a self-checkout
|
|
45
|
-
* ordering hazard (see `main`'s own placement comment in bin/reconcile for
|
|
46
|
-
* that separate, narrower ordering concern):
|
|
47
|
-
* - the fast-forward step (2 above) would silently auto-deploy whatever
|
|
48
|
-
* `origin/integration` (or main) has advanced to, as root, onto a
|
|
49
|
-
* production checkout -- `bin/sync-and-reconcile` never pulls in
|
|
50
|
-
* checkout source mode today, so this would have been genuinely NEW
|
|
51
|
-
* auto-deploy behaviour, not a restoration of an existing one;
|
|
52
|
-
* - `fleet-cutover.sh` re-reads `fleet-retire-list.json` from that same
|
|
53
|
-
* checkout AFTER reconcile runs (fleet-cutover.sh:1129-1135, :254) --
|
|
54
|
-
* mutating the checkout mid-cutover is exactly the class of hazard this
|
|
55
|
-
* job's own "run last" placement in bin/reconcile was trying to avoid,
|
|
56
|
-
* generalized to a tool this job doesn't control;
|
|
57
|
-
* - branches get deleted (`git branch -D`) as root on a checkout other
|
|
58
|
-
* tooling depends on being stable;
|
|
59
|
-
* - the fetch step still runs in dry/plan/audit mode (see GIT_MAINTAIN_DRY
|
|
60
|
-
* above) even though nothing else does, and its own failure path exits
|
|
61
|
-
* 0, not 1 -- a network hiccup would be silently absorbed rather than
|
|
62
|
-
* reported, for a step this use case has no reason to run at all.
|
|
63
|
-
* The actual goal was only ever arming `core.hooksPath` -- the ONE thing
|
|
64
|
-
* `.git/config` never getting the repo's own committed value is impossible
|
|
65
|
-
* to route around any other way (unlike the fetch/prune/refresh above, all
|
|
66
|
-
* of which land safely through this job's macOS LaunchAgent path or
|
|
67
|
-
* through the ordinary bundle-refresh `refreshRepo` already runs
|
|
68
|
-
* elsewhere). `--arm-only` narrows the Linux invocation to exactly that.
|
|
69
|
-
*
|
|
70
|
-
* Logs go to stdout; on macOS the LaunchAgent (packages/fleet/manifest.json
|
|
71
|
-
* mac.common.launch_agents.git-maintain) captures stdout+stderr in
|
|
72
|
-
* ~/Library/Logs/adl/git-maintain.log. On Linux there is no LaunchAgent
|
|
73
|
-
* equivalent -- see `parseArgv` -- `bin/reconcile` instead runs this file
|
|
74
|
-
* directly (via `bun`, not a compiled binary) on its own schedule, in
|
|
75
|
-
* `--arm-only` mode, whenever a device declares `git_maintain_repos` in the
|
|
76
|
-
* manifest.
|
|
77
|
-
*
|
|
78
|
-
* Reconcile ALSO compiles this file into the dedicated, ad-hoc-signed binary
|
|
79
|
-
* /usr/local/libexec/adl/git-maintain for the macOS LaunchAgent path (see
|
|
80
|
-
* packages/fleet/src/launch-agents.ts). Under launchd that binary is the
|
|
81
|
-
* responsible process for macOS privacy protection (TCC), so the owner grants
|
|
82
|
-
* Full Disk Access to exactly that binary; the `git` processes it spawns are
|
|
83
|
-
* attributed to it. A denied listing is logged as `TCC denied` with the exact
|
|
84
|
-
* remedy, and reconcile surfaces that last log line. None of that TCC
|
|
85
|
-
* machinery applies to the Linux `--repo` path above, which runs as `root`.
|
|
86
|
-
*/
|
|
87
|
-
|
|
88
|
-
import { spawnSync } from 'node:child_process'
|
|
89
|
-
import { lstatSync, readFileSync, readdirSync, realpathSync, statSync, type Dirent } from 'node:fs'
|
|
90
|
-
import { homedir } from 'node:os'
|
|
91
|
-
import { basename, isAbsolute, join, relative, resolve } from 'node:path'
|
|
92
|
-
// F6 (independent review, 2026-09-29): this job's `git fetch` runs from a
|
|
93
|
-
// LaunchAgent with no TTY and no ADL_AGENT_ID_AGENT, so once bin/git-ado's
|
|
94
|
-
// fail-closed rule (STATUS.md 12p decision 1) is deployed, every Azure
|
|
95
|
-
// DevOps fetch was refused. The one shared resolver every other Azure
|
|
96
|
-
// DevOps surface in this repo uses, never re-implemented here.
|
|
97
|
-
import { NO_AGENT_IDENTITY_MESSAGE, resolveAgentIdentity } from '../../../../azdo/src/lib/agent-identity.ts'
|
|
98
|
-
|
|
99
|
-
export const TCC_DENIED_MARKER = 'TCC denied'
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Matches packages/fleet/src/launch-agents.ts's DEFAULT_LIBEXEC_DIR -- a
|
|
103
|
-
* literal duplicate rather than an import, deliberately: launch-agents.ts
|
|
104
|
-
* already imports TCC_DENIED_MARKER FROM this file, so importing the
|
|
105
|
-
* constant back from there would make the two files circular.
|
|
106
|
-
*/
|
|
107
|
-
const LIBEXEC_DIR = '/usr/local/libexec/adl'
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* The file whose presence marks a repo adl hook-managed. It carries several
|
|
111
|
-
* owners' tables (`version`/`hooks`/`rules`/`repo` = adl hook, `extensions` =
|
|
112
|
-
* agent-skills, `controls` = repo-controls) plus hand-written
|
|
113
|
-
* blocks, so an installer that rewrites it wholesale silently deletes another
|
|
114
|
-
* owner's section.
|
|
115
|
-
*
|
|
116
|
-
* The invariant is ADDITIVE-ONLY, not byte-identical. Measured 2026-09-15
|
|
117
|
-
* against the real binaries (then named `adl-repo-controls install --repo X
|
|
118
|
-
* --force`; see the adl-m3-finish item-1/2/3 rename below): the run
|
|
119
|
-
* appends `[controls]`, `[controls.compliance]` and
|
|
120
|
-
* `[controls.compliance.trees]` while leaving a pre-existing `[extensions]` table
|
|
121
|
-
* and comments intact. Writing its own table IS an installer's job, so a
|
|
122
|
-
* byte-identical assertion would fail on every legitimate run. What must never
|
|
123
|
-
* happen is LOSING a table or comment that was there before — that is the
|
|
124
|
-
* shredder case, and it is what this job refuses to continue past.
|
|
125
|
-
*/
|
|
126
|
-
export const MARKER = 'adl-config.toml'
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Installers that own repo-local surfaces, with the argv each actually takes.
|
|
130
|
-
*
|
|
131
|
-
* adl-m3-finish items 1-3 (2026-09-27): neither installer is a standalone
|
|
132
|
-
* global command any more. `adl repo controls` folds repo-controls into the
|
|
133
|
-
* one `adl` binary (docs/architecture/cli-design.md §6, STATUS.md item 10) -- `adl` is
|
|
134
|
-
* itself fleet-installed by the `bun-compile` manager, so it is guaranteed
|
|
135
|
-
* on PATH by the time this job runs. agent-skills has no `adl` fold-in yet
|
|
136
|
-
* (a separate, not-yet-done item), so it is invoked directly from its
|
|
137
|
-
* `repo-dir`-deployed copy (packages/fleet/manifest.json's "agent-skills"
|
|
138
|
-
* tool, `bin/reconcile`'s `write_one_repo_dir`) via `bun`, the same way
|
|
139
|
-
* every other bundled `.mjs`/`.ts` entry point in this job already is.
|
|
140
|
-
*
|
|
141
|
-
* The bundle NAME must come before `--repo`
|
|
142
|
-
* (`install <name> --repo <path> --host claude,codex,cursor,kimi`); called without it, it exits 1 with
|
|
143
|
-
* "install --repo requires a plugin name". `adl-repo` is the baseline bundle
|
|
144
|
-
* its own `list --json` advertises. OpenCode is deliberately absent: its hook
|
|
145
|
-
* carrier is a VPS-owned global artifact, not a direct repo-local host.
|
|
146
|
-
*
|
|
147
|
-
* Each installer's `repoArgs` carries only the flags that installer's own
|
|
148
|
-
* `parse()` actually accepts (checked against the real CLIs, not stubs):
|
|
149
|
-
* agent-skills' `adl-extensions.mjs install` accepts `--force` (a no-op kept
|
|
150
|
-
* for compatibility — its own lock-owned content is always repaired on a
|
|
151
|
-
* plain install); repo-controls' `install` (packages/repo-controls/src/
|
|
152
|
-
* cli.ts) recognizes only `--dry-run` and exits 1 on an unknown flag, so it
|
|
153
|
-
* gets none. `--force` used to be appended to every installer's argv
|
|
154
|
-
* unconditionally, which made every real (non-stubbed) run of the
|
|
155
|
-
* repo-controls installer fail with "unknown option for install: --force" --
|
|
156
|
-
* install is already idempotent there, so nothing is lost by not forcing it.
|
|
157
|
-
*/
|
|
158
|
-
export const INSTALLERS: readonly {
|
|
159
|
-
readonly bin: string
|
|
160
|
-
readonly args: readonly string[]
|
|
161
|
-
readonly repoArgs?: readonly string[]
|
|
162
|
-
}[] = [
|
|
163
|
-
{
|
|
164
|
-
bin: 'bun',
|
|
165
|
-
args: [join(LIBEXEC_DIR, 'agent-skills', 'scripts', 'adl-extensions.mjs'), 'install', 'adl-repo'],
|
|
166
|
-
repoArgs: ['--host', 'claude,codex,cursor,kimi', '--force'],
|
|
167
|
-
},
|
|
168
|
-
{ bin: 'adl', args: ['repo', 'controls', 'install'] },
|
|
169
|
-
]
|
|
170
|
-
|
|
171
|
-
/** Table headers and comment lines — the structure a refresh must never remove. */
|
|
172
|
-
export function markerEntries(text: string): string[] {
|
|
173
|
-
return text
|
|
174
|
-
.split('\n')
|
|
175
|
-
.map((line) => line.trim())
|
|
176
|
-
.filter((line) => line.startsWith('[') || line.startsWith('#'))
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const PROTECTED = new Set(['integration', 'main', 'master', 'develop'])
|
|
180
|
-
|
|
181
|
-
function timestamp(date = new Date()): string {
|
|
182
|
-
const pad = (value: number) => String(value).padStart(2, '0')
|
|
183
|
-
const offset = -date.getTimezoneOffset()
|
|
184
|
-
const sign = offset >= 0 ? '+' : '-'
|
|
185
|
-
const abs = Math.abs(offset)
|
|
186
|
-
return (
|
|
187
|
-
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
|
188
|
-
`T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` +
|
|
189
|
-
`${sign}${pad(Math.floor(abs / 60))}${pad(abs % 60)}`
|
|
190
|
-
)
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function log(message: string): void {
|
|
194
|
-
process.stdout.write(`[${timestamp()}] ${message}\n`)
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Lines explaining why ROOT could not be listed. EPERM from readdir is what
|
|
199
|
-
* macOS privacy protection returns (plain permission bits give EACCES), so it
|
|
200
|
-
* names the exact binary the owner must grant Full Disk Access.
|
|
201
|
-
*
|
|
202
|
-
* ENOTDIR is a misconfiguration -- the path is the right name but the wrong
|
|
203
|
-
* kind (e.g. a file where a directory was expected). The message is the
|
|
204
|
-
* scanner's own ("not a directory"), not a readdir-failure wrapper.
|
|
205
|
-
*/
|
|
206
|
-
export function listFailureLines(root: string, error: unknown, executable: string): readonly string[] {
|
|
207
|
-
const message = error instanceof Error ? error.message : String(error)
|
|
208
|
-
const code = (error as { code?: unknown } | null)?.code
|
|
209
|
-
if (code === 'ENOTDIR') return [`${root}: ${message}`]
|
|
210
|
-
const lines = [`${root}: cannot list: ${message}`]
|
|
211
|
-
if (code === 'EPERM') {
|
|
212
|
-
lines.push(
|
|
213
|
-
`${root}: ${TCC_DENIED_MARKER}: macOS privacy protection blocked ${executable}; ` +
|
|
214
|
-
`one-time fix: System Settings › Privacy & Security › Full Disk Access › + › add ${executable}`,
|
|
215
|
-
)
|
|
216
|
-
}
|
|
217
|
-
return lines
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
export interface RepositoryScope {
|
|
221
|
-
readonly root: string
|
|
222
|
-
readonly glob: string
|
|
223
|
-
readonly recursive: true
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
const INSTALLED_MANIFEST = '/var/lib/adl/applied-manifest.json'
|
|
227
|
-
|
|
228
|
-
/** Read the installed LaunchAgent scope; its root is always relative to the user's home. */
|
|
229
|
-
export function readRepositoryScope(manifestPath: string, home: string, platform: 'mac' | 'linux'): RepositoryScope {
|
|
230
|
-
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as Record<string, unknown>
|
|
231
|
-
const section = manifest[platform] as Record<string, unknown> | undefined
|
|
232
|
-
const common = section?.common as Record<string, unknown> | undefined
|
|
233
|
-
const agents = common?.launch_agents as Record<string, unknown> | undefined
|
|
234
|
-
const job = agents?.['git-maintain'] as Record<string, unknown> | undefined
|
|
235
|
-
const scope = job?.repo_scope as Record<string, unknown> | undefined
|
|
236
|
-
if (
|
|
237
|
-
typeof scope?.root !== 'string' ||
|
|
238
|
-
scope.root.length === 0 ||
|
|
239
|
-
isAbsolute(scope.root) ||
|
|
240
|
-
scope.root.split(/[\\/]/).includes('..') ||
|
|
241
|
-
typeof scope.glob !== 'string' ||
|
|
242
|
-
scope.glob.length === 0 ||
|
|
243
|
-
scope.recursive !== true
|
|
244
|
-
) {
|
|
245
|
-
throw new Error(`${manifestPath}: mac.common.launch_agents.git-maintain.repo_scope must declare a home-relative root, glob, and recursive: true`)
|
|
246
|
-
}
|
|
247
|
-
const root = resolve(home, scope.root)
|
|
248
|
-
const fromHome = relative(resolve(home), root)
|
|
249
|
-
if (fromHome === '..' || fromHome.startsWith(`..${process.platform === 'win32' ? '\\\\' : '/'}`) || isAbsolute(fromHome)) {
|
|
250
|
-
throw new Error(`${manifestPath}: git-maintain repo_scope.root escapes the user's home`)
|
|
251
|
-
}
|
|
252
|
-
return { root, glob: scope.glob, recursive: true }
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* The fleet root is `~/Documents`; managed checkouts also live one level down
|
|
257
|
-
* in `~/Documents/Repos`. Both are in scope. A non-directory entry with this
|
|
258
|
-
* name at the top of the root is a misconfiguration -- silent skip would mask
|
|
259
|
-
* it (status 0, no maintenance performed).
|
|
260
|
-
*/
|
|
261
|
-
const TOP_LEVEL_COLLECTION = 'Repos'
|
|
262
|
-
|
|
263
|
-
/** Recursively locate real Git roots, including worktrees, without following symlinks. */
|
|
264
|
-
export function discoverGitRepositories(
|
|
265
|
-
root: string,
|
|
266
|
-
scope: Pick<RepositoryScope, 'glob' | 'recursive'>,
|
|
267
|
-
onError: (path: string, error: unknown) => void = () => {},
|
|
268
|
-
): string[] {
|
|
269
|
-
const matcher = globToRegExp(scope.glob)
|
|
270
|
-
const repositories: string[] = []
|
|
271
|
-
const visit = (directory: string): void => {
|
|
272
|
-
let entries: Dirent[]
|
|
273
|
-
try {
|
|
274
|
-
entries = readdirSync(directory, { withFileTypes: true })
|
|
275
|
-
} catch (error) {
|
|
276
|
-
onError(directory, error)
|
|
277
|
-
return
|
|
278
|
-
}
|
|
279
|
-
if (directory === root) {
|
|
280
|
-
const collection = entries.find((entry) => entry.name === TOP_LEVEL_COLLECTION)
|
|
281
|
-
if (collection !== undefined && !collection.isDirectory()) {
|
|
282
|
-
onError(
|
|
283
|
-
join(directory, TOP_LEVEL_COLLECTION),
|
|
284
|
-
Object.assign(new Error('not a directory'), { code: 'ENOTDIR' }),
|
|
285
|
-
)
|
|
286
|
-
return
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
try {
|
|
290
|
-
lstatSync(join(directory, '.git'))
|
|
291
|
-
const topLevel = spawnSync('git', ['-C', directory, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' })
|
|
292
|
-
if (topLevel.error !== undefined) throw topLevel.error
|
|
293
|
-
if (topLevel.status === 0 && realpathSync(topLevel.stdout.trim()) === realpathSync(directory) && matcher.test(basename(directory))) {
|
|
294
|
-
repositories.push(directory)
|
|
295
|
-
}
|
|
296
|
-
} catch (error) {
|
|
297
|
-
if ((error as { code?: unknown } | null)?.code !== 'ENOENT') onError(join(directory, '.git'), error)
|
|
298
|
-
}
|
|
299
|
-
if (scope.recursive) {
|
|
300
|
-
for (const entry of entries) {
|
|
301
|
-
if (entry.isDirectory() && entry.name !== '.git') visit(join(directory, entry.name))
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
visit(root)
|
|
306
|
-
return repositories.sort()
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
interface GitResult {
|
|
310
|
-
readonly ok: boolean
|
|
311
|
-
readonly stdout: string
|
|
312
|
-
/**
|
|
313
|
-
* Captured so a failure can report what git SAID.
|
|
314
|
-
*
|
|
315
|
-
* This was discarded, which is why the fetch failure below could only guess
|
|
316
|
-
* at a cause -- the information never reached the call site. `.donor` then
|
|
317
|
-
* failed every run for hours under a logged `(offline?)` that nobody had
|
|
318
|
-
* checked, while the real message on an https Azure DevOps remote is
|
|
319
|
-
* `could not read Username`: an authentication problem, not a network one.
|
|
320
|
-
*/
|
|
321
|
-
readonly stderr: string
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
/**
|
|
325
|
-
* F6: the resolved agent identity's env overlay for every `git` child this
|
|
326
|
-
* job spawns, set once in `main()` before the repo loop and merged into
|
|
327
|
-
* EVERY call (not only `fetch`) -- harmless for local-only commands, and
|
|
328
|
-
* simpler than threading it through every one of this file's ~30 call
|
|
329
|
-
* sites. Empty until `main()` resolves an identity; a repo with no
|
|
330
|
-
* Azure DevOps remote is unaffected either way.
|
|
331
|
-
*/
|
|
332
|
-
let adoIdentityEnv: NodeJS.ProcessEnv = {}
|
|
333
|
-
|
|
334
|
-
function git(repo: string, ...args: string[]): GitResult {
|
|
335
|
-
const result = spawnSync('git', ['-C', repo, ...args], { encoding: 'utf8', env: { ...process.env, ...adoIdentityEnv } })
|
|
336
|
-
if (result.error !== undefined) throw result.error
|
|
337
|
-
return { ok: result.status === 0, stdout: result.stdout, stderr: result.stderr ?? '' }
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
/** Matches bin/git's own Azure DevOps HTTPS remote check (F6/F2). */
|
|
341
|
-
const ADO_REMOTE_PATTERN = /^https:\/\/[^/]*dev\.azure\.com\//
|
|
342
|
-
|
|
343
|
-
/** Cheap, local-only (no network): reads the configured `origin` URL. */
|
|
344
|
-
function originIsAzureDevOps(repo: string): boolean {
|
|
345
|
-
const url = git(repo, 'remote', 'get-url', 'origin')
|
|
346
|
-
return url.ok && ADO_REMOTE_PATTERN.test(url.stdout.trim())
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
function lines(output: string): string[] {
|
|
350
|
-
return output.split('\n').filter((line) => line.length > 0)
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
/** find -name semantics for the simple glob characters `*`, `?` and `[...]`. */
|
|
354
|
-
function globToRegExp(glob: string): RegExp {
|
|
355
|
-
let pattern = ''
|
|
356
|
-
for (let index = 0; index < glob.length; index++) {
|
|
357
|
-
const char = glob[index]!
|
|
358
|
-
if (char === '*') pattern += '.*'
|
|
359
|
-
else if (char === '?') pattern += '.'
|
|
360
|
-
else if (char === '[') {
|
|
361
|
-
const close = glob.indexOf(']', index + 1)
|
|
362
|
-
if (close === -1) pattern += '\\['
|
|
363
|
-
else {
|
|
364
|
-
const body = glob.slice(index + 1, close).replace(/^!/, '^').replaceAll('\\', '\\\\')
|
|
365
|
-
pattern += `[${body}]`
|
|
366
|
-
index = close
|
|
367
|
-
}
|
|
368
|
-
} else pattern += char.replace(/[.+^${}()|\\/\]]/g, '\\$&')
|
|
369
|
-
}
|
|
370
|
-
return new RegExp(`^${pattern}$`, 's')
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
function isDirectory(path: string): boolean {
|
|
374
|
-
try {
|
|
375
|
-
return statSync(path).isDirectory()
|
|
376
|
-
} catch {
|
|
377
|
-
return false
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
function baselineFor(repo: string): string {
|
|
382
|
-
for (const branch of ['integration', 'main', 'master']) {
|
|
383
|
-
if (git(repo, 'show-ref', '--verify', '--quiet', `refs/remotes/origin/${branch}`).ok) return branch
|
|
384
|
-
}
|
|
385
|
-
return ''
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
function maintainRepo(repo: string, keep: number, dry: boolean): void {
|
|
389
|
-
const name = basename(repo)
|
|
390
|
-
if (!git(repo, 'rev-parse', '--git-dir').ok) return
|
|
391
|
-
|
|
392
|
-
const head = git(repo, 'symbolic-ref', '--short', '-q', 'HEAD')
|
|
393
|
-
const current = head.ok ? head.stdout.trim() : '(detached)'
|
|
394
|
-
|
|
395
|
-
// 1. fetch + prune
|
|
396
|
-
//
|
|
397
|
-
// Report what git SAID, never a guess at what it meant. This logged
|
|
398
|
-
// `fetch failed (offline?)` and discarded the stderr, so `.donor` failed
|
|
399
|
-
// every run for hours while the log asserted a cause nobody had checked --
|
|
400
|
-
// and on an https Azure DevOps remote the real message is
|
|
401
|
-
// `could not read Username`, which is an authentication problem and not a
|
|
402
|
-
// network one. A wrong cause in a log is worse than no cause: it stops the
|
|
403
|
-
// next person looking.
|
|
404
|
-
const fetched = git(repo, 'fetch', 'origin', '--prune', '--quiet')
|
|
405
|
-
if (!fetched.ok) {
|
|
406
|
-
const reason = fetched.stderr.trim().split('\n').pop() ?? 'no stderr from git'
|
|
407
|
-
log(`${name}: fetch failed, skipping repo: ${reason}`)
|
|
408
|
-
return
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
const base = baselineFor(repo)
|
|
412
|
-
|
|
413
|
-
// 2. fast-forward baseline without touching the working tree
|
|
414
|
-
if (base !== '') {
|
|
415
|
-
const local = `refs/heads/${base}`
|
|
416
|
-
const remote = `refs/remotes/origin/${base}`
|
|
417
|
-
const behind =
|
|
418
|
-
git(repo, 'show-ref', '--verify', '--quiet', local).ok &&
|
|
419
|
-
git(repo, 'rev-parse', local).stdout.trim() !== git(repo, 'rev-parse', remote).stdout.trim() &&
|
|
420
|
-
git(repo, 'merge-base', '--is-ancestor', local, remote).ok
|
|
421
|
-
if (current === base) {
|
|
422
|
-
if (git(repo, 'status', '--porcelain').stdout.trim() !== '') {
|
|
423
|
-
log(`${name}: ${base} checked out but dirty — skip FF`)
|
|
424
|
-
} else if (behind && dry) {
|
|
425
|
-
log(`${name}: [dry] would fast-forward ${base} (checked out)`)
|
|
426
|
-
} else if (behind) {
|
|
427
|
-
if (git(repo, 'merge', '--ff-only', `origin/${base}`, '--quiet').ok) {
|
|
428
|
-
log(`${name}: ${base} fast-forwarded (checked out)`)
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
} else if (behind) {
|
|
432
|
-
// update the local ref directly; FF-safe, no working-tree change
|
|
433
|
-
if (dry) log(`${name}: [dry] would fast-forward ${base} (background)`)
|
|
434
|
-
else if (git(repo, 'fetch', 'origin', `${base}:${base}`, '--quiet').ok) {
|
|
435
|
-
log(`${name}: ${base} ref fast-forwarded (background)`)
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
// 3. guarded prune
|
|
441
|
-
// Keep-set: top-KEEP most-recent non-protected, non-current branches.
|
|
442
|
-
const keptRecent = new Set(
|
|
443
|
-
lines(git(repo, 'for-each-ref', '--sort=-committerdate', 'refs/heads/', '--format=%(refname:short)').stdout)
|
|
444
|
-
.filter((branch) => !PROTECTED.has(branch) && branch !== current)
|
|
445
|
-
.slice(0, keep),
|
|
446
|
-
)
|
|
447
|
-
const merged =
|
|
448
|
-
base === ''
|
|
449
|
-
? new Set<string>()
|
|
450
|
-
: new Set(lines(git(repo, 'branch', '--merged', `origin/${base}`, '--format=%(refname:short)').stdout))
|
|
451
|
-
|
|
452
|
-
for (const line of lines(git(repo, 'for-each-ref', 'refs/heads/', '--format=%(refname:short)|%(upstream:track)').stdout)) {
|
|
453
|
-
const separator = line.indexOf('|')
|
|
454
|
-
const branch = line.slice(0, separator)
|
|
455
|
-
const track = line.slice(separator + 1)
|
|
456
|
-
if (PROTECTED.has(branch) || branch === current || keptRecent.has(branch)) continue
|
|
457
|
-
|
|
458
|
-
if (track.includes('[gone]')) {
|
|
459
|
-
// remote branch deleted after merge -> proven safe
|
|
460
|
-
if (dry) log(`${name}: [dry] would delete (upstream gone) ${branch}`)
|
|
461
|
-
else if (git(repo, 'branch', '-D', branch).ok) log(`${name}: deleted (upstream gone) ${branch}`)
|
|
462
|
-
else log(`${name}: FAILED delete ${branch}`)
|
|
463
|
-
} else if (base !== '' && merged.has(branch)) {
|
|
464
|
-
if (dry) log(`${name}: [dry] would delete (merged into ${base}) ${branch}`)
|
|
465
|
-
else if (git(repo, 'branch', '-d', branch).ok) log(`${name}: deleted (merged into ${base}) ${branch}`)
|
|
466
|
-
else log(`${name}: kept (${branch}: -d refused)`)
|
|
467
|
-
} else {
|
|
468
|
-
log(`${name}: kept (unconfirmed merged) ${branch}`)
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
function isFile(path: string): boolean {
|
|
474
|
-
try {
|
|
475
|
-
return statSync(path).isFile()
|
|
476
|
-
} catch {
|
|
477
|
-
return false
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
/**
|
|
482
|
-
* Re-install the repo-local surfaces into `repo`, if it carries the marker.
|
|
483
|
-
* Returns false if anything went wrong — a missing installer, a failing one,
|
|
484
|
-
* or a disturbed marker. A missing installer is a LOUD failure, never a quiet
|
|
485
|
-
* skip: "marker found, installer absent, nothing happened, no error" is
|
|
486
|
-
* exactly the presence-is-not-armedness shape this refresh exists to remove.
|
|
487
|
-
*
|
|
488
|
-
* Never commits and never stages: it runs the installers and nothing else.
|
|
489
|
-
*/
|
|
490
|
-
function refreshRepo(repo: string, dry: boolean): boolean {
|
|
491
|
-
const name = basename(repo)
|
|
492
|
-
const marker = join(repo, MARKER)
|
|
493
|
-
if (!isFile(marker)) return true
|
|
494
|
-
|
|
495
|
-
const argvFor = (installer: (typeof INSTALLERS)[number]) => [
|
|
496
|
-
...installer.args,
|
|
497
|
-
'--repo',
|
|
498
|
-
repo,
|
|
499
|
-
...(installer.repoArgs ?? []),
|
|
500
|
-
]
|
|
501
|
-
|
|
502
|
-
if (dry) {
|
|
503
|
-
for (const installer of INSTALLERS) {
|
|
504
|
-
log(`${name}: [dry] would refresh via ${installer.bin} ${argvFor(installer).join(' ')}`)
|
|
505
|
-
}
|
|
506
|
-
return true
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
const beforeText = readFileSync(marker, 'utf8')
|
|
510
|
-
const mtimeBefore = statSync(marker).mtimeMs
|
|
511
|
-
const before = markerEntries(beforeText)
|
|
512
|
-
let ok = true
|
|
513
|
-
|
|
514
|
-
for (const installer of INSTALLERS) {
|
|
515
|
-
const argv = argvFor(installer)
|
|
516
|
-
const result = spawnSync(installer.bin, argv, { encoding: 'utf8' })
|
|
517
|
-
const code = (result.error as { code?: unknown } | undefined)?.code
|
|
518
|
-
if (code === 'ENOENT') {
|
|
519
|
-
log(`${name}: ${installer.bin} not found on PATH — refresh incomplete, repo left stale`)
|
|
520
|
-
ok = false
|
|
521
|
-
} else if (result.error !== undefined) {
|
|
522
|
-
log(`${name}: ${installer.bin} failed to run: ${result.error.message}`)
|
|
523
|
-
ok = false
|
|
524
|
-
} else if (result.status !== 0) {
|
|
525
|
-
log(`${name}: ${installer.bin} ${argv.join(' ')} exited ${result.status}: ${result.stderr.trim()}`)
|
|
526
|
-
ok = false
|
|
527
|
-
} else {
|
|
528
|
-
log(`${name}: refreshed via ${installer.bin}`)
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
const afterText = readFileSync(marker, 'utf8')
|
|
533
|
-
const after = markerEntries(afterText)
|
|
534
|
-
const lost = before.filter((entry) => !after.includes(entry))
|
|
535
|
-
|
|
536
|
-
if (lost.length > 0) {
|
|
537
|
-
log(`${name}: ${MARKER} LOST ${lost.join(', ')} at ${repo} — another owner's section was destroyed; refusing to call this a clean refresh`)
|
|
538
|
-
ok = false
|
|
539
|
-
} else if (afterText === beforeText && statSync(marker).mtimeMs === mtimeBefore) {
|
|
540
|
-
// The steady state, and what every run after a repo's first must look like.
|
|
541
|
-
log(`${name}: ${MARKER} untouched`)
|
|
542
|
-
} else {
|
|
543
|
-
// An installer creating its own missing table: legitimate exactly once per
|
|
544
|
-
// repo. Seen on a repo that already had every table, it means an installer
|
|
545
|
-
// stopped being idempotent — so it is logged, loudly, rather than ignored.
|
|
546
|
-
log(`${name}: ${MARKER} gained ${after.filter((entry) => !before.includes(entry)).join(', ') || '(no new table)'} at ${repo} — expected only on a repo's first install`)
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
return ok
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
/**
|
|
553
|
-
* The repository-relative hooks directory repo-controls installs into every
|
|
554
|
-
* enrolled repo (packages/repo-controls/src/constants.ts's own HOOKS_PATH) --
|
|
555
|
-
* a literal duplicate here, deliberately, the same as LIBEXEC_DIR above:
|
|
556
|
-
* this job needs only the one string, and it never changes independent of
|
|
557
|
-
* the layout `adl repo controls install` itself writes.
|
|
558
|
-
*/
|
|
559
|
-
const HOOKS_DIR = '.workflows/hooks'
|
|
560
|
-
|
|
561
|
-
/**
|
|
562
|
-
* Arms `core.hooksPath` for a repo carrying the MARKER, if the repo also
|
|
563
|
-
* carries a committed `.workflows/hooks` directory.
|
|
564
|
-
*
|
|
565
|
-
* `core.hooksPath` lives in `.git/config`, which never travels with a clone
|
|
566
|
-
* -- a repo cloned onto a fresh machine keeps its committed hook stubs but
|
|
567
|
-
* stays unarmed (every commit silently skips every gate) until someone runs
|
|
568
|
-
* `adl repo controls install` there by hand. `refreshRepo` above already
|
|
569
|
-
* runs that installer on this job's own schedule, but only on a repo that
|
|
570
|
-
* ALREADY has the marker's tables in place; this closes the narrower gap of
|
|
571
|
-
* the git-local setting itself, directly, on the same schedule, without
|
|
572
|
-
* depending on that installer being present or succeeding.
|
|
573
|
-
*
|
|
574
|
-
* Scope is exactly the marker AND a committed hooks directory -- never a
|
|
575
|
-
* repo missing either. Correction (coordinator, 2026-09-30): a repo in that
|
|
576
|
-
* scope is ENROLLED, and an enrolled repo has no valid reason for
|
|
577
|
-
* `core.hooksPath` to point anywhere else -- it means the repo has NO gates
|
|
578
|
-
* (every commit silently skips every one of them), not a deliberate owner
|
|
579
|
-
* choice worth preserving. So a wrong value is restored, exactly like
|
|
580
|
-
* `packages/repo-controls/src/install.ts`'s own unconditional restore
|
|
581
|
-
* (which `refreshRepo` above already runs as part of this job's bundle
|
|
582
|
-
* refresh) -- this aligns with that standard instead of deviating from it.
|
|
583
|
-
*
|
|
584
|
-
* Returns false only when arming was attempted and the write itself failed
|
|
585
|
-
* -- a repo skipped by scope or already armed returns true; none of those
|
|
586
|
-
* is a fleet failure.
|
|
587
|
-
*/
|
|
588
|
-
function armHooks(repo: string, dry: boolean): boolean {
|
|
589
|
-
const name = basename(repo)
|
|
590
|
-
if (!isFile(join(repo, MARKER))) return true
|
|
591
|
-
if (!isDirectory(join(repo, HOOKS_DIR))) return true
|
|
592
|
-
|
|
593
|
-
const current = git(repo, 'config', '--local', '--get', 'core.hooksPath')
|
|
594
|
-
const value = current.ok ? current.stdout.trim() : ''
|
|
595
|
-
|
|
596
|
-
if (value === HOOKS_DIR) {
|
|
597
|
-
log(`${name}: hooksPath already armed (${HOOKS_DIR})`)
|
|
598
|
-
return true
|
|
599
|
-
}
|
|
600
|
-
const wasUnset = value === ''
|
|
601
|
-
const suffix = wasUnset ? '' : ` (was ${value})`
|
|
602
|
-
if (dry) {
|
|
603
|
-
log(`${name}: [dry] would ${wasUnset ? 'arm' : 're-arm'} core.hooksPath=${HOOKS_DIR}${suffix}`)
|
|
604
|
-
return true
|
|
605
|
-
}
|
|
606
|
-
if (git(repo, 'config', '--local', 'core.hooksPath', HOOKS_DIR).ok) {
|
|
607
|
-
log(`${name}: ${wasUnset ? 'armed' : 're-armed'} core.hooksPath=${HOOKS_DIR}${suffix}`)
|
|
608
|
-
return true
|
|
609
|
-
}
|
|
610
|
-
log(`${name}: FAILED to arm core.hooksPath`)
|
|
611
|
-
return false
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
export interface ParsedArgv {
|
|
615
|
-
readonly repos: string[]
|
|
616
|
-
readonly armOnly: boolean
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
/**
|
|
620
|
-
* Strict argv parser. Recognizes exactly two tokens:
|
|
621
|
-
*
|
|
622
|
-
* --repo <path> (repeatable) -- an explicit, closed list of
|
|
623
|
-
* repositories, as an alternative to the recursive
|
|
624
|
-
* root+glob scan `discoverGitRepositories` performs. This
|
|
625
|
-
* is how `bin/reconcile` reaches the fleet's own Linux VPS
|
|
626
|
-
* checkouts (see `main`'s header comment for why): those
|
|
627
|
-
* live at a fixed path outside any device's home (e.g.
|
|
628
|
-
* `/opt/adl-azdo`, root-owned), so there is no
|
|
629
|
-
* home-relative root to recursively scan in the first
|
|
630
|
-
* place -- and a recursive scan from a shared parent like
|
|
631
|
-
* `/opt` would also walk into unrelated, explicitly
|
|
632
|
-
* off-limits directories (Azure Pipelines agent installs).
|
|
633
|
-
* An explicit path list structurally cannot do that: it
|
|
634
|
-
* only ever touches exactly what it is given.
|
|
635
|
-
*
|
|
636
|
-
* --arm-only REQUIRES at least one --repo (enforced here, not just
|
|
637
|
-
* documented: --arm-only alone is a usage error) -- see
|
|
638
|
-
* `main`'s ARM-ONLY comment for the full decision. Runs
|
|
639
|
-
* ONLY `armHooks` for the declared repos -- no fetch, no
|
|
640
|
-
* fast-forward, no branch prune, no bundle-refresh
|
|
641
|
-
* installer, no network at all.
|
|
642
|
-
*
|
|
643
|
-
* Both are CLI flags, not environment variables: `bin/reconcile` computes
|
|
644
|
-
* this data from the manifest itself and passes it as argv, the same
|
|
645
|
-
* convention already used for every other bun-invoked step in that script
|
|
646
|
-
* (`--platform <p> --mode <m>`, never a bespoke env var for data reconcile
|
|
647
|
-
* already has).
|
|
648
|
-
*
|
|
649
|
-
* ANY other token -- an unrecognized flag, `--repo` with no following
|
|
650
|
-
* value, `--repo=/path` (the `=` form is not supported; write `--repo
|
|
651
|
-
* /path`), or a near-miss like `--rep` -- is a loud, fatal usage error.
|
|
652
|
-
* This must never happen: silently ignoring or misreading an argument here
|
|
653
|
-
* and falling through to the recursive scope-resolution path below would
|
|
654
|
-
* silently WIDEN what this run touches, far past whatever was actually
|
|
655
|
-
* declared.
|
|
656
|
-
*/
|
|
657
|
-
export function parseArgv(argv: readonly string[]): ParsedArgv {
|
|
658
|
-
const repos: string[] = []
|
|
659
|
-
let armOnly = false
|
|
660
|
-
for (let index = 0; index < argv.length; index++) {
|
|
661
|
-
const token = argv[index]!
|
|
662
|
-
if (token === '--repo') {
|
|
663
|
-
const value = argv[index + 1]
|
|
664
|
-
if (value === undefined) throw new Error('--repo requires a path argument')
|
|
665
|
-
repos.push(value)
|
|
666
|
-
index++
|
|
667
|
-
} else if (token === '--arm-only') {
|
|
668
|
-
armOnly = true
|
|
669
|
-
} else {
|
|
670
|
-
throw new Error(`unknown argument: ${token}`)
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
if (armOnly && repos.length === 0) throw new Error('--arm-only requires at least one --repo')
|
|
674
|
-
return { repos, armOnly }
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
/**
|
|
678
|
-
* Path names, or resolves to (via realpath, so a symlink cannot launder
|
|
679
|
-
* it), something naming an Azure Pipelines agent install -- case
|
|
680
|
-
* insensitive, matching bin/validate-manifest's own schema-layer guard on
|
|
681
|
-
* `git_maintain_repos` (independent review, 2026-09-30: that guard alone
|
|
682
|
-
* only ever sees the manifest's literal string; this is the same check
|
|
683
|
-
* enforced again at the point of actual use, so a hand-typed `--repo`
|
|
684
|
-
* argument or a symlinked path bypassing the manifest entirely is refused
|
|
685
|
-
* too). This estate's own agent directories/services are never named here,
|
|
686
|
-
* on purpose: this function exists so this job can refuse them, not so it
|
|
687
|
-
* can reference them as a path it might otherwise touch.
|
|
688
|
-
*/
|
|
689
|
-
const FORBIDDEN_PATH_PATTERN = /azagent|vsts\.agent/i
|
|
690
|
-
|
|
691
|
-
function isForbiddenAzurePipelinesPath(path: string): boolean {
|
|
692
|
-
if (FORBIDDEN_PATH_PATTERN.test(path)) return true
|
|
693
|
-
try {
|
|
694
|
-
return FORBIDDEN_PATH_PATTERN.test(realpathSync(path))
|
|
695
|
-
} catch {
|
|
696
|
-
return false
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
/** Same top-level-repo check `discoverGitRepositories` applies while walking -- standalone, for a caller that already has an exact candidate path and skips the walk entirely. */
|
|
701
|
-
function isGitToplevel(directory: string): boolean {
|
|
702
|
-
try {
|
|
703
|
-
lstatSync(join(directory, '.git'))
|
|
704
|
-
} catch {
|
|
705
|
-
return false
|
|
706
|
-
}
|
|
707
|
-
const topLevel = spawnSync('git', ['-C', directory, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' })
|
|
708
|
-
if (topLevel.error !== undefined || topLevel.status !== 0) return false
|
|
709
|
-
try {
|
|
710
|
-
return realpathSync(topLevel.stdout.trim()) === realpathSync(directory)
|
|
711
|
-
} catch {
|
|
712
|
-
return false
|
|
713
|
-
}
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
export function main(env: NodeJS.ProcessEnv, executable: string, manifestPath = INSTALLED_MANIFEST, argv: readonly string[] = []): number {
|
|
717
|
-
adoIdentityEnv = {}
|
|
718
|
-
let parsedArgv: ParsedArgv
|
|
719
|
-
try {
|
|
720
|
-
parsedArgv = parseArgv(argv)
|
|
721
|
-
} catch (error) {
|
|
722
|
-
// Exit 2 (usage error), never 1 (runtime failure) -- and returned here,
|
|
723
|
-
// before any scope is resolved, so a bad argument can never fall
|
|
724
|
-
// through to the recursive scan below by accident.
|
|
725
|
-
log(`usage error: ${error instanceof Error ? error.message : String(error)}`)
|
|
726
|
-
return 2
|
|
727
|
-
}
|
|
728
|
-
const { repos: explicitRepos, armOnly } = parsedArgv
|
|
729
|
-
let scope: RepositoryScope | undefined
|
|
730
|
-
if (explicitRepos.length === 0) {
|
|
731
|
-
if (env.GIT_MAINTAIN_ROOT !== undefined || env.GIT_MAINTAIN_GLOB !== undefined) {
|
|
732
|
-
// Optional test overrides keep fixtures out of the installed host scope.
|
|
733
|
-
scope = {
|
|
734
|
-
root: env.GIT_MAINTAIN_ROOT ?? join(homedir(), 'Documents'),
|
|
735
|
-
glob: env.GIT_MAINTAIN_GLOB ?? '*',
|
|
736
|
-
recursive: true,
|
|
737
|
-
}
|
|
738
|
-
} else {
|
|
739
|
-
try {
|
|
740
|
-
const platform = process.platform === 'darwin' ? 'mac' : 'linux'
|
|
741
|
-
scope = readRepositoryScope(manifestPath, homedir(), platform)
|
|
742
|
-
} catch (error) {
|
|
743
|
-
log(`cannot read installed git-maintain scope from ${manifestPath}: ${error instanceof Error ? error.message : String(error)}`)
|
|
744
|
-
return 1
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
const keepRaw = env.GIT_MAINTAIN_KEEP || '3'
|
|
749
|
-
const dryRaw = env.GIT_MAINTAIN_DRY || '0'
|
|
750
|
-
const keep = Number(keepRaw)
|
|
751
|
-
if (!Number.isInteger(keep) || keep < 0) {
|
|
752
|
-
log(`GIT_MAINTAIN_KEEP must be a non-negative integer: ${keepRaw}`)
|
|
753
|
-
return 1
|
|
754
|
-
}
|
|
755
|
-
const dry = dryRaw === '1'
|
|
756
|
-
|
|
757
|
-
// F6: resolve the Azure DevOps agent identity ONCE, before touching any
|
|
758
|
-
// repo, the same way every other Azure DevOps surface does
|
|
759
|
-
// (`resolveAgentIdentity`: env, else the machine default-agent file,
|
|
760
|
-
// never a human fallback). A real resolution error (e.g. an invalid name
|
|
761
|
-
// in the default-agent file) is fatal, not a per-repo skip -- it means
|
|
762
|
-
// the machine's own identity configuration is broken. Resolving to
|
|
763
|
-
// "nothing configured" is not fatal by itself: only Azure DevOps-remoted
|
|
764
|
-
// repos need it, and those are skipped loudly below, never silently
|
|
765
|
-
// fetched with no identity (AGENTS.md: "no silent fallback or degrade").
|
|
766
|
-
//
|
|
767
|
-
// ARM-ONLY (coordinator decision, 2026-09-30): skipped entirely in
|
|
768
|
-
// --arm-only mode, which never fetches and so never needs an Azure
|
|
769
|
-
// DevOps identity for anything -- resolving one anyway would make this
|
|
770
|
-
// mode's success depend on machine identity configuration it has no use
|
|
771
|
-
// for at all.
|
|
772
|
-
let agentForAdo: string | undefined
|
|
773
|
-
if (!armOnly) {
|
|
774
|
-
try {
|
|
775
|
-
agentForAdo = resolveAgentIdentity(env).agent
|
|
776
|
-
} catch (error) {
|
|
777
|
-
const message = error instanceof Error ? error.message : String(error)
|
|
778
|
-
if (message !== NO_AGENT_IDENTITY_MESSAGE) {
|
|
779
|
-
log(`cannot resolve an Azure DevOps agent identity: ${message}`)
|
|
780
|
-
return 1
|
|
781
|
-
}
|
|
782
|
-
log(
|
|
783
|
-
'no Azure DevOps agent identity resolved (no ADL_AGENT_ID_AGENT, no default-agent file): ' +
|
|
784
|
-
'Azure DevOps-remoted repos will be skipped this run, not silently fetched with no identity',
|
|
785
|
-
)
|
|
786
|
-
}
|
|
787
|
-
if (agentForAdo !== undefined) adoIdentityEnv = { ADL_AGENT_ID_AGENT: agentForAdo }
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
let discoveryFailed = false
|
|
791
|
-
let candidates: string[]
|
|
792
|
-
if (explicitRepos.length > 0) {
|
|
793
|
-
log(`=== git-maintain start (repos=${explicitRepos.length} declared${armOnly ? ', arm-only' : ''} keep=${keepRaw} dry=${dryRaw}) ===`)
|
|
794
|
-
candidates = []
|
|
795
|
-
for (const repo of explicitRepos.slice().sort()) {
|
|
796
|
-
if (isForbiddenAzurePipelinesPath(repo)) {
|
|
797
|
-
log(`${repo}: refusing an Azure Pipelines agent path (declared via --repo)`)
|
|
798
|
-
discoveryFailed = true
|
|
799
|
-
} else if (isGitToplevel(repo)) {
|
|
800
|
-
candidates.push(repo)
|
|
801
|
-
} else {
|
|
802
|
-
log(`${repo}: not a git repository (declared via --repo)`)
|
|
803
|
-
discoveryFailed = true
|
|
804
|
-
}
|
|
805
|
-
}
|
|
806
|
-
} else {
|
|
807
|
-
const { root, glob } = scope as RepositoryScope
|
|
808
|
-
log(`=== git-maintain start (root=${root} glob=${glob} keep=${keepRaw} dry=${dryRaw}) ===`)
|
|
809
|
-
if (!isDirectory(root)) {
|
|
810
|
-
log(`${root}: missing (not a directory)`)
|
|
811
|
-
return 1
|
|
812
|
-
}
|
|
813
|
-
candidates = discoverGitRepositories(root, scope as RepositoryScope, (path, error) => {
|
|
814
|
-
for (const line of listFailureLines(path, error, executable)) log(line)
|
|
815
|
-
discoveryFailed = true
|
|
816
|
-
})
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
let refreshed = !discoveryFailed
|
|
820
|
-
for (const repo of candidates) {
|
|
821
|
-
const name = basename(repo)
|
|
822
|
-
// Local-only (no fetch, no Azure DevOps identity needed), so it runs for
|
|
823
|
-
// every candidate before the identity gate below -- a repo skipped for
|
|
824
|
-
// having no resolvable ADO identity must not also stay unarmed for a
|
|
825
|
-
// reason that has nothing to do with that.
|
|
826
|
-
if (!armHooks(repo, dry)) refreshed = false
|
|
827
|
-
// ARM-ONLY: nothing below this line ever runs. No fetch, no
|
|
828
|
-
// fast-forward, no branch prune (maintainRepo), no bundle-refresh
|
|
829
|
-
// installer (refreshRepo) -- and therefore no Azure DevOps identity
|
|
830
|
-
// check either, since that check exists only to gate the fetch
|
|
831
|
-
// maintainRepo is about to skip anyway.
|
|
832
|
-
if (armOnly) continue
|
|
833
|
-
if (agentForAdo === undefined && originIsAzureDevOps(repo)) {
|
|
834
|
-
log(`${name}: skipping — Azure DevOps remote but no agent identity resolved (see the start-of-run message above)`)
|
|
835
|
-
refreshed = false
|
|
836
|
-
continue
|
|
837
|
-
}
|
|
838
|
-
maintainRepo(repo, keep, dry)
|
|
839
|
-
if (!refreshRepo(repo, dry)) refreshed = false
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
log('=== git-maintain done ===')
|
|
843
|
-
return refreshed ? 0 : 1
|
|
844
|
-
}
|
|
845
|
-
|
|
846
|
-
if (import.meta.main) {
|
|
847
|
-
process.exitCode = main(process.env, process.execPath, INSTALLED_MANIFEST, process.argv.slice(2))
|
|
848
|
-
}
|