fix: git в админке — ls-remote и pull от владельца репозитория

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
shop
2026-05-17 14:35:15 +03:00
parent d4166ec62a
commit 4c37f4ac1a
4 changed files with 133 additions and 50 deletions
+100 -29
View File
@@ -41,38 +41,99 @@ function gitEnv(root) {
};
}
/** Git 2.35+: репозиторий с другим владельцем (www-data vs root) */
async function ensureSafeDirectory(root) {
const resolved = path.resolve(root);
const home = process.env.HOME || '/var/www';
async function getRepoOwner(root) {
if (process.env.SHOP_GIT_USER) {
return process.env.SHOP_GIT_USER.trim();
}
if (process.platform === 'win32') return null;
const gitPath = path.join(root, '.git');
try {
await execFileAsync('git', ['config', '--global', '--add', 'safe.directory', resolved], {
timeout: 15000,
env: { ...process.env, HOME: home },
});
const target = fs.statSync(gitPath).isDirectory() ? gitPath : root;
const { stdout } = await execFileAsync('stat', ['-c', '%U', target], { timeout: 5000 });
const user = stdout.trim();
return user || null;
} catch {
// глобальный config может быть недоступен — используем -c в gitCmd
return null;
}
}
async function gitCmd(args, cwd) {
const root = path.resolve(cwd);
const { stdout, stderr } = await execFileAsync(
'git',
['-c', `safe.directory=${root}`, ...args],
{
cwd: root,
maxBuffer: 1024 * 1024,
timeout: 90000,
env: gitEnv(root),
async function ensureSafeDirectory(root, user) {
const resolved = path.resolve(root);
const targets = [{ home: process.env.HOME || '/var/www' }];
if (user) {
try {
const { stdout } = await execFileAsync('getent', ['passwd', user], { timeout: 5000 });
const home = stdout.split(':')[5];
if (home) targets.push({ home });
} catch {
/* ignore */
}
);
}
for (const { home } of targets) {
try {
await execFileAsync('git', ['config', '--global', '--add', 'safe.directory', resolved], {
timeout: 15000,
env: { ...process.env, HOME: home },
});
} catch {
/* ignore */
}
}
}
async function execGit(args, cwd, runAs) {
const root = path.resolve(cwd);
const gitArgs = ['-c', `safe.directory=${root}`, ...args];
const opts = {
cwd: root,
maxBuffer: 1024 * 1024,
timeout: 120000,
env: gitEnv(root),
};
if (runAs && process.env.ADMIN_UPDATE_USE_SUDO === '1') {
const { stdout, stderr } = await execFileAsync('sudo', ['-n', '-u', runAs, 'git', ...gitArgs], opts);
return `${stdout}${stderr}`.trim();
}
const { stdout, stderr } = await execFileAsync('git', gitArgs, opts);
return `${stdout}${stderr}`.trim();
}
async function gitCmd(args, cwd, { needsWrite = false } = {}) {
const root = path.resolve(cwd);
const owner = await getRepoOwner(root);
try {
return await execGit(args, root, null);
} catch (err) {
const msg = err.message || '';
const denied = /permission denied|EACCES|FETCH_HEAD/i.test(msg);
if ((needsWrite || denied) && owner) {
await ensureSafeDirectory(root, owner);
return execGit(args, root, owner);
}
throw err;
}
}
/** Сколько коммитов на origin/main без записи в .git (без fetch) */
async function getCommitsBehind(root) {
const localHead = (await gitCmd(['rev-parse', 'HEAD'], root)).split('\n')[0].trim();
const remoteOut = await gitCmd(['ls-remote', 'origin', 'refs/heads/main'], root);
const remoteSha = remoteOut.split(/\s+/)[0]?.trim();
if (!remoteSha) {
throw new Error('Не найден refs/heads/main на origin');
}
if (remoteSha === localHead) return 0;
const count = await gitCmd(['rev-list', '--count', `${localHead}..${remoteSha}`], root);
return parseInt(count, 10) || 0;
}
async function getGitInfo({ fetchRemote = false } = {}) {
const root = resolveShopRoot();
const pkg = root ? JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) : null;
const repoOwner = root ? await getRepoOwner(root) : null;
if (!root || !fs.existsSync(path.join(root, '.git'))) {
return {
@@ -86,6 +147,7 @@ async function getGitInfo({ fetchRemote = false } = {}) {
const info = {
available: true,
shopRoot: root,
repoOwner,
packageVersion: pkg?.version || null,
branch: null,
commit: null,
@@ -93,13 +155,14 @@ async function getGitInfo({ fetchRemote = false } = {}) {
commitSubject: null,
commitDate: null,
dirty: false,
dirtyHint: null,
behind: null,
updateEnabled: isUpdateEnabled(),
platform: process.platform,
};
try {
await ensureSafeDirectory(root);
await ensureSafeDirectory(root, repoOwner);
info.branch = await gitCmd(['branch', '--show-current'], root);
if (!info.branch) {
info.branch = '(detached)';
@@ -109,8 +172,17 @@ async function getGitInfo({ fetchRemote = false } = {}) {
info.commitShort = info.commitShort || (await gitCmd(['rev-parse', '--short', 'HEAD'], root));
info.commitSubject = await gitCmd(['log', '-1', '--pretty=%s'], root);
info.commitDate = await gitCmd(['log', '-1', '--pretty=%ci'], root);
const status = await gitCmd(['status', '--porcelain'], root);
info.dirty = status.length > 0;
try {
const status = await gitCmd(['status', '--porcelain'], root);
info.dirty = status.length > 0;
if (info.dirty) {
info.dirtyHint =
'На сервере есть локальные изменения. Обновление может их перезаписать; при ошибке выполните git stash или git reset --hard с машины администратора.';
}
} catch {
info.dirty = null;
info.dirtyHint = 'Не удалось прочитать статус (права на .git).';
}
} catch (err) {
info.available = false;
info.reason = err.message;
@@ -119,14 +191,12 @@ async function getGitInfo({ fetchRemote = false } = {}) {
if (fetchRemote) {
try {
await gitCmd(['fetch', 'origin'], root);
const behind = await gitCmd(
['rev-list', '--count', 'HEAD..origin/main'],
root
);
info.behind = parseInt(behind, 10) || 0;
info.behind = await getCommitsBehind(root);
} catch (err) {
info.fetchError = err.message;
if (repoOwner) {
info.fetchError += ` Владелец репозитория: ${repoOwner}. Задайте SHOP_GIT_USER=${repoOwner} и ADMIN_UPDATE_USE_SUDO=1 в .env.`;
}
}
}
@@ -179,4 +249,5 @@ module.exports = {
isUpdateEnabled,
getGitInfo,
runDeployUpdate,
getRepoOwner,
};
+8 -1
View File
@@ -28,6 +28,10 @@
<dd><strong>v<%= git.packageVersion || '?' %></strong></dd>
<dt>Каталог</dt>
<dd><code class="admin-system__path"><%= git.shopRoot %></code></dd>
<% if (git.repoOwner) { %>
<dt>Владелец .git</dt>
<dd><code><%= git.repoOwner %></code> <span class="muted">(git pull выполняется от этого пользователя)</span></dd>
<% } %>
<dt>Ветка</dt>
<dd><%= git.branch %></dd>
<dt>Коммит</dt>
@@ -38,7 +42,10 @@
</dd>
<% if (git.dirty) { %>
<dt>Состояние</dt>
<dd><span class="badge badge--warn">Есть незакоммиченные изменения</span></dd>
<dd>
<span class="badge badge--warn">Есть незакоммиченные изменения</span>
<% if (git.dirtyHint) { %><p class="muted" style="margin:0.35rem 0 0;font-size:0.85rem"><%= git.dirtyHint %></p><% } %>
</dd>
<% } %>
<% if (git.behind != null) { %>
<dt>На origin/main</dt>