code-foundry 1.46.3 → 1.46.5

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.
@@ -6,6 +6,46 @@ if [ -z "$(git diff --cached --name-only)" ]; then
6
6
  exit 0
7
7
  fi
8
8
 
9
+ # core.hooksPath names a single directory, so pointing it at .githooks would
10
+ # silence every machine-level hook behind it: secret guards, git-lfs, signing.
11
+ # Chain to the machine-level pre-commit when one exists. The target is
12
+ # discovered at run time — never baked into this committed file — from the
13
+ # repository's previous hooks directory (recorded by sync), then the global
14
+ # hooksPath, then the checkout's default hooks directory. The machine hook
15
+ # runs first so cheap secret guards reject before the heavier project gate.
16
+ machine_hook() {
17
+ hook_name="$1"
18
+ for dir in \
19
+ "$(git config --get code-foundry.previousHooksPath 2>/dev/null || true)" \
20
+ "$(git config --global --get core.hooksPath 2>/dev/null || true)" \
21
+ "$(git rev-parse --git-common-dir 2>/dev/null || printf '.git')/hooks"
22
+ do
23
+ case "$dir" in
24
+ '' | .githooks | ./.githooks | */.githooks) continue ;;
25
+ esac
26
+ candidate="$dir/$hook_name"
27
+ if [ -f "$candidate" ] && ! [ "$candidate" -ef "$0" ]; then
28
+ printf '%s' "$candidate"
29
+ return 0
30
+ fi
31
+ done
32
+ return 1
33
+ }
34
+
35
+ if [ -z "${CODE_FOUNDRY_HOOK_CHAIN:-}" ]; then
36
+ if machine="$(machine_hook pre-commit)"; then
37
+ # The marker travels through the machine hook into anything it delegates
38
+ # back to: a guard that chains to .githooks/pre-commit (a mutual-delegation
39
+ # pair) re-enters this hook, sees the marker, and runs the gate instead of
40
+ # chaining again — machine policy once, project policy once, no loop.
41
+ if [ -x "$machine" ]; then
42
+ CODE_FOUNDRY_HOOK_CHAIN=1 "$machine" "$@" || exit $?
43
+ else
44
+ CODE_FOUNDRY_HOOK_CHAIN=1 sh "$machine" "$@" || exit $?
45
+ fi
46
+ fi
47
+ fi
48
+
9
49
  # Prefer a runtime the repository already depends on. A repository opts into
10
50
  # the gate by depending on code-foundry; one that does not keeps the
11
51
  # whitespace guard and stays offline. Nothing is fetched at commit time.
@@ -447,6 +447,9 @@ jobs:
447
447
  # when it validated, github.token after fallback. The token value
448
448
  # lives only in this masked env expression, never in step outputs.
449
449
  AUTO_MERGE: ${{ steps.credentials.outputs.auto_merge }}
450
+ # Read Actions ownership with the workflow credential, not an
451
+ # automation token that may only have release/PR write permissions.
452
+ VALIDATION_TOKEN: ${{ github.token }}
450
453
  GH_TOKEN: ${{ steps.credentials.outputs.token_source == 'configured' && (secrets.CODE_FOUNDRY_TOKEN) || github.token }}
451
454
  run: |
452
455
  set -euo pipefail
@@ -464,12 +467,87 @@ jobs:
464
467
  fi
465
468
 
466
469
  for pr in "${release_prs[@]}"; do
467
- PR_DRAFT=$(gh pr view "$pr" --repo "$GITHUB_REPOSITORY" --json isDraft --jq '.isDraft')
470
+ pr_state=$(gh pr view "$pr" --repo "$GITHUB_REPOSITORY" \
471
+ --json isDraft,headRefOid,headRefName \
472
+ --jq '[.isDraft, .headRefOid, .headRefName] | @tsv')
473
+ read -r PR_DRAFT PR_HEAD PR_BRANCH <<< "$pr_state"
474
+ if [[ ! "$PR_DRAFT" =~ ^(true|false)$ || ! "$PR_HEAD" =~ ^[a-f0-9]{40}$ ]]; then
475
+ echo 'Unable to verify generated release PR state.' >&2
476
+ exit 1
477
+ fi
478
+ case "$PR_BRANCH" in
479
+ release-please--branches--main|release-please--branches--main--*) ;;
480
+ *) echo 'Release PR head left the generated namespace; refusing readiness mutation.' >&2; exit 1 ;;
481
+ esac
468
482
  if [ "$AUTO_MERGE" = true ]; then
