mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-09-11 19:46:21 +00:00
* fix(dev): install hooks during worktree bootstrap
* fix(dev): keep worktree hook setup out of the shared git config
Husky runs `git config core.hooksPath .husky/_` with no --worktree, so
from a linked worktree the value lands in the config every worktree of
the repository shares while `.husky/_` is created only in the checkout
being bootstrapped. Skip the Husky step and report it when the key is
unset and this checkout does not own the repository config, so a
bootstrap can no longer repoint hook resolution for roots that never
received the wrappers. A primary checkout still installs hooks, and an
already-configured `core.hooksPath` is untouched.
Also drop the caller's success exit, which `install()` made unreachable
when it started exiting on every successful path, and bring the
pnpm-worktree-bootstrap design doc in line with a hook step it still
recorded as deliberately skipped.
The new fixture runs the real script against a throwaway root whose
`.git` is a file or a directory and whose config comes from a real
`git init` repo, which makes both new branches reachable and pins the
fail-closed guard: the injected `GIT_CONFIG_*` constant holds one value
for the child's whole lifetime and cannot express the unset state that
asks husky to write.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtuo29vduf
* fix(dev): ask git which root owns the worktree hook config
`ownsRepositoryConfig()` inferred repository-config ownership from the
filesystem shape of `.git`, and the proxy is wrong at both ends. With no
`.git` at all, `statSync(..., { throwIfNoEntry: false })` returns `undefined`
and the predicate folded that absence into "owns the config", so a
repository-less checkout ran husky into its `.git can't be found` soft failure
(exit 0) and the fail-closed check then turned a successful dependency install
into exit 1 blaming Husky. A `.git` file is not only a linked worktree either:
`git clone --separate-git-dir` checkouts and submodules have one too and do own
their config, so hooks were declined where they would have been correctly
scoped.
Ask git instead: `rev-parse --git-dir` differs from `--git-common-dir` only in
a linked worktree, and a failed `rev-parse` names the no-repository state, so
the skip notice stops asserting "linked worktree" and the bootstrap does not
gain a hard git dependency.
Also bind the fail-closed check to an artefact husky's own write produced, not
only to the config value. husky 9.1.7 exits 0 on every soft-failure path
(`index.js:16` git command not found, `index.js:17` refused `git config` write)
before the `mkdirSync(_())` on line 19, and a linked worktree inherits
`core.hooksPath` from the config it shares, so re-reading that value compared
it against itself and passed exactly when husky had created nothing.
Restore the registry-fallback case's hermeticity: `PATH` holds only the stub
directory again, which now also pins that the retry needs no ambient git.
Rebuild the ownership fixture from real git layouts, because `rev-parse`
resolves nothing for a `mkdirSync`'d `.git` or a hand-written `gitdir:` file,
and give the stub husky a failing mode so the exit code husky returns is pinned
rather than the install result's.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtuys2rjuu
* fix(dev): surface hooks-path read failures in worktree bootstrap
- getHooksPath() no longer collapses a refused git config read (exit 128/2/3) into "unset": only an absent key (exit 1) or a missing git binary keeps the skip path; anything else fails the bootstrap with the read error instead of a green, hook-less worktree.
- The linked-worktree skip notice now names the recovery path: re-run this script once the primary checkout has hooks installed.
- Tests cover a git stub exiting 128, the real-world unset HUSKY state, and pin the recovery sentence in the skip notice.
---------
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
182 lines
6.4 KiB
JavaScript
182 lines
6.4 KiB
JavaScript
/**
|
|
* @license
|
|
* Copyright 2026 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { spawnSync } from 'node:child_process';
|
|
import { existsSync, readFileSync } from 'node:fs';
|
|
import { constants as osConstants } from 'node:os';
|
|
import { delimiter, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import { getPinnedPnpmPackage } from './pnpm-package.js';
|
|
|
|
const corepack = process.platform === 'win32' ? 'corepack.cmd' : 'corepack';
|
|
// The script lives in <repo>/scripts, so it bootstraps the checkout it
|
|
// belongs to no matter which directory the caller runs it from.
|
|
const rootDir = fileURLToPath(new URL('..', import.meta.url));
|
|
getPinnedPnpmPackage(
|
|
JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')),
|
|
);
|
|
const env = {
|
|
...process.env,
|
|
QWEN_SKIP_PREPARE: '1',
|
|
QWEN_SKIP_NOTICE_GENERATION: '1',
|
|
};
|
|
|
|
// A spread of process.env is an ordinary object: on Windows the path
|
|
// variable canonically arrives as `Path`, so a case-sensitive `env.PATH`
|
|
// read misses it and corepack is never found.
|
|
function envValue(name) {
|
|
if (process.platform !== 'win32') return env[name];
|
|
const key = Object.keys(env).find((key) => key.toUpperCase() === name);
|
|
return key === undefined ? undefined : env[key];
|
|
}
|
|
|
|
function pathValue() {
|
|
return envValue('PATH') ?? '';
|
|
}
|
|
|
|
function findOnPath(command) {
|
|
for (const entry of pathValue().split(delimiter)) {
|
|
const directory = entry.replace(/^"(.*)"$/, '$1');
|
|
const candidate = resolve(directory || '.', command);
|
|
if (existsSync(candidate)) return candidate;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
const corepackPath = findOnPath(corepack);
|
|
if (!corepackPath) {
|
|
console.error(
|
|
'worktree setup failed: Corepack is required to verify the pinned pnpm package',
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
function runPnpm(args) {
|
|
return spawnSync(corepack, ['pnpm', ...args], {
|
|
cwd: rootDir,
|
|
env,
|
|
shell: process.platform === 'win32',
|
|
stdio: 'inherit',
|
|
});
|
|
}
|
|
|
|
function getHooksPath() {
|
|
const result = spawnSync('git', ['config', '--get', 'core.hooksPath'], {
|
|
cwd: rootDir,
|
|
env,
|
|
encoding: 'utf8',
|
|
});
|
|
if (result.status === 0) return result.stdout.trim();
|
|
// git exits 1 when the key is absent, and a spawn failure means git itself
|
|
// is unavailable — the ownership probe below reports that shape as having
|
|
// no repository. Any other status is a read failure (a refused config on a
|
|
// shared host, a config error) the hooks decision must not be made from.
|
|
if (result.status === 1 || result.error) return undefined;
|
|
console.error(
|
|
`worktree setup failed: could not read core.hooksPath (${result.stderr.trim()})`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Husky runs `git config core.hooksPath .husky/_` with no --worktree, so the
|
|
// value always lands in the config of the root that owns the repository while
|
|
// the `.husky/_` wrappers are created in the working directory it was invoked
|
|
// from. Git names that root: `--git-dir` differs from `--git-common-dir` only
|
|
// in a linked worktree, whose config every sibling worktree shares. The shape
|
|
// of `.git` is no proxy for it — a file also means a `--separate-git-dir` clone
|
|
// or a submodule, which own their config, and no `.git` means no repository.
|
|
function repositoryConfigOwnership() {
|
|
const probe = spawnSync(
|
|
'git',
|
|
['rev-parse', '--git-dir', '--git-common-dir'],
|
|
{ cwd: rootDir, env, encoding: 'utf8' },
|
|
);
|
|
if (probe.status !== 0) return 'none';
|
|
const [gitDir, commonDir] = probe.stdout.trim().split(/\r?\n/);
|
|
return gitDir === commonDir ? 'owns' : 'linked';
|
|
}
|
|
|
|
function install(cacheMode) {
|
|
const result = runPnpm(['install', '--frozen-lockfile', cacheMode]);
|
|
if (result.status === 0) {
|
|
const hooksPath = getHooksPath();
|
|
if (
|
|
envValue('HUSKY') === '0' ||
|
|
(hooksPath !== undefined && hooksPath !== '.husky/_')
|
|
) {
|
|
exitWithResult(result);
|
|
}
|
|
// Without a repository there is no config for husky to write. With the key
|
|
// unset in a linked worktree, husky's write would add it to the config
|
|
// every worktree of this repository shares while only this checkout
|
|
// receives `.husky/_`, silently repointing hook resolution for roots that
|
|
// never got the wrappers. Leave both alone and say so instead.
|
|
const ownership = repositoryConfigOwnership();
|
|
if (
|
|
ownership === 'none' ||
|
|
(hooksPath === undefined && ownership === 'linked')
|
|
) {
|
|
console.log(
|
|
ownership === 'none'
|
|
? 'worktree setup: git could not resolve a repository for this ' +
|
|
'checkout; skipping Husky because there is no repository config ' +
|
|
'for it to write.'
|
|
: 'worktree setup: core.hooksPath is unset and this checkout does not ' +
|
|
'own the repository config; skipping Husky so the hooks path is ' +
|
|
'not rewritten for every other worktree. Re-run this script here ' +
|
|
'once hooks are installed in the primary checkout.',
|
|
);
|
|
exitWithResult(result);
|
|
}
|
|
// Husky exits 0 on every soft failure (`.git can't be found`, a refused
|
|
// `git config` write), so success takes both proofs: the config value says
|
|
// git will use the hooks, and a wrapper on disk says husky wrote them here.
|
|
const husky = runPnpm(['exec', 'husky']);
|
|
if (
|
|
husky.status === 0 &&
|
|
(getHooksPath() !== '.husky/_' ||
|
|
!existsSync(resolve(rootDir, '.husky', '_', 'pre-commit')))
|
|
) {
|
|
console.error('worktree setup failed: Husky did not install hooks');
|
|
process.exit(1);
|
|
}
|
|
exitWithResult(husky);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function exitWithResult(result) {
|
|
if (result.error) {
|
|
console.error(`worktree setup failed: ${result.error.message}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (result.signal) {
|
|
console.error(`worktree setup killed by signal ${result.signal}`);
|
|
const signalNumber = osConstants.signals[result.signal];
|
|
process.exit(signalNumber ? 128 + signalNumber : 1);
|
|
}
|
|
|
|
process.exit(result.status ?? 1);
|
|
}
|
|
|
|
// install() exits the process on every path where the install succeeded, so it
|
|
// returns only a failed result and the registry retry below is the only
|
|
// decision left for this driver to make.
|
|
const cachedInstall = install('--offline');
|
|
|
|
if (
|
|
cachedInstall.error ||
|
|
cachedInstall.signal ||
|
|
(cachedInstall.status !== null && cachedInstall.status >= 128)
|
|
) {
|
|
exitWithResult(cachedInstall);
|
|
}
|
|
|
|
console.warn('Cached install unavailable; retrying with registry access.');
|
|
exitWithResult(install('--prefer-offline'));
|