$files project-relative paths. * @param array $lastRunTree path → content hash from last run. * @return array */ public function filterUnchangedSinceLastRun(array $files, array $lastRunTree): array { if ($lastRunTree === []) { return $files; } $candidates = array_fill_keys($files, true); foreach (array_keys($lastRunTree) as $snapshotted) { $candidates[$snapshotted] = true; } $remaining = []; foreach (array_keys($candidates) as $file) { $snapshot = $lastRunTree[$file] ?? null; $current = $this->currentHash($file); if ($snapshot === null || $current === null || $current !== $snapshot) { $remaining[] = $file; } } return $remaining; } private function currentHash(string $relativePath): ?string { $absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$relativePath; if (! is_file($absolute)) { return null; } $hash = ContentHash::of($absolute); return $hash === false ? null : $hash; } /** * @param array $files * @return array path → xxh128 content hash */ public function snapshotTree(array $files): array { $out = []; foreach ($files as $file) { $absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file; if (! is_file($absolute)) { $out[$file] = ''; continue; } $hash = ContentHash::of($absolute); if ($hash !== false) { $out[$file] = $hash; } } return $out; } /** * @return array|null `null` when git is unavailable, or when */ public function since(?string $sha): ?array { $files = []; if ($sha !== null && $sha !== '') { if (! $this->shaIsReachable($sha)) { return null; } $files = array_merge($files, $this->diffSinceSha($sha)); } $files = array_merge($files, $this->workingTreeChanges()); $unique = []; foreach ($files as $file) { if ($file === '') { continue; } $unique[$file] = true; } $candidates = array_keys($this->filterIgnored($unique)); if ($sha !== null && $sha !== '') { return $this->filterBehaviourallyUnchanged($candidates, $sha); } return $candidates; } /** * @param array $files * @return array */ private function filterBehaviourallyUnchanged(array $files, string $sha): array { $remaining = []; foreach ($files as $file) { $currentHash = $this->currentHash($file); if ($currentHash === null) { $remaining[] = $file; continue; } $baselineContent = $this->contentAtSha($sha, $file); if ($baselineContent === null) { $remaining[] = $file; continue; } if ($currentHash !== ContentHash::ofContent($file, $baselineContent)) { $remaining[] = $file; } } return $remaining; } private function contentAtSha(string $sha, string $path): ?string { $process = new Process(['git', 'show', $sha.':'.$path], $this->projectRoot); $process->setTimeout(5.0); $process->run(); if (! $process->isSuccessful()) { return null; } return $process->getOutput(); } /** * @param array $candidates * @return array */ private function filterIgnored(array $candidates): array { if ($candidates === []) { return $candidates; } $process = new Process( ['git', 'check-ignore', '--no-index', '-z', '--stdin'], $this->projectRoot, ); $process->setTimeout(5.0); $process->setInput(implode("\x00", array_keys($candidates))); $process->run(); $exitCode = $process->getExitCode(); if ($exitCode !== 0 && $exitCode !== 1) { throw new MissingDependency('Tia mode', 'git'); } $output = $process->getOutput(); if ($output === '') { return $candidates; } foreach (explode("\x00", rtrim($output, "\x00")) as $ignored) { if ($ignored !== '') { unset($candidates[$ignored]); } } return $candidates; } public function currentBranch(): ?string { $process = new Process(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], $this->projectRoot); $process->run(); if (! $process->isSuccessful()) { throw new MissingDependency('Tia mode', 'git'); } $branch = trim($process->getOutput()); return $branch === '' || $branch === 'HEAD' ? null : $branch; } public function defaultBranch(): ?string { $head = $this->gitOutput(['git', 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD']); if ($head !== null) { $branch = preg_replace('#^origin/#', '', $head); if (is_string($branch) && $branch !== '') { return $branch; } } $configured = $this->gitOutput(['git', 'config', '--get', 'init.defaultBranch']); if ($configured === null) { return null; } $exists = $this->gitOutput(['git', 'rev-parse', '--verify', '--quiet', 'refs/heads/'.$configured]) !== null || $this->gitOutput(['git', 'rev-parse', '--verify', '--quiet', 'refs/remotes/origin/'.$configured]) !== null; return $exists ? $configured : null; } /** * Every branch name this checkout knows, local and remote alike. Remotes * count: a branch that only lives on the origin is still a branch someone * will check out, and its baseline must survive. * * @return list|null `null` when git cannot answer. */ public function branchNames(): ?array { $process = new Process( ['git', 'for-each-ref', '--format=%(refname)', 'refs/heads', 'refs/remotes'], $this->projectRoot, ); $process->setTimeout(5.0); $process->run(); if (! $process->isSuccessful()) { return null; } $names = []; foreach ($this->splitLines($process->getOutput()) as $ref) { if (str_starts_with($ref, 'refs/heads/')) { $names[substr($ref, strlen('refs/heads/'))] = true; continue; } if (! str_starts_with($ref, 'refs/remotes/')) { continue; } $tail = substr($ref, strlen('refs/remotes/')); $slash = strpos($tail, '/'); if ($slash === false) { continue; } $branch = substr($tail, $slash + 1); if ($branch !== '' && $branch !== 'HEAD') { $names[$branch] = true; } } return array_keys($names); } public function hasRemote(): bool { return $this->gitOutput(['git', 'remote']) !== null; } /** * @param array $command */ private function gitOutput(array $command): ?string { $process = new Process($command, $this->projectRoot); $process->setTimeout(5.0); $process->run(); if (! $process->isSuccessful()) { return null; } $output = trim($process->getOutput()); return $output === '' ? null : $output; } private function shaIsReachable(string $sha): bool { $process = new Process( ['git', 'merge-base', '--is-ancestor', $sha, 'HEAD'], $this->projectRoot, ); $process->run(); return $process->getExitCode() === 0; } /** * @return array */ private function diffSinceSha(string $sha): array { $process = new Process( ['git', 'diff', '--name-only', $sha.'..HEAD'], $this->projectRoot, ); $process->run(); if (! $process->isSuccessful()) { throw new MissingDependency('Tia mode', 'git'); } return $this->splitLines($process->getOutput()); } /** * @return array */ private function workingTreeChanges(): array { $process = new Process( ['git', 'status', '--porcelain', '-z', '--untracked-files=all'], $this->projectRoot, ); $process->run(); if (! $process->isSuccessful()) { throw new MissingDependency('Tia mode', 'git'); } $output = $process->getOutput(); if ($output === '') { return []; } $records = explode("\x00", rtrim($output, "\x00")); $files = []; $count = count($records); for ($i = 0; $i < $count; $i++) { $record = $records[$i]; if (strlen($record) < 4) { continue; } $status = substr($record, 0, 2); $path = substr($record, 3); if ($status[0] === 'R' || $status[0] === 'C') { $files[] = $path; if (isset($records[$i + 1]) && $records[$i + 1] !== '') { $files[] = $records[$i + 1]; $i++; } continue; } $files[] = $path; } return $files; } public function currentSha(): ?string { $process = new Process(['git', 'rev-parse', 'HEAD'], $this->projectRoot); $process->run(); if (! $process->isSuccessful()) { throw new MissingDependency('Tia mode', 'git'); } $sha = trim($process->getOutput()); return $sha === '' ? null : $sha; } /** * @return array */ private function splitLines(string $output): array { $lines = preg_split('/\R+/', trim($output), flags: PREG_SPLIT_NO_EMPTY); return $lines === false ? [] : $lines; } }