469
483
  if [ "$PR_DRAFT" = false ]; then
470
- # A Release Please PR can be updated while already ready. Reset
471
- # it so ready_for_review always starts validation for the exact
472
- # current release head.
484
+ # A ready head already receives synchronize validation. Preserve
485
+ # its live/completed owner instead of cancelling and reallocating
486
+ # CI every time another queued main release run prepares it.
487
+ runs=$(GH_TOKEN="$VALIDATION_TOKEN" gh api \
488
+ "repos/$GITHUB_REPOSITORY/actions/runs" --method GET \
489
+ -f event=pull_request -f head_sha="$PR_HEAD" -f per_page=100)
490
+ validation=$(jq -er --arg head "$PR_HEAD" --arg branch "$PR_BRANCH" \
491
+ --arg repo "$GITHUB_REPOSITORY" '
492
+ def active: .status == "queued" or .status == "in_progress" or
493
+ .status == "waiting" or .status == "pending" or .status == "requested";
494
+ def valid_run:
495
+ type == "object" and
496
+ (.id | type == "number" and . > 0 and floor == .) and
497
+ (.run_attempt | type == "number" and . >= 1 and floor == .) and
498
+ (.head_sha | type == "string" and test("^[a-f0-9]{40}$")) and
499
+ (.head_branch | type == "string" and length > 0) and
500
+ (.event | type == "string" and length > 0) and
501
+ (.repository | type == "object") and
502
+ (.repository.full_name | type == "string" and length > 0) and
503
+ (.path | type == "string" and length > 0) and
504
+ ((active and has("conclusion") and .conclusion == null) or
505
+ (.status == "completed" and
506
+ (.conclusion == "success" or .conclusion == "failure" or
507
+ .conclusion == "timed_out" or .conclusion == "action_required" or
508
+ .conclusion == "cancelled" or .conclusion == "skipped" or
509
+ .conclusion == "neutral")));
510
+ if (.workflow_runs | type) != "array" or
511
+ (.total_count | type) != "number" then
512
+ error("Invalid validation owner response")
513
+ elif .total_count != (.workflow_runs | length) then
514
+ error("Incomplete validation owner response")
515
+ elif any(.workflow_runs[]; valid_run | not) then
516
+ error("Invalid validation run identity or state")
517
+ else
518
+ [.workflow_runs[] | (.path | split("@")[0]) as $workflow | select(
519
+ .head_sha == $head and .head_branch == $branch and
520
+ .event == "pull_request" and .repository.full_name == $repo and
521
+ ($workflow == ".github/workflows/validation.yml" or
522
+ $workflow == ".github/workflows/validation_self-ci.yml")
523
+ )] | sort_by(.id) | last |
524
+ if . == null then "missing"
525
+ elif active or (.status == "completed" and .conclusion == "success") then "owned"
526
+ elif .status == "completed" and
527
+ (.conclusion == "failure" or .conclusion == "timed_out" or
528
+ .conclusion == "action_required") then "failed"
529
+ elif .status == "completed" and
530
+ (.conclusion == "cancelled" or .conclusion == "skipped" or
531
+ .conclusion == "neutral") then "missing"
532
+ else error("Unconfirmed validation owner state") end
533
+ end' <<< "$runs")
534
+ fresh_state=$(gh pr view "$pr" --repo "$GITHUB_REPOSITORY" \
535
+ --json isDraft,headRefOid,headRefName \
536
+ --jq '[.isDraft, .headRefOid, .headRefName] | @tsv')
537
+ if [ "$fresh_state" != "$pr_state" ]; then
538
+ echo 'Release PR state changed during validation lookup; refusing a stale readiness mutation.' >&2
539
+ exit 1
540
+ fi
541
+ if [ "$validation" = owned ]; then
542
+ echo "Release PR #$pr already has current-head validation; preserving readiness."
543
+ continue
544
+ fi
545
+ if [ "$validation" = failed ]; then
546
+ echo "Current-head validation failed for release PR #$pr; resolve or explicitly retry it before release." >&2
547
+ exit 1
548
+ fi
549
+ # A missing, cancelled or skipped owner still needs the existing
550
+ # explicit ready_for_review transition with the validated token.
473
551
  gh pr ready --undo "$pr" --repo "$GITHUB_REPOSITORY"
474
552
  fi
475
553
  gh pr ready "$pr" --repo "$GITHUB_REPOSITORY"
package/.gitignore CHANGED
@@ -78,3 +78,8 @@ mise.local.toml
78
78
  mise.local.lock
79
79
  .mise.local.lock
80
80
  *.code-workspace
81
+
82
+ # Machine-local git hook delegates written by Code Foundry sync; only the
83
+ # committed pre-commit gate is tracked.
84
+ .githooks/*
85
+ !.githooks/pre-commit
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.46.5](https://github.com/0xPlayerOne/code-foundry/compare/v1.46.4...v1.46.5) (2026-10-08)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **sync:** chain machine-level hooks instead of silencing them ([#747](https://github.com/0xPlayerOne/code-foundry/issues/747)) ([7fcad52](https://github.com/0xPlayerOne/code-foundry/commit/7fcad52d400dbb2aaa9ba46c0c76f14e8d63f660)), closes [#690](https://github.com/0xPlayerOne/code-foundry/issues/690)
9
+
10
+ ## [1.46.4](https://github.com/0xPlayerOne/code-foundry/compare/v1.46.3...v1.46.4) (2026-10-08)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **release:** preserve current-head validation ownership ([#737](https://github.com/0xPlayerOne/code-foundry/issues/737)) ([aa8bfcf](https://github.com/0xPlayerOne/code-foundry/commit/aa8bfcfbfcd3c213d15ba9e1fbfb5c65ab8ddb6b))
16
+
3
17
  ## [1.46.3](https://github.com/0xPlayerOne/code-foundry/compare/v1.46.2...v1.46.3) (2026-10-07)
4
18
 
5
19
 
@@ -126,23 +140,9 @@
126
140
 
127
141
  * **release:** budget the package without the changelog and ship recent history only ([#708](https://github.com/0xPlayerOne/code-foundry/issues/708)) ([ab365e0](https://github.com/0xPlayerOne/code-foundry/commit/ab365e0910472c3596429adc86a1c8f3e19730f9))
128
142
 
129
- ## [1.44.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.43.2...v1.44.0) (2026-10-03)
130
-
131
-
132
- ### Features
133
-
134
- * **pre-commit:** make the commit gate change-aware ([#702](https://github.com/0xPlayerOne/code-foundry/issues/702)) ([f5bca91](https://github.com/0xPlayerOne/code-foundry/commit/f5bca91c177b30b99f5d62287255d191617b4788))
135
-
136
- ## [1.43.2](https://github.com/0xPlayerOne/code-foundry/compare/v1.43.1...v1.43.2) (2026-10-03)
137
-
138
-
139
- ### Bug Fixes
140
-
141
- * require candidate asset qualification before stable promotion ([#705](https://github.com/0xPlayerOne/code-foundry/issues/705)) ([f42e4f5](https://github.com/0xPlayerOne/code-foundry/commit/f42e4f5fb063ea22fa25791a13c75c90d41bf17a))
142
-
143
143
  <!-- code-foundry: packaged changelog -->
144
144
 
145
145
  ---
146
146
 
147
- This packaged changelog lists the 20 most recent releases; 238 older releases are omitted to keep the package small.
147
+ This packaged changelog lists the 20 most recent releases; 240 older releases are omitted to keep the package small.
148
148
  See the [full changelog](https://github.com/0xPlayerOne/code-foundry/blob/main/CHANGELOG.md) for the complete history.
@@ -201,6 +201,27 @@ exists on disk. Task scripts that declare dependencies of their own, such as a
201
201
  Turborepo `typecheck` task with `dependsOn: ["^build"]`, still run them; drop
202
202
  that dependency to keep type checks build-free.
203
203
 
204
+ ### Machine-level hook chaining
205
+
206
+ `core.hooksPath` names a single directory, so enabling `.githooks` would
207
+ silence every hook in the machine's own hooks directory: secret guards,
208
+ git-lfs, signing. Sync chains instead of replacing. The generated
209
+ `.githooks/pre-commit` discovers a machine-level `pre-commit` at run time —
210
+ from the hooks directory sync replaced (recorded as the
211
+ `code-foundry.previousHooksPath` repository config), then the global
212
+ `core.hooksPath`, then the checkout's default hooks directory — and runs it
213
+ before the gate, so a cheap secret guard rejects before the heavier project
214
+ checks. The committed hook stays machine-independent; the target is resolved
215
+ on the machine that runs it.
216
+
217
+ Hook names this repository does not own (`post-checkout`, `pre-push`,
218
+ `commit-msg`, and the other client-side hooks) get delegating stubs in
219
+ `.githooks` when a machine-level counterpart exists, which is what keeps
220
+ git-lfs working after sync. Stubs are machine-local, gitignored, rewritten on
221
+ every sync, and removed once the machine hook they mirrored disappears.
222
+ `code-foundry doctor` warns when a machine-level pre-commit exists but the
223
+ installed hook predates chaining.
224
+
204
225
  ## Release and branch policy
205
226
 
206
227
  | Key | Values | Purpose |
package/docs/WORKFLOWS.md CHANGED
@@ -82,6 +82,23 @@ to run generated gates for drafts. Cloudflare callers use their equivalent
82
82
  `draft-protection: false` input. These opt-outs do not remove Draft Guard or
83
83
  draft-PR automation; they only allow the protected gates to run for drafts.
84
84
 
85
+ Generated release PR preparation preserves a ready head when the canonical
86
+ generated `.github/workflows/validation.yml` (or Code Foundry
87
+ `validation_self-ci.yml`) pull-request run for that exact head, branch, and
88
+ repository is the newest matching run and is active or successful. API workflow
89
+ paths may include a Git ref suffix; ownership compares the canonical filename.
90
+ The workflow credential reads Actions ownership; the validated automation credential still performs readiness writes.
91
+ A newer failed qualification blocks release preparation until it is resolved or
92
+ explicitly retried. Missing, cancelled, or skipped validation still receives the
93
+ explicit draft/ready transition; a new draft receives its first readiness event.
94
+ An unavailable or malformed ownership response, an incomplete Actions page, or a
95
+ PR state change during the lookup fails before mutation. An older live run cannot
96
+ mask a newer failed qualification. Invalid automation credentials retain the
97
+ manual draft fallback. This changes readiness only: release creation, qualification,
98
+ tag publication, and branch-policy-gated exact-head merging still run through
99
+ the existing release pipeline. Consumers must adopt the fixed reusable workflow
100
+ pin before they receive this behavior; older pins still reset ready heads.
101
+
85
102
  ## Billing pause
86
103
 
87
104
  Code Foundry consumers can stop all generated jobs from allocating GitHub-hosted
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-foundry",
3
- "version": "1.46.3",
3
+ "version": "1.46.5",
4
4
  "description": "A fast, language-aware repository factory for agent-ready workflows, testing, security, and release automation.",
5
5
  "homepage": "https://github.com/0xPlayerOne/code-foundry#readme",
6
6
  "bugs": {
@@ -1,7 +1,7 @@
1
1
  // @ts-check
2
2
 
3
- import { existsSync, readFileSync, readdirSync } from 'node:fs'
4
- import { join, resolve } from 'node:path'
3
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
4
+ import { join, resolve, isAbsolute } from 'node:path'
5
5
  import { spawnSync } from 'node:child_process'
6
6
  import { includesValue, readConfig } from '../lib/config.mjs'
7
7
  import { recommendRunners, resolveProfile } from '../lib/profile.mjs'
@@ -53,6 +53,29 @@ export function doctor(root, options = {}) {
53
53
  warn(
54
54
  '.githooks/pre-commit does not delegate to the Code Foundry gate; run `npx code-foundry sync`'
55
55
  )
56
+ // core.hooksPath shadows every machine-level hook behind it (secret
57
+ // guards, git-lfs). The generated hook chains to the machine's pre-commit
58
+ // when sync recorded one, so a machine hook without the chain marker in
59
+ // the installed hook is exactly the silent-disable bug chaining exists to
60
+ // prevent.
61
+ const hookText = existsSync(hookFile) ? readFileSync(hookFile, 'utf8') : ''
62
+ const machineDirs = [
63
+ git(target, ['config', '--get', 'code-foundry.previousHooksPath']),
64
+ git(target, ['config', '--global', '--get', 'core.hooksPath']),
65
+ `${git(target, ['rev-parse', '--git-common-dir']) || '.git'}/hooks`,
66
+ ].filter((dir) => dir && dir !== '.githooks' && dir !== './.githooks')
67
+ const machinePreCommit = machineDirs.some((dir) => {
68
+ const candidate = isAbsolute(dir) ? join(dir, 'pre-commit') : join(target, dir, 'pre-commit')
69
+ try {
70
+ return statSync(candidate).isFile()
71
+ } catch {
72
+ return false
73
+ }
74
+ })
75
+ if (machinePreCommit && !hookText.includes('machine_hook'))
76
+ warn(
77
+ 'A machine-level pre-commit exists but the installed hook does not chain to it; run `npx code-foundry sync`'
78
+ )
56
79
  }
57
80
  if (
58
81
  ['rust', 'python', 'solidity'].some((language) =>
@@ -7,9 +7,10 @@ import {
7
7
  readdirSync,
8
8
  readFileSync,
9
9
  rmSync,
10
+ statSync,
10
11
  writeFileSync,
11
12
  } from 'node:fs'
12
- import { dirname, join, resolve } from 'node:path'
13
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
13
14
  import { spawnSync } from 'node:child_process'
14
15
  import {
15
16
  detectLanguages,
@@ -550,7 +551,17 @@ function synchronize(options) {
550
551
  existsSync(join(target, '.githooks/pre-commit'))
551
552
  ) {
552
553
  chmodSync(join(target, '.githooks/pre-commit'), 0o755)
554
+ // core.hooksPath names a single directory, so pointing it at .githooks
555
+ // would silence every machine-level hook behind it (secret guards,
556
+ // git-lfs, signing). Record what is being replaced so the generated
557
+ // hooks can chain back to it, then delegate the hook names this
558
+ // repository does not own.
559
+ const previousHooksPath = gitOutput(target, ['config', '--get', 'core.hooksPath'])
560
+ if (previousHooksPath && previousHooksPath !== '.githooks') {
561
+ git(target, ['config', 'code-foundry.previousHooksPath', previousHooksPath])
562
+ }
553
563
  git(target, ['config', 'core.hooksPath', '.githooks'])
564
+ syncMachineHookDelegates(target, previousHooksPath)
554
565
  }
555
566
  console.log(`${changed.length} baseline file(s) differ.`)
556
567
  if (overlays.custom_workflows === 'preserve') {
@@ -1848,6 +1859,138 @@ function git(target, args) {
1848
1859
  spawnSync('git', args, { cwd: target, stdio: 'ignore' })
1849
1860
  }
1850
1861
 
1862
+ /** @param {string} target @param {string[]} args @returns {string} */
1863
+ function gitOutput(target, args) {
1864
+ const result = spawnSync('git', args, { cwd: target, encoding: 'utf8' })
1865
+ return result.status === 0 ? (result.stdout ?? '').trim() : ''
1866
+ }
1867
+
1868
+ /**
1869
+ * Hook names git runs on the client that Code Foundry does not own. When one
1870
+ * of these exists in a machine-level hooks directory, sync writes a
1871
+ * delegating stub for it so enabling `core.hooksPath=.githooks` keeps the
1872
+ * machine's hooks (git-lfs, secret scanners) firing.
1873
+ */
1874
+ const MACHINE_DELEGATED_HOOKS = [
1875
+ 'applypatch-msg',
1876
+ 'commit-msg',
1877
+ 'prepare-commit-msg',
1878
+ 'post-applypatch',
1879
+ 'post-checkout',
1880
+ 'post-commit',
1881
+ 'post-merge',
1882
+ 'post-rewrite',
1883
+ 'pre-applypatch',
1884
+ 'pre-auto-gc',
1885
+ 'pre-rebase',
1886
+ 'pre-push',
1887
+ 'reference-transaction',
1888
+ 'sendemail-validate',
1889
+ 'post-index-change',
1890
+ ]
1891
+
1892
+ /**
1893
+ * The hooks directories a machine-level hook may live in, in the order the
1894
+ * generated hooks probe them: the repository hooks path sync just replaced,
1895
+ * then the global one, then the checkout's default directory. Relative values
1896
+ * resolve against the repository root, matching git's own semantics.
1897
+ *
1898
+ * @param {string} target @param {string} previousHooksPath @returns {string[]}
1899
+ */
1900
+ function machineHooksDirectories(target, previousHooksPath) {
1901
+ return [
1902
+ previousHooksPath,
1903
+ gitOutput(target, ['config', '--global', '--get', 'core.hooksPath']),
1904
+ `${gitOutput(target, ['rev-parse', '--git-common-dir']) || '.git'}/hooks`,
1905
+ ].flatMap((dir) => {
1906
+ if (!dir || dir === '.githooks' || dir === './.githooks') return []
1907
+ return [isAbsolute(dir) ? dir : join(target, dir)]
1908
+ })
1909
+ }
1910
+
1911
+ /**
1912
+ * The delegating stub for one machine-level hook name. Self-contained: the
1913
+ * machine's hooks directories are re-probed at run time so the committed
1914
+ * templates never bake in machine-specific paths.
1915
+ *
1916
+ * @param {string} name
1917
+ */
1918
+ const machineDelegateStub = (name) => `#!/usr/bin/env sh
1919
+ # Generated by Code Foundry — delegates ${name} to the machine-level hook so
1920
+ # enabling .githooks keeps secret guards, git-lfs, and other machine hooks
1921
+ # working. Discovery mirrors .githooks/pre-commit; rerun sync after changing
1922
+ # the machine's hooks.
1923
+ set -eu
1924
+ machine_hook() {
1925
+ hook_name="$1"
1926
+ for dir in \\
1927
+ "$(git config --get code-foundry.previousHooksPath 2>/dev/null || true)" \\
1928
+ "$(git config --global --get core.hooksPath 2>/dev/null || true)" \\
1929
+ "$(git rev-parse --git-common-dir 2>/dev/null || printf '.git')/hooks"
1930
+ do
1931
+ case "$dir" in
1932
+ '' | .githooks | ./.githooks | */.githooks) continue ;;
1933
+ esac
1934
+ candidate="$dir/$hook_name"
1935
+ if [ -f "$candidate" ] && ! [ "$candidate" -ef "$0" ]; then
1936
+ printf '%s' "$candidate"
1937
+ return 0
1938
+ fi
1939
+ done
1940
+ return 1
1941
+ }
1942
+ if [ -n "\${CODE_FOUNDRY_HOOK_CHAIN:-}" ]; then
1943
+ exit 0
1944
+ fi
1945
+ if machine="$(machine_hook ${name})"; then
1946
+ # The marker makes a machine hook that delegates back to .githooks settle
1947
+ # here instead of looping.
1948
+ if [ -x "$machine" ]; then
1949
+ CODE_FOUNDRY_HOOK_CHAIN=1 exec "$machine" "$@"
1950
+ else
1951
+ CODE_FOUNDRY_HOOK_CHAIN=1 exec sh "$machine" "$@"
1952
+ fi
1953
+ fi
1954
+ exit 0
1955
+ `
1956
+
1957
+ /**
1958
+ * Write delegating stubs into .githooks for every machine-level hook this
1959
+ * repository does not own, and remove stubs the machine no longer backs. The
1960
+ * stubs are machine-local (gitignored): they are rewritten on every sync and
1961
+ * only ever removed when their content carries this generator's marker.
1962
+ *
1963
+ * @param {string} target @param {string} previousHooksPath
1964
+ */
1965
+ function syncMachineHookDelegates(target, previousHooksPath) {
1966
+ const directories = machineHooksDirectories(target, previousHooksPath)
1967
+ /** @param {string} name */
1968
+ const machineHook = (name) => {
1969
+ for (const dir of directories) {
1970
+ const candidate = join(dir, name)
1971
+ try {
1972
+ if (statSync(candidate).isFile()) return candidate
1973
+ } catch {
1974
+ // Absent from this directory; keep probing.
1975
+ }
1976
+ }
1977
+ return null
1978
+ }
1979
+ mkdirSync(join(target, '.githooks'), { recursive: true })
1980
+ for (const name of MACHINE_DELEGATED_HOOKS) {
1981
+ const stubPath = join(target, '.githooks', name)
1982
+ if (machineHook(name)) {
1983
+ writeFileSync(stubPath, machineDelegateStub(name))
1984
+ chmodSync(stubPath, 0o755)
1985
+ } else if (
1986
+ existsSync(stubPath) &&
1987
+ readFileSync(stubPath, 'utf8').includes('Generated by Code Foundry')
1988
+ ) {
1989
+ rmSync(stubPath)
1990
+ }
1991
+ }
1992
+ }
1993
+
1851
1994
  /** @param {string} file @param {Buffer|string} content @param {boolean} dryRun */
1852
1995
  function writeOrReport(file, content, dryRun) {
1853
1996
  if (dryRun) {
@@ -83,3 +83,8 @@ mise.local.toml
83
83
  mise.local.lock
84
84
  .mise.local.lock
85
85
  *.code-workspace
86
+
87
+ # Machine-local git hook delegates written by Code Foundry sync; only the
88
+ # committed pre-commit gate is tracked.
89
+ .githooks/*
90
+ !.githooks/pre-commit