$args Git args (e.g. ['log', '-1', '--format=%cd|%s']) * @return array{ok:bool,code:int,output:string} */ function git_run(array $args, string $repo): array { // On hosts with proc_open() in disable_functions, calling it is a FATAL // "undefined function" error — fail soft so callers can degrade. if (!platform_proc_open_available()) { return ['ok' => false, 'code' => -1, 'output' => 'proc_open() está desativado no PHP deste servidor (disable_functions) — as operações git via web não estão disponíveis.']; } // One temp gitconfig per REQUEST, not per call: a page load fires 30+ git // invocations and each one was writing + unlinking its own temp file. // `safe.directory = *` covers the main repo and every submodule alike. static $tmp_gitconfig = null; if ($tmp_gitconfig === null) { $tmp_gitconfig = @tempnam(sys_get_temp_dir(), 'platgit_'); if ($tmp_gitconfig !== false) { @file_put_contents($tmp_gitconfig, "[safe]\n\tdirectory = *\n"); $cfg = $tmp_gitconfig; register_shutdown_function(function () use ($cfg) { @unlink($cfg); }); } } // Base env every git subprocess shares. GIT_CONFIG_GLOBAL must point at // the temp gitconfig BEFORE we even read the remote URL, otherwise the // safe.directory guard can break that call. $env = getenv() ?: []; if ($tmp_gitconfig !== false) { $env['GIT_CONFIG_GLOBAL'] = $tmp_gitconfig; } $env['GIT_TERMINAL_PROMPT'] = '0'; // HTTP credentials for non-interactive fetch/pull. PHP has no TTY, so any // prompt for username/password kills the call. If GIT_HTTP_USERNAME + // GIT_HTTP_TOKEN are set (via env() or getenv()), write them to a chmod // 0600 temp file and tell git to read credentials from there via the // built-in `store` helper. Resolved once per repo per request (the remote // URL probe is itself a git subprocess); cleaned up at shutdown. static $creds_cache = []; if (!array_key_exists($repo, $creds_cache)) { $creds_cache[$repo] = null; $username = (string) (function_exists('env') ? (env('GIT_HTTP_USERNAME') ?: '') : (getenv('GIT_HTTP_USERNAME') ?: '')); $token = (string) (function_exists('env') ? (env('GIT_HTTP_TOKEN') ?: '') : (getenv('GIT_HTTP_TOKEN') ?: '')); if ($username !== '' && $token !== '') { // Read the remote URL via git itself — works for regular repos AND // submodules (where .git is a file pointing to .git/modules//, // so parsing $repo/.git/config directly would fail). $url_cmd = ['git', '-C', $repo, 'config', '--get', 'remote.origin.url']; $url_desc = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; $url_proc = @proc_open($url_cmd, $url_desc, $url_pipes, null, $env); $remote_url = ''; if (is_resource($url_proc)) { $remote_url = trim((string) stream_get_contents($url_pipes[1])); fclose($url_pipes[1]); fclose($url_pipes[2]); proc_close($url_proc); } if ($remote_url !== '' && preg_match('#^https?://#', $remote_url)) { $url = parse_url($remote_url); if (isset($url['scheme'], $url['host'])) { $host = $url['host'] . (isset($url['port']) ? ':' . $url['port'] : ''); $line = $url['scheme'] . '://' . rawurlencode($username) . ':' . rawurlencode($token) . '@' . $host . "\n"; $creds_file = @tempnam(sys_get_temp_dir(), 'gitcred_'); if ($creds_file !== false) { @chmod($creds_file, 0600); @file_put_contents($creds_file, $line); register_shutdown_function(function () use ($creds_file) { @unlink($creds_file); }); $creds_cache[$repo] = $creds_file; } } } } } $creds_file = $creds_cache[$repo]; $cmd = ['git', '-C', $repo]; if ($creds_file !== null) { $cmd[] = '-c'; $cmd[] = 'credential.helper='; $cmd[] = '-c'; $cmd[] = 'credential.helper=store --file=' . $creds_file; } $cmd = array_merge($cmd, $args); $descs = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; $proc = @proc_open($cmd, $descs, $pipes, null, $env); if (!is_resource($proc)) { return ['ok' => false, 'code' => -1, 'output' => 'Não foi possível invocar o git. Confirme que está instalado e no PATH do utilizador do servidor web.']; } $out = stream_get_contents($pipes[1]); $err = stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]); $code = proc_close($proc); $output = trim((string) $out); if ($err !== '' && $err !== false) { $output .= ($output !== '' ? "\n" : '') . trim($err); } return ['ok' => $code === 0, 'code' => $code, 'output' => $output]; } /** * Submodule paths declared in .gitmodules, relative to the repo root. * * Reads them via git itself (`git config -f .gitmodules --get-regexp path`) so * the result matches exactly what git acts on. Returns [] when there are no * submodules (or no .gitmodules) — callers must treat that as a no-op, never an * error. * * @return array */ function git_submodule_paths(string $repo): array { if (!is_file($repo . '/.gitmodules')) return []; $res = git_run(['config', '-f', '.gitmodules', '--get-regexp', 'path'], $repo); if (!$res['ok'] || $res['output'] === '') return []; $paths = []; foreach (preg_split('/\R/', $res['output']) as $line) { // Each line: "submodule..path " $parts = preg_split('/\s+/', trim($line), 2); if (count($parts) === 2 && $parts[1] !== '') { $paths[] = $parts[1]; } } return $paths; } /** * Idempotently stop git from tracking the filesystem executable bit, in the * main repo AND every initialized submodule. Run this at the very start of any * update flow, BEFORE fetch/pull/reset. * * `core.fileMode` is LOCAL per-repo config (it never travels in a commit), so on * a bind-mount/Docker volume that reports +x inconsistently it must be re-asserted * on every run — hence a function, not a versioned file. With it set to false the * phantom `old mode 100644 → new mode 100755` diffs (and the resulting * `modified: ` in the parent) simply vanish from `git status`. * * @return array{ok:bool,log:array,normalized:bool} */ function git_harden_repo(string $repo): array { $log = []; $all_false = true; $before = trim(git_run(['config', '--get', 'core.fileMode'], $repo)['output']); $set = git_run(['config', 'core.fileMode', 'false'], $repo); $log[] = 'core.fileMode=false (repo principal) → ' . ($set['ok'] ? ($before === 'false' ? 'já normalizado' : 'normalizado') : 'FALHOU') . ($set['ok'] || $set['output'] === '' ? '' : ': ' . $set['output']); if (!$set['ok']) $all_false = false; foreach (git_submodule_paths($repo) as $path) { $full = $repo . '/' . $path; if (!file_exists($full . '/.git')) { $log[] = "core.fileMode (submódulo {$path}) → ignorado (não inicializado)"; continue; } $b = trim(git_run(['config', '--get', 'core.fileMode'], $full)['output']); $rs = git_run(['config', 'core.fileMode', 'false'], $full); $log[] = "core.fileMode=false (submódulo {$path}) → " . ($rs['ok'] ? ($b === 'false' ? 'já normalizado' : 'normalizado') : 'FALHOU'); if (!$rs['ok']) $all_false = false; } return ['ok' => $all_false, 'log' => $log, 'normalized' => $all_false]; } /** * Bring submodules back in line with the SHAs pinned by the parent's index, * discarding ONLY drift (in practice: filesystem-mode flips). `--force` is the * point — it resets each submodule's working tree to the pinned commit. Because * the pinned content is identical to what's checked out, nothing real is lost; * the only thing thrown away is the phantom mode diff. `--init` also clones any * submodule freshly added upstream. * * A submodule declared `ignore = all` in .gitmodules follows its branch instead * (`--remote`) — see git_submodule_follows_branch(). * * Safe and quiet when there are no submodules. * * @return array{ok:bool,log:array} */ function git_sync_submodules(string $repo): array { $paths = git_submodule_paths($repo); if ($paths === []) { return ['ok' => true, 'log' => ['(sem submódulos)']]; } $log = []; $sync = git_run(['submodule', 'sync', '--recursive'], $repo); $log[] = 'git submodule sync --recursive → ' . ($sync['ok'] ? 'OK' : 'FALHOU'); if (trim($sync['output']) !== '') $log[] = $sync['output']; $ok = $sync['ok']; $pinned = $tracking = []; foreach ($paths as $path) { if (git_submodule_follows_branch($repo, $path)) $tracking[] = $path; else $pinned[] = $path; } // Um submódulo com `ignore = all` segue o seu branch, não o commit gravado // (é o que o .gitmodules da quack declara). Pô-lo no commit gravado era // fazer a plataforma andar para trás a cada atualização. foreach ([[$pinned, []], [$tracking, ['--remote']]] as [$group, $extra]) { if (!$group) continue; $args = array_merge(['submodule', 'update', '--init', '--recursive', '--force'], $extra, ['--'], $group); $upd = git_run($args, $repo); $log[] = 'git ' . implode(' ', $args) . ' → ' . ($upd['ok'] ? 'OK' : 'FALHOU'); if (trim($upd['output']) !== '') $log[] = $upd['output']; $ok = $ok && $upd['ok']; } return ['ok' => $ok, 'log' => $log]; } /** * O submódulo segue o branch (`update --remote`) em vez do commit gravado? Sim * quando o .gitmodules lhe põe `ignore = all`: o commit gravado deixa de ser * vigiado, portanto só pode ser um ponto de partida, não uma versão a cumprir. */ function git_submodule_follows_branch(string $repo, string $path): bool { $res = git_run(['config', '-f', '.gitmodules', '--get-regexp', '^submodule\..*\.path$'], $repo); if (!$res['ok']) return false; foreach (preg_split('/\R/', trim($res['output'])) as $line) { $parts = preg_split('/\s+/', trim($line), 2); if (count($parts) === 2 && $parts[1] === $path) { $name = substr($parts[0], strlen('submodule.'), -strlen('.path')); $ignore = git_run(['config', '-f', '.gitmodules', '--get', "submodule.$name.ignore"], $repo); return $ignore['ok'] && trim($ignore['output']) === 'all'; } } return false; } /** * True when a `git diff` contains ONLY executable-bit changes — i.e. every * changed file is just `old mode …` / `new mode …` with no content hunks, no * index lines, no rename/binary/submodule markers. An empty diff returns false * (the caller decides what "no diff" means in its context). */ function git_diff_is_mode_only(string $diff): bool { $diff = trim($diff); if ($diff === '') return false; foreach (preg_split('/\R/', $diff) as $line) { if ($line === '') continue; if (str_starts_with($line, 'diff --git ')) continue; if (str_starts_with($line, 'old mode ')) continue; if (str_starts_with($line, 'new mode ')) continue; // Anything else (index/@@/+/-/Subproject commit/Binary/rename) is real. return false; } return true; } /** * Snapshot how the local repo diverges from origin/, classifying the * divergence as either "safe to hard-reset" (only mode flips / submodule drift) * or "real work that must never be discarded". * * Call this AFTER git_harden_repo() + a fetch (and ideally after * git_sync_submodules()), so phantom mode diffs have already disappeared from * `git status` and a leftover dirty entry genuinely means real content. * * @return array{ * branch:string, ahead:int, behind:int, * content_dirty:bool, dirty_files:string, * real_ahead:bool, ahead_log:string, * safe_to_reset:bool * } */ function git_remote_branches(string $repo): array { $res = git_run(['for-each-ref', 'refs/remotes/origin', '--format=%(refname:short)'], $repo); if (!$res['ok']) return []; $branches = []; foreach (explode(PHP_EOL, trim((string) $res['output'])) as $line) { $line = trim($line); if ($line === '' || $line === 'origin/HEAD') continue; if (str_starts_with($line, 'origin/')) $line = substr($line, 7); $branches[] = $line; } return array_values(array_unique($branches)); } function git_local_state(string $repo, ?string $branch = null): array { if ($branch === null || $branch === '') { $branch = trim(git_run(['rev-parse', '--abbrev-ref', 'HEAD'], $repo)['output']); } $ref = 'origin/' . $branch; // Working-tree changes. Untracked files (??) are ignored: `reset --hard` // never deletes them, so they don't make a reset unsafe. $st = git_run(['status', '--porcelain'], $repo); $dirty_lines = []; $content_dirty = false; foreach (preg_split('/\R/', $st['output']) as $line) { if ($line === '') continue; $dirty_lines[] = $line; if (str_starts_with($line, '??')) continue; $content_dirty = true; } $ahead = (int) trim(git_run(['rev-list', '--count', $ref . '..HEAD'], $repo)['output']); $behind = (int) trim(git_run(['rev-list', '--count', 'HEAD..' . $ref], $repo)['output']); // Are the local-only commits real, or just committed mode flips? $real_ahead = false; $ahead_log = ''; if ($ahead > 0) { $ahead_log = git_run(['log', '--oneline', '--no-color', $ref . '..HEAD'], $repo)['output']; // Three-dot: what the local commits introduced since the merge-base. $diff = trim(git_run(['diff', '--no-color', $ref . '...HEAD'], $repo)['output']); $real_ahead = $diff !== '' && !git_diff_is_mode_only($diff); } return [ 'branch' => $branch, 'ahead' => $ahead, 'behind' => $behind, 'content_dirty' => $content_dirty, 'dirty_files' => implode("\n", $dirty_lines), 'real_ahead' => $real_ahead, 'ahead_log' => $ahead_log, 'safe_to_reset' => !$content_dirty && !$real_ahead, ]; } /** * The robust "Atualizar plataforma" git step. Non-destructive by default; only * discards state when the divergence is provably nothing but mode/submodule drift. * * 1. git_harden_repo() — kill phantom mode diffs first. * 2. git pull --ff-only — the happy path. * 3. on failure: fetch, realign submodules, then classify the local state. * · safe (mode/submodule only) → fetch + reset --hard origin/. * · real changes/commits → STOP, report the divergence, discard nothing. * 4. git submodule sync + update --init --recursive --force. * * @return array{ok:bool,needs_confirm:bool,output:string,state:?array} */ function git_safe_update(string $repo): array { $log = []; $branch = trim(git_run(['rev-parse', '--abbrev-ref', 'HEAD'], $repo)['output']); $log[] = '── Normalização de permissões (core.fileMode=false) ──'; foreach (git_harden_repo($repo)['log'] as $l) $log[] = $l; $log[] = ''; $log[] = '── git pull --ff-only ──'; $pull = git_run(['pull', '--ff-only'], $repo); $log[] = $pull['output'] !== '' ? $pull['output'] : '(sem saída)'; if ($pull['ok']) { $log[] = ''; $log[] = '── Submódulos ──'; foreach (git_sync_submodules($repo)['log'] as $l) $log[] = $l; return ['ok' => true, 'needs_confirm' => false, 'output' => implode("\n", $log), 'state' => null]; } // Fast-forward refused — diverged or blocked by local state. Fetch, realign // submodule working trees (so mode-drift inside them isn't mistaken for real // changes), then classify. $log[] = ''; $log[] = '── Fast-forward falhou — a analisar estado local ──'; $fetch = git_run(['fetch', 'origin', '--prune'], $repo); if (trim($fetch['output']) !== '') $log[] = $fetch['output']; foreach (git_sync_submodules($repo)['log'] as $l) $log[] = $l; $state = git_local_state($repo, $branch); if ($state['safe_to_reset']) { $log[] = ''; $log[] = "── Divergência apenas de modo/submódulo — alinhar com origin/{$branch} ──"; $reset = git_run(['reset', '--hard', 'origin/' . $branch], $repo); $log[] = trim($reset['output']) !== '' ? $reset['output'] : '(reset --hard concluído)'; if ($reset['ok']) { foreach (git_sync_submodules($repo)['log'] as $l) $log[] = $l; } return ['ok' => $reset['ok'], 'needs_confirm' => false, 'output' => implode("\n", $log), 'state' => $state]; } // Real local work — refuse to touch it. $log[] = ''; $log[] = '── ALTERAÇÕES LOCAIS REAIS — atualização interrompida (nada foi descartado) ──'; if ($state['content_dirty']) { $log[] = 'Alterações de conteúdo não-comitadas:'; $log[] = $state['dirty_files']; } if ($state['real_ahead']) { $log[] = "Commits locais por enviar (origin/{$branch}..HEAD):"; $log[] = $state['ahead_log']; } $log[] = ''; $log[] = 'Resolve manualmente (commit/push/stash) ou usa "Sincronização forçada"'; $log[] = 'com confirmação explícita para descartar o estado local e alinhar com o remoto.'; return ['ok' => false, 'needs_confirm' => true, 'output' => implode("\n", $log), 'state' => $state]; } /** * The explicit, user-confirmed destructive escape hatch: discard whatever is * local and force the working tree to match origin/. Only ever reached * from a UI action gated behind an explicit confirmation checkbox. * * @return array{ok:bool,output:string} */ function git_force_sync(string $repo): array { $log = []; $branch = trim(git_run(['rev-parse', '--abbrev-ref', 'HEAD'], $repo)['output']); foreach (git_harden_repo($repo)['log'] as $l) $log[] = $l; $fetch = git_run(['fetch', 'origin', '--prune'], $repo); $log[] = 'git fetch origin --prune → ' . ($fetch['ok'] ? 'OK' : 'FALHOU'); if (trim($fetch['output']) !== '') $log[] = $fetch['output']; $reset = git_run(['reset', '--hard', 'origin/' . $branch], $repo); $log[] = "git reset --hard origin/{$branch} → " . ($reset['ok'] ? 'OK' : 'FALHOU'); if (trim($reset['output']) !== '') $log[] = $reset['output']; if ($reset['ok']) { foreach (git_sync_submodules($repo)['log'] as $l) $log[] = $l; } return ['ok' => $reset['ok'], 'output' => implode("\n", $log)]; }