From 1272ed8719d3c2127586363b443663106161ad51 Mon Sep 17 00:00:00 2001 From: Johannes Hoppe Date: Fri, 9 Oct 2026 23:30:49 +0200 Subject: [PATCH 01/17] chore: load the vitest config as ESM Vitest 5 warns about ESM syntax in a config file loaded as CommonJS. --- src/engine/engine.gh-pages-behavior.spec.ts | 1182 ----------------- src/engine/engine.gh-pages-clean.spec.ts | 59 - src/engine/engine.gh-pages-cleanup.spec.ts | 246 ---- .../engine.gh-pages-filecreation.spec.ts | 231 ---- .../engine.gh-pages-integration.spec.ts | 632 --------- .../engine.gh-pages-no-package-json.spec.ts | 159 --- src/engine/gh-pages.d.ts | 13 - src/tsconfig.build.json | 2 +- src/{vitest.config.ts => vitest.config.mts} | 4 +- 9 files changed, 3 insertions(+), 2525 deletions(-) delete mode 100644 src/engine/engine.gh-pages-behavior.spec.ts delete mode 100644 src/engine/engine.gh-pages-clean.spec.ts delete mode 100644 src/engine/engine.gh-pages-cleanup.spec.ts delete mode 100644 src/engine/engine.gh-pages-filecreation.spec.ts delete mode 100644 src/engine/engine.gh-pages-integration.spec.ts delete mode 100644 src/engine/engine.gh-pages-no-package-json.spec.ts delete mode 100644 src/engine/gh-pages.d.ts rename src/{vitest.config.ts => vitest.config.mts} (51%) diff --git a/src/engine/engine.gh-pages-behavior.spec.ts b/src/engine/engine.gh-pages-behavior.spec.ts deleted file mode 100644 index 8f72014..0000000 --- a/src/engine/engine.gh-pages-behavior.spec.ts +++ /dev/null @@ -1,1182 +0,0 @@ -/** - * Behavioral snapshot tests for gh-pages v6.3.0 - * - * These tests capture the EXACT internal behavior of gh-pages.publish(). - * Focus: Git commands executed in correct order with correct arguments. - * - * Purpose: When upgrading gh-pages, these tests will break if behavior changes. - * This ensures we maintain exact compatibility for our users. - * - * Approach: Mock child_process and gh-pages/lib/util, use real filesystem - */ - -import { ChildProcess } from 'child_process'; - -import * as engine from './engine'; -import { cleanupMonkeypatch } from './engine.prepare-options-helpers'; -import { logging } from '@angular-devkit/core'; - -const path = require('path'); -const fs = require('fs/promises'); -const os = require('os'); -const { EventEmitter } = require('events'); - -// Mock utils.pathExists so engine.run() passes the dist folder check -vi.mock('../utils', async () => { - const actual = await vi.importActual('../utils'); - return { - ...actual, - pathExists: vi.fn().mockResolvedValue(true) - }; -}); - -// Types for better type safety -interface SpawnCall { - cmd: string; - args: string[]; - options: unknown; -} - -interface TestContext { - repo?: string; -} - -// Mock child_process to capture all git commands -let spawnCalls: SpawnCall[] = []; - -// Track current test context for deterministic mock behavior -let currentTestContext: TestContext = {}; - -// Factory function to create mock child process compatible with gh-pages expectations -// gh-pages lib/git.js expects: child.stdout.on, child.stderr.on, child.on('close') -function createMockChildProcess(): Partial { - const child: Partial = new EventEmitter(); - child.stdout = new EventEmitter() as unknown as ChildProcess['stdout']; - child.stderr = new EventEmitter() as unknown as ChildProcess['stderr']; - return child; -} - -/** - * Whitelist of expected git commands from gh-pages v6.3.0 - * - * Strict whitelist: If gh-pages changes git subcommands in future versions, - * this array must be updated first and tests will fail loudly. - * This is intentional - we want to know about any new git operations. - */ -const EXPECTED_GIT_COMMANDS = [ - 'clone', 'clean', 'fetch', 'checkout', 'ls-remote', 'ls-files', 'reset', - 'rm', 'add', 'config', 'diff-index', 'commit', 'tag', 'push', 'update-ref' -]; - -const { mockSpawn, mockCopy, mockGetUser } = vi.hoisted(() => { - const mockCopy = vi.fn(() => Promise.resolve()); - const mockGetUser = vi.fn(() => Promise.resolve(null)); - const mockSpawn = vi.fn(); - return { mockSpawn, mockCopy, mockGetUser }; -}); - -vi.mock('child_process', () => ({ - spawn: mockSpawn -})); - -vi.mock('gh-pages/lib/util', () => ({ - copy: mockCopy, - getUser: mockGetUser -})); - -// Configure mockSpawn with full implementation (after hoisted declaration) -mockSpawn.mockImplementation((cmd: string, args: string[] | undefined, opts: unknown) => { - const capturedArgs = args || []; - spawnCalls.push({ cmd, args: capturedArgs, options: opts }); - - // Validate git commands are expected - if (cmd === 'git' && capturedArgs[0]) { - if (!EXPECTED_GIT_COMMANDS.includes(capturedArgs[0])) { - throw new Error(`Unexpected git command: ${capturedArgs[0]}. Add to whitelist if intentional.`); - } - } - - const mockChild = createMockChildProcess(); - - // Simulate appropriate response based on git command - setImmediate(() => { - let output = ''; - - // git config --get remote.X.url should return the repo URL - if (cmd === 'git' && capturedArgs[0] === 'config' && capturedArgs[1] === '--get' && - capturedArgs[2] && capturedArgs[2].startsWith('remote.')) { - // Use tracked repo URL for deterministic behavior - output = currentTestContext.repo || ''; - } - // ls-remote should return something for branch existence check - else if (cmd === 'git' && capturedArgs[0] === 'ls-remote') { - output = 'refs/heads/gh-pages'; - } - // diff-index for checking if commit needed (exit 1 means changes exist) - else if (cmd === 'git' && capturedArgs[0] === 'diff-index') { - // Return exit code 1 to indicate changes exist - mockChild.emit!('close', 1); - return; - } - - mockChild.stdout!.emit('data', Buffer.from(output)); - mockChild.emit!('close', 0); - }); - - return mockChild; -}); - -// Require gh-pages after mocking -const ghPages = require('gh-pages'); - -// Helper to avoid duplicate error handling -function publishAndHandle( - basePath: string, - options: unknown, - done: ((err?: unknown) => void), - assertions: () => void -): void { - ghPages.publish(basePath, options, (err: Error | null) => { - if (err) { - done(err); - return; - } - try { - assertions(); - done(); - } catch (assertionError) { - done(assertionError); - } - }); -} - -describe('gh-pages v6.3.0 - behavioral snapshot', () => { - let tempDir: string; - let basePath: string; - - beforeAll(async () => { - // Create a real temp directory with test files - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gh-pages-test-')); - basePath = path.join(tempDir, 'dist'); - await fs.mkdir(basePath, { recursive: true }); - - // Create test files (including dotfiles for testing dotfiles option) - await fs.writeFile(path.join(basePath, 'index.html'), 'test'); - await fs.writeFile(path.join(basePath, 'main.js'), 'console.log("test");'); - await fs.writeFile(path.join(basePath, 'styles.css'), 'body { }'); - await fs.writeFile(path.join(basePath, '.htaccess'), 'RewriteEngine On'); - }); - - afterAll(async () => { - // Clean up temp directory - // force: true matches fse.remove() behavior (no error if path doesn't exist) - await fs.rm(tempDir, { recursive: true, force: true }); - }); - - beforeEach(() => { - // Note: We DON'T call ghPages.clean() here because it interferes with test isolation - // Each test uses a unique repo URL which naturally isolates cache directories - - // Clear all mock calls and context - spawnCalls = []; - currentTestContext = {}; - mockSpawn.mockClear(); - mockCopy.mockClear(); - mockGetUser.mockClear(); - }); - - describe('Git command execution order', () => { - it('should execute critical git commands in sequence', (done) => { - const repo = 'https://github.com/test/order-test.git'; - const branch = 'gh-pages'; - currentTestContext.repo = repo; - - const options = { repo, branch, message: 'Deploy' }; - - publishAndHandle(basePath, options, done, () => { - // Extract git commands in order - const gitCommands = spawnCalls - .filter(call => call.cmd === 'git') - .map(call => call.args[0]); - - // Verify critical commands appear - expect(gitCommands).toContain('clone'); - expect(gitCommands).toContain('add'); - expect(gitCommands).toContain('commit'); - expect(gitCommands).toContain('push'); - - // Verify critical ordering constraints (these MUST be in order) - const cloneIndex = gitCommands.indexOf('clone'); - const addIndex = gitCommands.indexOf('add'); - const commitIndex = gitCommands.indexOf('commit'); - const pushIndex = gitCommands.indexOf('push'); - - expect(cloneIndex).toBeGreaterThan(-1); - expect(addIndex).toBeGreaterThan(cloneIndex); // add after clone - expect(commitIndex).toBeGreaterThan(addIndex); // commit after add - expect(pushIndex).toBeGreaterThan(commitIndex); // push after commit - }); - }); - - it('should execute ls-remote before checkout', (done) => { - const repo = 'https://github.com/test/ls-remote-test.git'; - currentTestContext.repo = repo; - const options = { repo, message: 'Deploy' }; - - publishAndHandle(basePath, options, done, () => { - const gitCommands = spawnCalls - .filter(call => call.cmd === 'git') - .map(call => call.args[0]); - - const lsRemoteIndex = gitCommands.indexOf('ls-remote'); - const checkoutIndex = gitCommands.indexOf('checkout'); - - // ls-remote checks if branch exists before checkout - expect(lsRemoteIndex).toBeGreaterThan(-1); - expect(checkoutIndex).toBeGreaterThan(-1); - expect(lsRemoteIndex).toBeLessThan(checkoutIndex); - }); - }); - }); - - describe('Git clone command', () => { - it('should clone with exact repository URL', (done) => { - const repo = 'https://github.com/angular-schule/test-repo.git'; - const branch = 'production'; - currentTestContext.repo = repo; - const options = { repo, branch, message: 'Deploy' }; - - publishAndHandle(basePath, options, done, () => { - const cloneCall = spawnCalls.find(call => - call.cmd === 'git' && call.args[0] === 'clone' - ); - - expect(cloneCall).toBeDefined(); - expect(cloneCall?.args).toContain(repo); - expect(cloneCall?.args).toContain('--branch'); - expect(cloneCall?.args).toContain(branch); - expect(cloneCall?.args).toContain('--single-branch'); - }); - }); - - /** - * Git clone depth controls shallow clone behavior. - * - * What is depth? - * - `--depth N` limits git clone history to N commits - * - gh-pages defaults to depth=1 (only latest commit, optimal for deployments) - * - Smaller depth = faster clones, less disk space, faster CI/CD - * - * Why angular-cli-ghpages does NOT expose this option: - * - We're deploying build artifacts (compiled Angular app) - * - We only care about the latest built version - * - History is in the gh-pages branch itself - * - depth=1 is perfect - no benefit to cloning more history - * - * What we're testing: - * - That gh-pages uses its default depth=1 - * - Exact argument position and value - * - This ensures optimal performance for all deployments - */ - it('should use default depth=1 for optimal performance', (done) => { - const repo = 'https://github.com/test/depth-test.git'; - const branch = 'gh-pages'; - currentTestContext.repo = repo; - - // Don't specify depth - rely on gh-pages default - const options = { repo, branch, message: 'Deploy' }; - - publishAndHandle(basePath, options, done, () => { - const cloneCall = spawnCalls.find(call => - call.cmd === 'git' && call.args[0] === 'clone' - ); - expect(cloneCall).toBeDefined(); - - // Find --depth flag and verify next argument is exactly 1 - const depthFlagIndex = cloneCall?.args.indexOf('--depth'); - expect(depthFlagIndex).toBeGreaterThan(-1); - expect(cloneCall?.args[depthFlagIndex! + 1]).toBe(1); - }); - }); - }); - - describe('Git add and commit', () => { - it('should add all files with dot notation', (done) => { - const repo = 'https://github.com/test/add-test.git'; - currentTestContext.repo = repo; - const options = { repo, message: 'Deploy' }; - - publishAndHandle(basePath, options, done, () => { - const addCall = spawnCalls.find(call => - call.cmd === 'git' && call.args[0] === 'add' - ); - - expect(addCall).toBeDefined(); - expect(addCall?.args[1]).toBe('.'); // Exact position check - }); - }); - - it('should commit with exact message provided', (done) => { - const repo = 'https://github.com/test/commit-test.git'; - const message = 'Custom deployment message with special chars: Γ©mojis πŸš€'; - currentTestContext.repo = repo; - const options = { repo, message }; - - publishAndHandle(basePath, options, done, () => { - const commitCall = spawnCalls.find(call => - call.cmd === 'git' && call.args[0] === 'commit' - ); - - expect(commitCall).toBeDefined(); - expect(commitCall?.args).toContain('-m'); - const messageIndex = commitCall?.args.indexOf('-m'); - expect(commitCall?.args[messageIndex! + 1]).toBe(message); - }); - }); - }); - - /** - * Git push command - * - * What does angular-cli-ghpages do? - * - We DO NOT pass the 'history' option to gh-pages - * - gh-pages defaults to history: true (normal push, no --force) - * - We rely on this default behavior - * - * What we're testing: - * - Push command includes correct remote and branch - * - Push ALWAYS includes --tags flag (gh-pages behavior) - * - NO --force flag (because we don't pass history: false) - */ - describe('Git push command', () => { - it('should push to correct remote and branch with --tags flag', (done) => { - const repo = 'https://github.com/test/push-test.git'; - const branch = 'gh-pages'; - const remote = 'upstream'; - currentTestContext.repo = repo; - const options = { repo, branch, remote, message: 'Deploy' }; - - publishAndHandle(basePath, options, done, () => { - const pushCall = spawnCalls.find(call => - call.cmd === 'git' && call.args[0] === 'push' - ); - - expect(pushCall).toBeDefined(); - expect(pushCall?.args).toContain(remote); - expect(pushCall?.args).toContain(branch); - // gh-pages ALWAYS includes --tags in push (lib/git.js line 189) - expect(pushCall?.args).toContain('--tags'); - }); - }); - - it('should NOT use force push (we rely on gh-pages default history: true)', (done) => { - const repo = 'https://github.com/test/no-force-test.git'; - currentTestContext.repo = repo; - const options = { repo, message: 'Deploy' }; - - publishAndHandle(basePath, options, done, () => { - const pushCall = spawnCalls.find(call => - call.cmd === 'git' && call.args[0] === 'push' - ); - - expect(pushCall).toBeDefined(); - // We don't pass history: false, so gh-pages defaults to history: true (no --force) - expect(pushCall?.args).not.toContain('--force'); - }); - }); - }); - - describe('File copy operation', () => { - it('should copy exact test files from basePath to cache destination', (done) => { - const repo = 'https://github.com/test/copy-test.git'; - currentTestContext.repo = repo; - const options = { repo, message: 'Deploy' }; - - publishAndHandle(basePath, options, done, () => { - expect(mockCopy).toHaveBeenCalledTimes(1); - - const callArgs = mockCopy.mock.calls[0]; - expect(callArgs).toBeDefined(); - expect(callArgs.length).toBe(3); - - const [files, source, destination] = callArgs as unknown as [string[], string, string]; - - // Verify files array contains EXACTLY our test files - expect(Array.isArray(files)).toBe(true); - expect(files.length).toBe(3); - expect(files).toContain('index.html'); - expect(files).toContain('main.js'); - expect(files).toContain('styles.css'); - - // Verify source is our basePath - expect(source).toBe(basePath); - - // Verify destination is in gh-pages cache - expect(destination).toContain('gh-pages'); - }); - }); - }); - - /** - * Git rm behavior with add option - * - * What is the add option? - * - add: false (default) - Removes existing files before deploying new ones (clean slate) - * - add: true - Adds files without removing existing ones (incremental deployment) - * - * Why this matters: - * - add: false ensures no stale files remain (recommended for most cases) - * - add: true useful for multi-source deployments or preserving manually added files - * - * Critical behavior from gh-pages source (lib/index.js lines 156-170): - * if (options.add) { - * return git; // SKIP removal logic entirely - * } - * // Otherwise: execute globby to find files, then git rm if files exist - * - * Testing strategy: - * - We can't directly test git rm is called because it depends on existing files in cache - * - Instead, we test command ORDERING which differs between add: true and add: false - * - With add: false, checkout happens BEFORE add (removal step in between, even if no files) - * - With add: true, checkout happens BEFORE add (no removal step at all) - * - We also verify that add: true NEVER calls git rm (critical) - */ - describe('Git rm command behavior', () => { - it('should maintain standard command order with add: false (default)', (done) => { - const repo = 'https://github.com/test/rm-default-test.git'; - currentTestContext.repo = repo; - const options = { repo, message: 'Deploy', add: false }; - - publishAndHandle(basePath, options, done, () => { - const gitCommands = spawnCalls - .filter(call => call.cmd === 'git') - .map(call => call.args[0]); - - // Verify standard flow: checkout β†’ (removal logic) β†’ add β†’ commit - const checkoutIndex = gitCommands.indexOf('checkout'); - const addIndex = gitCommands.indexOf('add'); - const commitIndex = gitCommands.indexOf('commit'); - - expect(checkoutIndex).toBeGreaterThan(-1); - expect(addIndex).toBeGreaterThan(checkoutIndex); - expect(commitIndex).toBeGreaterThan(addIndex); - - // Note: git rm may or may not be called depending on existing files - // The important thing is the removal logic is ATTEMPTED (not skipped) - }); - }); - - it('should NEVER execute git rm when add: true (skips removal entirely)', (done) => { - const repo = 'https://github.com/test/rm-add-test.git'; - currentTestContext.repo = repo; - const options = { repo, message: 'Deploy', add: true }; - - publishAndHandle(basePath, options, done, () => { - const rmCall = spawnCalls.find(call => - call.cmd === 'git' && call.args[0] === 'rm' - ); - - // CRITICAL: git rm MUST NEVER be called when add: true - // This verifies the removal logic is completely skipped - expect(rmCall).toBeUndefined(); - }); - }); - }); - - /** - * Dotfiles option - * - * What does angular-cli-ghpages do? - * - We PASS dotfiles option to gh-pages (engine.ts line 242) - * - Default in angular-cli-ghpages: dotfiles: true (include dotfiles) - * - User can disable with --no-dotfiles flag - * - * How gh-pages uses it: - * - Controls globby pattern matching for files to copy - * - dotfiles: true β†’ includes files starting with '.' (like .htaccess) - * - dotfiles: false β†’ ignores files starting with '.' - * - * Why we test this: - * - CRITICAL: We expose this option and pass it to gh-pages - * - Must verify it affects file selection behavior - * - * Test fixture has these files: - * - index.html (normal file) - * - main.js (normal file) - * - styles.css (normal file) - * - .htaccess (dotfile) - */ - describe('Dotfiles option', () => { - it('should include dotfiles when dotfiles: true (our default)', (done) => { - const repo = 'https://github.com/test/dotfiles-true.git'; - currentTestContext.repo = repo; - const options = { repo, message: 'Deploy', dotfiles: true }; - - publishAndHandle(basePath, options, done, () => { - // gh-pages uses globby with { dot: options.dotfiles } (lib/index.js line 90) - expect(mockCopy).toHaveBeenCalledTimes(1); - - const callArgs = mockCopy.mock.calls[0]; - expect(callArgs).toBeDefined(); - expect(callArgs.length).toBe(3); - - const [files, source, destination] = callArgs as unknown as [string[], string, string]; - - // CRITICAL: Verify files array INCLUDES the .htaccess dotfile - expect(Array.isArray(files)).toBe(true); - expect(files.length).toBe(4); // index.html, main.js, styles.css, .htaccess - expect(files).toContain('index.html'); - expect(files).toContain('main.js'); - expect(files).toContain('styles.css'); - expect(files).toContain('.htaccess'); // This is the dotfile - - expect(source).toBe(basePath); - expect(destination).toContain('gh-pages'); - }); - }); - - it('should exclude dotfiles when dotfiles: false', (done) => { - const repo = 'https://github.com/test/dotfiles-false.git'; - currentTestContext.repo = repo; - const options = { repo, message: 'Deploy', dotfiles: false }; - - publishAndHandle(basePath, options, done, () => { - expect(mockCopy).toHaveBeenCalledTimes(1); - - const callArgs = mockCopy.mock.calls[0]; - expect(callArgs).toBeDefined(); - expect(callArgs.length).toBe(3); - - const [files, source, destination] = callArgs as unknown as [string[], string, string]; - - // CRITICAL: Verify files array EXCLUDES the .htaccess dotfile - expect(Array.isArray(files)).toBe(true); - expect(files.length).toBe(3); // Only index.html, main.js, styles.css - expect(files).toContain('index.html'); - expect(files).toContain('main.js'); - expect(files).toContain('styles.css'); - expect(files).not.toContain('.htaccess'); // Dotfile must be excluded - - expect(source).toBe(basePath); - expect(destination).toContain('gh-pages'); - }); - }); - }); - - /** - * Git executable option - * - * What does angular-cli-ghpages do? - * - We PASS git option to gh-pages (engine.ts line 240) - * - Default: 'git' (relies on PATH) - * - gh-pages defaults.git = 'git' (lib/index.js line 29) - * - * Why we test this: - * - We expose this option through defaults.ts - * - Must verify the option flows through to gh-pages - * - * Testing approach: - * - We pass git: 'git' (our default) - * - Verify git commands are executed - * - This confirms the option is accepted by gh-pages - */ - describe('Git executable option', () => { - it('should accept git executable option (we pass our default "git")', (done) => { - const repo = 'https://github.com/test/git-exe-test.git'; - const gitExecutable = 'git'; // Our default from defaults.ts line 15 - currentTestContext.repo = repo; - const options = { repo, message: 'Deploy', git: gitExecutable }; - - publishAndHandle(basePath, options, done, () => { - // Find all git commands - const gitCalls = spawnCalls.filter(call => call.cmd === 'git'); - - // CRITICAL: Verify git commands were executed - // This confirms gh-pages accepted our git option - expect(gitCalls.length).toBeGreaterThan(0); - - // Verify critical commands were executed with the git executable - expect(gitCalls.some(call => call.args[0] === 'clone')).toBe(true); - expect(gitCalls.some(call => call.args[0] === 'add')).toBe(true); - expect(gitCalls.some(call => call.args[0] === 'commit')).toBe(true); - }); - }); - }); - - describe('User credentials', () => { - it('should configure git user.email with exact value', (done) => { - const repo = 'https://github.com/test/user-test.git'; - const name = 'Deploy Bot'; - const email = 'bot@example.com'; - currentTestContext.repo = repo; - const options = { repo, message: 'Deploy', user: { name, email } }; - - publishAndHandle(basePath, options, done, () => { - const configEmailCall = spawnCalls.find(call => - call.cmd === 'git' && - call.args[0] === 'config' && - call.args[1] === 'user.email' - ); - - expect(configEmailCall).toBeDefined(); - expect(configEmailCall?.args[2]).toBe(email); // Exact position - }); - }); - - it('should configure git user.name with exact value', (done) => { - const repo = 'https://github.com/test/username-test.git'; - const name = 'Deploy Bot'; - const email = 'bot@example.com'; - currentTestContext.repo = repo; - const options = { repo, message: 'Deploy', user: { name, email } }; - - publishAndHandle(basePath, options, done, () => { - const configNameCall = spawnCalls.find(call => - call.cmd === 'git' && - call.args[0] === 'config' && - call.args[1] === 'user.name' - ); - - expect(configNameCall).toBeDefined(); - expect(configNameCall?.args[2]).toBe(name); // Exact position - }); - }); - }); - - /** - * Git tag command - Verify we DON'T use tagging - * - * What does angular-cli-ghpages do? - * - We DO NOT pass the 'tag' option to gh-pages - * - We DO NOT expose tag option in schema.json - * - Therefore, no git tags should ever be created - * - * Why test this? - * - Ensures our deployments don't accidentally create tags - * - If gh-pages changes defaults, we'll catch it - */ - describe('Git tag command', () => { - it('should NEVER create git tags (we do not use tag option)', (done) => { - const repo = 'https://github.com/test/no-tag-test.git'; - currentTestContext.repo = repo; - const options = { repo, message: 'Deploy' }; - - publishAndHandle(basePath, options, done, () => { - const tagCall = spawnCalls.find(call => - call.cmd === 'git' && call.args[0] === 'tag' - ); - - // CRITICAL: We should NEVER create tags - expect(tagCall).toBeUndefined(); - }); - }); - }); - - /** - * gh-pages default options we DON'T pass - * - * These tests verify gh-pages default behavior for options we don't pass. - * This ensures we understand what gh-pages is doing on our behalf. - * - * Options we DON'T pass (gh-pages uses defaults): - * - push: true (default) - Always pushes to remote - * - dest: '.' (default) - Deploys to root of branch - * - src: '**\/*' (default) - Includes all files - * - remove: '.' (default) - Removes existing files (unless add: true) - * - silent: false (default) - gh-pages logs output (we used to have noSilent but it's DEPRECATED) - * - * Note on 'silent' option: - * - We have 'noSilent' in schema.json but it's DEPRECATED and IGNORED - * - We do NOT pass 'silent' to gh-pages - * - gh-pages defaults to silent: false (logging enabled) - * - This is what we want: verbose logging for deployments - */ - describe('gh-pages default options (we do not pass these)', () => { - it('should push to remote (gh-pages default push: true)', (done) => { - const repo = 'https://github.com/test/default-push-test.git'; - currentTestContext.repo = repo; - const options = { repo, message: 'Deploy' }; - // We DON'T pass push option, gh-pages defaults to push: true - - publishAndHandle(basePath, options, done, () => { - const pushCall = spawnCalls.find(call => - call.cmd === 'git' && call.args[0] === 'push' - ); - - // CRITICAL: Push MUST be called (gh-pages default: push: true) - expect(pushCall).toBeDefined(); - }); - }); - - it('should use root destination (gh-pages default dest: ".")', (done) => { - const repo = 'https://github.com/test/default-dest-test.git'; - currentTestContext.repo = repo; - const options = { repo, message: 'Deploy' }; - // We DON'T pass dest option, gh-pages defaults to dest: '.' - - publishAndHandle(basePath, options, done, () => { - // Copy destination should be at root of cache (not in subdirectory) - expect(mockCopy).toHaveBeenCalledTimes(1); - - const callArgs = mockCopy.mock.calls[0]; - const [files, source, destination] = callArgs as unknown as [string[], string, string]; - - // Destination should be in gh-pages cache, not in a subdirectory - expect(destination).toContain('gh-pages'); - // Should NOT contain additional path segments beyond cache dir - // (dest: '.' means deploy to root of branch) - }); - }); - - it('should include all files (gh-pages default src: "**/*")', (done) => { - const repo = 'https://github.com/test/default-src-test.git'; - currentTestContext.repo = repo; - const options = { repo, message: 'Deploy' }; - // We DON'T pass src option, gh-pages defaults to src: '**/*' - - publishAndHandle(basePath, options, done, () => { - expect(mockCopy).toHaveBeenCalledTimes(1); - - const callArgs = mockCopy.mock.calls[0]; - const [files] = callArgs as unknown as [string[], string, string]; - - // All our test files should be included (src: '**/*' means all files) - expect(files.length).toBe(3); - expect(files).toContain('index.html'); - expect(files).toContain('main.js'); - expect(files).toContain('styles.css'); - }); - }); - - it('should use default remove pattern (gh-pages default remove: ".")', (done) => { - const repo = 'https://github.com/test/default-remove-test.git'; - currentTestContext.repo = repo; - const options = { repo, message: 'Deploy', add: false }; - // We DON'T pass remove option, gh-pages defaults to remove: '.' - // With add: false, gh-pages will attempt to remove files matching '.' - - publishAndHandle(basePath, options, done, () => { - // Since it's a fresh clone, there may be no files to remove - // But we verify the removal logic is executed (not skipped like with add: true) - const gitCommands = spawnCalls - .filter(call => call.cmd === 'git') - .map(call => call.args[0]); - - // Standard flow includes removal logic (even if no files to remove) - expect(gitCommands).toContain('checkout'); - expect(gitCommands).toContain('add'); - }); - }); - }); - - /** - * Error scenarios - * - * gh-pages has robust error handling. We test critical failure paths: - * 1. Git clone failure and retry (branch doesn't exist) - * 2. No changes to commit (diff-index returns 0) - * 3. Git commit when no user configured (uses getUser) - * - * Why test error scenarios: - * - Ensures deployments don't fail silently - * - Verifies retry/fallback mechanisms work - * - Documents expected error recovery behavior - */ - describe('Error scenarios', () => { - it('should retry git clone without branch/depth options on failure', (done) => { - const repo = 'https://github.com/test/clone-failure-test.git'; - currentTestContext.repo = repo; - const branch = 'new-branch'; - const options = { repo, branch, message: 'Deploy' }; - - // Reset mock to implement failure behavior - mockSpawn.mockClear(); - - let cloneAttempts = 0; - mockSpawn.mockImplementation((cmd: string, args: string[] | undefined, opts: unknown) => { - const capturedArgs = args || []; - spawnCalls.push({ cmd, args: capturedArgs, options: opts }); - - // Validate git commands are expected - if (cmd === 'git' && capturedArgs[0]) { - if (!EXPECTED_GIT_COMMANDS.includes(capturedArgs[0])) { - throw new Error(`Unexpected git command: ${capturedArgs[0]}`); - } - } - - const mockChild = createMockChildProcess(); - - setImmediate(() => { - // First clone attempt with --branch and --depth should FAIL - if (cmd === 'git' && capturedArgs[0] === 'clone' && cloneAttempts === 0) { - cloneAttempts++; - mockChild.stderr!.emit('data', Buffer.from('fatal: Remote branch new-branch not found')); - mockChild.emit!('close', 128); // Git error code - return; - } - - // Second clone attempt without --branch/--depth should SUCCEED - if (cmd === 'git' && capturedArgs[0] === 'clone' && cloneAttempts === 1) { - cloneAttempts++; - mockChild.stdout!.emit('data', Buffer.from('')); - mockChild.emit!('close', 0); - return; - } - - // All other commands succeed normally - let output = ''; - if (cmd === 'git' && capturedArgs[0] === 'config' && capturedArgs[1] === '--get') { - output = repo; - } else if (cmd === 'git' && capturedArgs[0] === 'ls-remote') { - output = 'refs/heads/gh-pages'; - } else if (cmd === 'git' && capturedArgs[0] === 'diff-index') { - mockChild.emit!('close', 1); - return; - } - - mockChild.stdout!.emit('data', Buffer.from(output)); - mockChild.emit!('close', 0); - }); - - return mockChild; - }); - - ghPages.publish(basePath, options, (err: Error | null) => { - if (err) { - done(err); - return; - } - - try { - const cloneCalls = spawnCalls.filter(call => - call.cmd === 'git' && call.args[0] === 'clone' - ); - - // CRITICAL: Must have exactly 2 clone attempts - expect(cloneCalls.length).toBe(2); - - // First attempt: with --branch and --depth - expect(cloneCalls[0].args).toContain('--branch'); - expect(cloneCalls[0].args).toContain(branch); - expect(cloneCalls[0].args).toContain('--depth'); - - // Second attempt: WITHOUT --branch and --depth (fallback) - expect(cloneCalls[1].args).not.toContain('--branch'); - expect(cloneCalls[1].args).not.toContain('--depth'); - expect(cloneCalls[1].args).toContain(repo); - - done(); - } catch (assertionError) { - done(assertionError); - } - }); - }); - - it('should NOT commit when no changes exist (diff-index returns 0)', (done) => { - const repo = 'https://github.com/test/no-changes-test.git'; - currentTestContext.repo = repo; - const options = { repo, message: 'Deploy' }; - - // Reset mock to implement no-changes behavior - mockSpawn.mockClear(); - spawnCalls = []; - - mockSpawn.mockImplementation((cmd: string, args: string[] | undefined, opts: unknown) => { - const capturedArgs = args || []; - spawnCalls.push({ cmd, args: capturedArgs, options: opts }); - - if (cmd === 'git' && capturedArgs[0]) { - if (!EXPECTED_GIT_COMMANDS.includes(capturedArgs[0])) { - throw new Error(`Unexpected git command: ${capturedArgs[0]}`); - } - } - - const mockChild = createMockChildProcess(); - - setImmediate(() => { - let output = ''; - - if (cmd === 'git' && capturedArgs[0] === 'config' && capturedArgs[1] === '--get') { - output = repo; - } else if (cmd === 'git' && capturedArgs[0] === 'ls-remote') { - output = 'refs/heads/gh-pages'; - } else if (cmd === 'git' && capturedArgs[0] === 'diff-index') { - // Return 0 = no changes (opposite of our normal mock) - mockChild.emit!('close', 0); - return; - } - - mockChild.stdout!.emit('data', Buffer.from(output)); - mockChild.emit!('close', 0); - }); - - return mockChild; - }); - - ghPages.publish(basePath, options, (err: Error | null) => { - if (err) { - done(err); - return; - } - - try { - const commitCall = spawnCalls.find(call => - call.cmd === 'git' && call.args[0] === 'commit' - ); - - // CRITICAL: Commit should NOT be called when no changes - // gh-pages uses: git diff-index --quiet HEAD || git commit -m "message" - expect(commitCall).toBeUndefined(); - - // But push should still be called (even with no new commits) - const pushCall = spawnCalls.find(call => - call.cmd === 'git' && call.args[0] === 'push' - ); - expect(pushCall).toBeDefined(); - - done(); - } catch (assertionError) { - done(assertionError); - } - }); - }); - - it('should handle deployment without user credentials (uses getUser)', (done) => { - const repo = 'https://github.com/test/no-user-test.git'; - currentTestContext.repo = repo; - const options = { repo, message: 'Deploy' }; - // DON'T pass user option - gh-pages will call getUser() - - // Reset mock to normal behavior (previous test modified it) - mockSpawn.mockClear(); - spawnCalls = []; - - mockSpawn.mockImplementation((cmd: string, args: string[] | undefined, opts: unknown) => { - const capturedArgs = args || []; - spawnCalls.push({ cmd, args: capturedArgs, options: opts }); - - if (cmd === 'git' && capturedArgs[0]) { - if (!EXPECTED_GIT_COMMANDS.includes(capturedArgs[0])) { - throw new Error(`Unexpected git command: ${capturedArgs[0]}`); - } - } - - const mockChild = createMockChildProcess(); - - setImmediate(() => { - let output = ''; - - if (cmd === 'git' && capturedArgs[0] === 'config' && capturedArgs[1] === '--get' && - capturedArgs[2] && capturedArgs[2].startsWith('remote.')) { - output = currentTestContext.repo || ''; - } else if (cmd === 'git' && capturedArgs[0] === 'ls-remote') { - output = 'refs/heads/gh-pages'; - } else if (cmd === 'git' && capturedArgs[0] === 'diff-index') { - mockChild.emit!('close', 1); - return; - } - - mockChild.stdout!.emit('data', Buffer.from(output)); - mockChild.emit!('close', 0); - }); - - return mockChild; - }); - - ghPages.publish(basePath, options, (err: Error | null) => { - if (err) { - done(err); - return; - } - - try { - // Verify getUser was called (our mock returns null) - expect(mockGetUser).toHaveBeenCalled(); - - // Verify NO git config commands for user.name/user.email - const configUserCalls = spawnCalls.filter(call => - call.cmd === 'git' && - call.args[0] === 'config' && - (call.args[1] === 'user.name' || call.args[1] === 'user.email') - ); - - // When getUser returns null, gh-pages skips git config - // (relies on global/local git config) - expect(configUserCalls.length).toBe(0); - - done(); - } catch (assertionError) { - done(assertionError); - } - }); - }); - - }); - - /** - * gh-pages v6+ CNAME and .nojekyll file creation - * - * IMPORTANT: File creation tests are in engine.gh-pages-filecreation.spec.ts - * Those tests run WITHOUT mocks to verify actual file creation behavior. - * - * This mocked test file cannot test actual file creation because: - * - We mock child_process.spawn (git commands) - * - gh-pages creates files in git.cwd which is set during clone - * - Since clone is mocked, the directory structure isn't created - * - * See engine.gh-pages-filecreation.spec.ts for real file creation tests. - */ - - /** - * gh-pages.clean() behavior is tested in engine.gh-pages-clean.spec.ts - * That test uses real filesystem operations without mocks. - */ - - /** - * End-to-end canary for issue #205: drives the REAL gh-pages library - * through engine.run() with a mocked child_process.spawn that simulates - * an HTTPS auth failure during `git clone`. If upstream gh-pages ever - * fixes the `.then(_, onRejected)` absorption pattern, this test will - * still pass; if they change callback semantics, it will fail loudly. - */ - describe('auth-failure silent-swallow regression (issue #205)', () => { - // engine.run() β†’ require('gh-pages') uses the REAL child_process (not vi.mock'd), - // so we must spy on the actual module to intercept spawn calls. - let realCp: typeof import('child_process'); - let realSpawnSpy: ReturnType; - - beforeEach(() => { - cleanupMonkeypatch(); - realCp = require('node:child_process'); - realSpawnSpy = vi.spyOn(realCp, 'spawn'); - }); - - afterEach(() => { - realSpawnSpy.mockRestore(); - }); - - it('engine.run() should reject when git clone fails with fatal: Authentication failed', async () => { - realSpawnSpy.mockImplementation(((cmd: string, args?: string[]) => { - const capturedArgs = args || []; - spawnCalls.push({ cmd, args: capturedArgs, options: undefined }); - const child = createMockChildProcess(); - setImmediate(() => { - child.stderr!.emit( - 'data', - Buffer.from( - "fatal: Authentication failed for 'https://github.com/owner/repo.git'" - ) - ); - child.emit!('close', 128); - }); - return child; - }) as typeof realCp.spawn); - - const options = { - repo: 'https://x-access-token:bad-token@github.com/owner/repo.git', - branch: 'gh-pages', - dotfiles: true, - notfound: true, - nojekyll: true - }; - - await expect( - engine.run(basePath, options, new logging.NullLogger()) - ).rejects.toThrow(); - - const cloneCall = spawnCalls.find(c => c.cmd === 'git' && c.args[0] === 'clone'); - expect(cloneCall).toBeDefined(); - expect(cloneCall!.cmd).toBe('git'); - expect(cloneCall!.args[0]).toBe('clone'); - }); - }); - - /** - * End-to-end regression for issue #204: gh-pages@6.3.0's "Removing files" step - * skips dotfiles and submodule gitlinks. Our beforeAdd hook should catch them - * via `git ls-files` and `git rm` the leftovers while leaving dist files alone. - */ - describe('submodule / dotfile cleanup regression (issue #204)', () => { - // engine.run() β†’ require('gh-pages') uses the REAL child_process (not vi.mock'd), - // so we must spy on the actual module to intercept spawn calls. - let realCp: typeof import('child_process'); - let realSpawnSpy: ReturnType; - - beforeEach(() => { - cleanupMonkeypatch(); - realCp = require('node:child_process'); - realSpawnSpy = vi.spyOn(realCp, 'spawn'); - }); - - afterEach(() => { - realSpawnSpy.mockRestore(); - }); - - it('engine.run() should git rm leftover gh-pages branch files not present in dist', async () => { - const repo = 'https://github.com/owner/leftover-test.git'; - currentTestContext.repo = repo; - - const leftoverFiles = [ - '.github/workflows/deploy.yml', - '.gitignore', - '.gitmodules', - 'build' // submodule gitlink - ]; - - // Spy on the real child_process.spawn so `git ls-files` returns our leftover set. - realSpawnSpy.mockImplementation(((cmd: string, args?: string[], opts?: unknown) => { - const capturedArgs = args || []; - spawnCalls.push({ cmd, args: capturedArgs, options: opts }); - - if (cmd === 'git' && capturedArgs[0] && !EXPECTED_GIT_COMMANDS.includes(capturedArgs[0])) { - throw new Error(`Unexpected git command: ${capturedArgs[0]}. Add to whitelist if intentional.`); - } - - const child = createMockChildProcess(); - setImmediate(() => { - let output = ''; - if (cmd === 'git' && capturedArgs[0] === 'ls-files') { - output = leftoverFiles.join('\0') + '\0'; - } else if (cmd === 'git' && capturedArgs[0] === 'config' && - capturedArgs[1] === '--get' && capturedArgs[2]?.startsWith('remote.')) { - output = repo; - } else if (cmd === 'git' && capturedArgs[0] === 'ls-remote') { - output = 'refs/heads/gh-pages'; - } else if (cmd === 'git' && capturedArgs[0] === 'diff-index') { - child.emit!('close', 1); - return; - } - child.stdout!.emit('data', Buffer.from(output)); - child.emit!('close', 0); - }); - return child; - }) as typeof realCp.spawn); - - const options = { - repo, - branch: 'gh-pages', - dotfiles: true, - notfound: false, - nojekyll: false - }; - - await engine.run(basePath, options, new logging.NullLogger()); - - // Our hook should have issued `git ls-files -z`. - const lsFilesCall = spawnCalls.find(c => c.cmd === 'git' && c.args[0] === 'ls-files'); - expect(lsFilesCall).toBeDefined(); - expect(lsFilesCall!.args).toContain('-z'); - - // And a subsequent `git rm` that targets exactly the leftovers. - const rmCalls = spawnCalls.filter(c => c.cmd === 'git' && c.args[0] === 'rm'); - const cleanupCall = rmCalls.find(c => leftoverFiles.every(f => c.args.includes(f))); - expect(cleanupCall).toBeDefined(); - - // Dist files (from the outer beforeAll: index.html, main.js, styles.css, .htaccess) - // must NOT be in the cleanup call β€” those are files we just copied. - for (const distFile of ['index.html', 'main.js', 'styles.css', '.htaccess']) { - expect(cleanupCall!.args).not.toContain(distFile); - } - }); - }); -}); diff --git a/src/engine/engine.gh-pages-clean.spec.ts b/src/engine/engine.gh-pages-clean.spec.ts deleted file mode 100644 index 11dc742..0000000 --- a/src/engine/engine.gh-pages-clean.spec.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * gh-pages.clean() behavior verification - * - * What we're testing: - * - gh-pages.clean() actually removes cache directories - * - This catches breaking changes if gh-pages updates its behavior - * - * Why maxWorkers: 1 is required: - * gh-pages uses a globally shared cache at node_modules/.cache/gh-pages/ - * The clean() function is a nuclear option - it wipes the ENTIRE cache directory: - * - * exports.clean = function clean() { - * fs.removeSync(getCacheDir()); // Removes ALL repo caches at once! - * }; - * - * If tests run in parallel, one test's clean() call destroys caches that - * other tests are actively using, causing random failures. - */ - -import * as path from 'path'; -import * as fs from 'fs/promises'; - -import { pathExists } from '../utils'; - -const ghPages = require('gh-pages'); -const findCacheDir = require('find-cache-dir'); -const filenamify = require('filenamify'); - -describe('gh-pages.clean() behavior', () => { - - it('should remove repo-specific cache directories', async () => { - const cacheBaseDir = findCacheDir({ name: 'gh-pages' }); - if (!cacheBaseDir) { - // Skip if no cache dir available (e.g., in some CI environments) - console.warn('Skipping test: no gh-pages cache directory available'); - return; - } - - // Create a fake repo-specific cache directory - const fakeRepoUrl = 'https://github.com/test/clean-test.git'; - const repoCacheDir = path.join(cacheBaseDir, filenamify(fakeRepoUrl, { replacement: '!' })); - - // Setup: create the fake cache directory with a marker file - await fs.mkdir(repoCacheDir, { recursive: true }); - await fs.writeFile(path.join(repoCacheDir, 'marker.txt'), 'should be deleted'); - expect(await pathExists(repoCacheDir)).toBe(true); - - // Execute clean - this removes ALL repo cache directories - ghPages.clean(); - - // Verify the directory was removed - expect(await pathExists(repoCacheDir)).toBe(false); - }); - - it('should not throw when cache directory does not exist', () => { - // clean() should be safe to call even if nothing to clean - expect(() => ghPages.clean()).not.toThrow(); - }); -}); diff --git a/src/engine/engine.gh-pages-cleanup.spec.ts b/src/engine/engine.gh-pages-cleanup.spec.ts deleted file mode 100644 index d355162..0000000 --- a/src/engine/engine.gh-pages-cleanup.spec.ts +++ /dev/null @@ -1,246 +0,0 @@ -/** - * End-to-end integration test for issue #204 β€” REAL git, REAL gh-pages, REAL filesystem. - * - * No mocks. This test proves the `beforeAdd` cleanup hook actually produces a clean - * gh-pages commit when the branch previously contained dotfiles and submodule gitlinks. - * - * Flow: - * 1. Create a local bare git repo (serves as "remote"). - * 2. Seed a gh-pages branch containing: - * - .github/workflows/deploy.yml (dot-directory, nested) - * - .gitignore (dotfile) - * - .gitmodules (dotfile) - * - a submodule gitlink `build` (mode 160000) - * - stale.html (regular file gh-pages' own remove would catch) - * 3. Run `engine.run()` against the bare repo with a dist containing only index.html. - * 4. `git ls-tree -r gh-pages` on the bare repo β†’ assert ONLY `index.html` landed. - * - * A companion test demonstrates the upstream bug itself by calling `gh-pages.publish()` - * directly (bypassing our hook) and observing the leftovers leak into the commit. That - * test will start failing once tschaub/gh-pages ships PR #612 in a release β€” which is - * fine; it's the signal that our workaround can be removed. - */ - -import * as path from 'path'; -import * as fs from 'fs/promises'; -import * as os from 'os'; -import { execSync } from 'child_process'; - -import { logging } from '@angular-devkit/core'; - -import * as engine from './engine'; -import { cleanupMonkeypatch } from './engine.prepare-options-helpers'; - -// NO MOCKS β€” we want real gh-pages behavior end-to-end -const ghPages = require('gh-pages'); - -interface GitOptions { - readonly cwd: string; -} - -function git(args: string, options: GitOptions): string { - return execSync(`git -C "${options.cwd}" ${args}`, { stdio: ['pipe', 'pipe', 'pipe'] }) - .toString() - .trim(); -} - -async function seedGhPagesBranch(workDir: string, bareRepoPath: string): Promise { - await fs.mkdir(workDir, { recursive: true }); - execSync(`git init "${workDir}"`, { stdio: 'pipe' }); - git('config user.email "seed@test.com"', { cwd: workDir }); - git('config user.name "Seed"', { cwd: workDir }); - git('checkout -b gh-pages', { cwd: workDir }); - - // Dotfiles and dot-directory (what gh-pages' broken remove step misses) - await fs.mkdir(path.join(workDir, '.github', 'workflows'), { recursive: true }); - await fs.writeFile(path.join(workDir, '.github', 'workflows', 'deploy.yml'), 'name: deploy\n'); - await fs.writeFile(path.join(workDir, '.gitignore'), 'node_modules\n'); - await fs.writeFile( - path.join(workDir, '.gitmodules'), - '[submodule "build"]\n\tpath = build\n\turl = https://example.invalid/build.git\n' - ); - // A regular non-dot file β€” gh-pages' own remove step WILL catch this one. - await fs.writeFile(path.join(workDir, 'stale.html'), 'stale\n'); - - git('add .github .gitignore .gitmodules stale.html', { cwd: workDir }); - git('commit -m "seed dotfiles and stale content"', { cwd: workDir }); - - // Add a submodule gitlink without needing a real subrepo. - // update-index --cacheinfo creates the 160000 entry directly in the index. - const seedSha = git('rev-parse HEAD', { cwd: workDir }); - git(`update-index --add --cacheinfo 160000,${seedSha},build`, { cwd: workDir }); - git('commit -m "add submodule gitlink"', { cwd: workDir }); - - git(`remote add origin "${bareRepoPath}"`, { cwd: workDir }); - git('push origin gh-pages', { cwd: workDir }); -} - -describe('end-to-end cleanup regression (issue #204, real git)', () => { - let tempDir: string; - let distDir: string; - let bareRepoPath: string; - let originalEnv: NodeJS.ProcessEnv; - - // Unique suffix so parallel-ish reruns never collide in /tmp. - const testRunId = `${Date.now()}-${Math.random().toString(36).substring(7)}`; - - beforeEach(async () => { - cleanupMonkeypatch(); - - // Isolate from ambient CI envs and tokens so engine.prepareOptions doesn't - // rewrite the repo URL or append CI metadata during tests. - originalEnv = { ...process.env }; - delete process.env.TRAVIS; - delete process.env.CIRCLECI; - delete process.env.GITHUB_ACTIONS; - delete process.env.GH_TOKEN; - delete process.env.PERSONAL_TOKEN; - delete process.env.GITHUB_TOKEN; - - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), `ghp-204-${testRunId}-`)); - - distDir = path.join(tempDir, 'dist'); - await fs.mkdir(distDir, { recursive: true }); - await fs.writeFile(path.join(distDir, 'index.html'), 'fresh dist\n'); - - bareRepoPath = path.join(tempDir, 'bare.git'); - execSync(`git init --bare "${bareRepoPath}"`, { stdio: 'pipe' }); - - await seedGhPagesBranch(path.join(tempDir, 'seed'), bareRepoPath); - - // Clear gh-pages' cache so each test starts from a clean clone. - ghPages.clean(); - }); - - afterEach(async () => { - ghPages.clean(); - cleanupMonkeypatch(); - process.env = originalEnv; - await fs.rm(tempDir, { recursive: true, force: true }); - }); - - it('engine.run() produces a gh-pages commit containing ONLY dist files (no dotfiles, no submodule)', async () => { - await engine.run( - distDir, - { - repo: bareRepoPath, - branch: 'gh-pages', - dotfiles: true, - // Keep assertions simple: don't let our own 404.html / .nojekyll machinery - // add files to the expected set. - notfound: false, - nojekyll: false, - name: 'Test', - email: 'test@test.com', - message: 'test deploy' - }, - new logging.NullLogger() - ); - - // Inspect what actually landed on gh-pages in the bare repo. - const tree = git('ls-tree -r gh-pages --name-only', { cwd: bareRepoPath }) - .split('\n') - .filter(Boolean) - .sort(); - - expect(tree).toEqual(['index.html']); - - // Explicit negative checks β€” makes regressions very readable on failure. - expect(tree).not.toContain('.gitignore'); - expect(tree).not.toContain('.gitmodules'); - expect(tree).not.toContain('.github/workflows/deploy.yml'); - expect(tree).not.toContain('build'); - expect(tree).not.toContain('stale.html'); - }, 30_000); - - it('keeps .nojekyll and CNAME on a redeploy', async () => { - const deploy = () => engine.run( - distDir, - { - repo: bareRepoPath, - branch: 'gh-pages', - dotfiles: true, - notfound: false, - nojekyll: true, - cname: 'example.org', - name: 'Test', - email: 'test@test.com', - message: 'test deploy' - }, - new logging.NullLogger() - ); - - await deploy(); - await fs.writeFile(path.join(distDir, 'index.html'), 'second deploy\n'); - await deploy(); - - const tree = git('ls-tree -r gh-pages --name-only', { cwd: bareRepoPath }) - .split('\n') - .filter(Boolean) - .sort(); - - expect(tree).toEqual(['.nojekyll', 'CNAME', 'index.html']); - expect(git('show gh-pages:CNAME', { cwd: bareRepoPath })).toBe('example.org'); - }, 30_000); - - it('removes a leftover .nojekyll when nojekyll is disabled', async () => { - const deploy = (nojekyll: boolean) => engine.run( - distDir, - { - repo: bareRepoPath, - branch: 'gh-pages', - dotfiles: true, - notfound: false, - nojekyll, - name: 'Test', - email: 'test@test.com', - message: 'test deploy' - }, - new logging.NullLogger() - ); - - await deploy(true); - await deploy(false); - - const tree = git('ls-tree -r gh-pages --name-only', { cwd: bareRepoPath }) - .split('\n') - .filter(Boolean); - - expect(tree).toEqual(['index.html']); - }, 30_000); - - it('baseline: without our hook, gh-pages alone leaks dotfiles and submodule gitlinks (demonstrates the upstream bug)', async () => { - // Call gh-pages.publish() directly β€” no engine.run(), no beforeAdd hook. - // This is exactly what angular-cli-ghpages v3 did before our fix. - await new Promise((resolve, reject) => { - ghPages.publish( - distDir, - { - repo: bareRepoPath, - branch: 'gh-pages', - dotfiles: true, - user: { name: 'Test', email: 'test@test.com' }, - message: 'baseline: unhooked gh-pages publish' - }, - (err: Error | null) => { - if (err) reject(err); - else resolve(); - } - ); - }); - - const tree = git('ls-tree -r gh-pages --name-only', { cwd: bareRepoPath }) - .split('\n') - .filter(Boolean); - - // gh-pages' broken remove step missed all of these: - expect(tree).toContain('.gitignore'); - expect(tree).toContain('.gitmodules'); - expect(tree).toContain('.github/workflows/deploy.yml'); - expect(tree).toContain('build'); - // Our dist file did land: - expect(tree).toContain('index.html'); - // And the non-dot stale file DID get removed by gh-pages' (partial) remove step: - expect(tree).not.toContain('stale.html'); - }, 30_000); -}); diff --git a/src/engine/engine.gh-pages-filecreation.spec.ts b/src/engine/engine.gh-pages-filecreation.spec.ts deleted file mode 100644 index 4c38de3..0000000 --- a/src/engine/engine.gh-pages-filecreation.spec.ts +++ /dev/null @@ -1,231 +0,0 @@ -/** - * gh-pages v6+ file creation tests (REAL filesystem with local git repo) - * - * These tests verify that gh-pages ACTUALLY creates CNAME and .nojekyll files - * on the REAL filesystem when the options are passed. - * - * IMPORTANT: These tests do NOT mock child_process or fs. - * This ensures we test the actual gh-pages behavior, not mocked behavior. - * - * Approach: - * - Create a local bare git repository for each test - * - gh-pages clones from the local repo (no network required) - * - Run with push: false to complete cleanly without network - * - Verify files after publish completes - * - * Why this matters: - * - We delegated CNAME/.nojekyll creation from angular-cli-ghpages to gh-pages v6+ - * - We must verify gh-pages actually creates these files - * - Unlike 404.html (which we still create ourselves), these files are gh-pages' responsibility - */ - -import * as path from 'path'; -import * as fs from 'fs/promises'; -import * as os from 'os'; -import { execSync } from 'child_process'; - -import { pathExists } from '../utils'; - -// NO MOCKS - we want real gh-pages behavior -const ghPages = require('gh-pages'); -const filenamify = require('filenamify'); - -describe('gh-pages v6+ CNAME/.nojekyll file creation (REAL filesystem)', () => { - let tempDir: string; - let basePath: string; - let bareRepoPath: string; - let cacheBaseDir: string; - - // Unique ID to prevent conflicts with parallel test runs - const testRunId = `${Date.now()}-${Math.random().toString(36).substring(7)}`; - - beforeAll(async () => { - // Get the cache directory that gh-pages will use - const findCacheDir = require('find-cache-dir'); - cacheBaseDir = findCacheDir({ name: 'gh-pages' }); - }); - - beforeEach(async () => { - // Create a unique temp directory for this test - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), `gh-pages-file-test-${testRunId}-`)); - - // Create source directory with minimal files - basePath = path.join(tempDir, 'dist'); - await fs.mkdir(basePath, { recursive: true }); - await fs.writeFile(path.join(basePath, 'index.html'), 'test'); - - // Create a local bare git repository that gh-pages can clone from - bareRepoPath = path.join(tempDir, 'bare-repo.git'); - execSync(`git init --bare "${bareRepoPath}"`, { stdio: 'pipe' }); - - // Initialize gh-pages branch in the bare repo - // gh-pages needs the branch to exist, so we create it with an initial commit - const initWorkDir = path.join(tempDir, 'init-work'); - await fs.mkdir(initWorkDir, { recursive: true }); - execSync(`git init "${initWorkDir}"`, { stdio: 'pipe' }); - execSync(`git -C "${initWorkDir}" config user.email "test@test.com"`, { stdio: 'pipe' }); - execSync(`git -C "${initWorkDir}" config user.name "Test"`, { stdio: 'pipe' }); - await fs.writeFile(path.join(initWorkDir, 'README.md'), 'init'); - execSync(`git -C "${initWorkDir}" add .`, { stdio: 'pipe' }); - execSync(`git -C "${initWorkDir}" commit -m "init"`, { stdio: 'pipe' }); - execSync(`git -C "${initWorkDir}" branch gh-pages`, { stdio: 'pipe' }); - execSync(`git -C "${initWorkDir}" remote add origin "${bareRepoPath}"`, { stdio: 'pipe' }); - execSync(`git -C "${initWorkDir}" push -u origin gh-pages`, { stdio: 'pipe' }); - - // Clean gh-pages cache before each test - ghPages.clean(); - }); - - afterEach(async () => { - // Clean gh-pages cache to remove repo-specific cache directories - ghPages.clean(); - // Cleanup temp directory - await fs.rm(tempDir, { recursive: true, force: true }); - }); - - /** - * Helper to get the cache directory path for a repo URL - * gh-pages uses filenamify to convert repo URL to directory name - */ - function getCacheDir(repoPath: string): string { - return path.join(cacheBaseDir, filenamify(repoPath, { replacement: '!' })); - } - - describe('CNAME file creation', () => { - it('should create CNAME file with exact domain content when cname option is set', async () => { - const testDomain = 'test-cname.example.com'; - const cacheDir = getCacheDir(bareRepoPath); - - const options = { - repo: bareRepoPath, - branch: 'gh-pages', - cname: testDomain, - message: 'Test CNAME creation', - user: { name: 'Test', email: 'test@test.com' }, - push: false // Don't push - just verify file creation - }; - - await new Promise((resolve, reject) => { - ghPages.publish(basePath, options, (err: Error | null) => { - if (err) reject(err); - else resolve(); - }); - }); - - // Verify CNAME file was created by gh-pages - const cnamePath = path.join(cacheDir, 'CNAME'); - const exists = await pathExists(cnamePath); - expect(exists).toBe(true); - - const content = await fs.readFile(cnamePath, 'utf-8'); - expect(content).toBe(testDomain); - }); - - it('should NOT create CNAME file when cname option is not provided', async () => { - const cacheDir = getCacheDir(bareRepoPath); - - const options = { - repo: bareRepoPath, - branch: 'gh-pages', - // cname NOT provided - message: 'Test no CNAME', - user: { name: 'Test', email: 'test@test.com' }, - push: false - }; - - await new Promise((resolve, reject) => { - ghPages.publish(basePath, options, (err: Error | null) => { - if (err) reject(err); - else resolve(); - }); - }); - - const cnamePath = path.join(cacheDir, 'CNAME'); - const exists = await pathExists(cnamePath); - expect(exists).toBe(false); - }); - }); - - describe('.nojekyll file creation', () => { - it('should create .nojekyll file when nojekyll option is true', async () => { - const cacheDir = getCacheDir(bareRepoPath); - - const options = { - repo: bareRepoPath, - branch: 'gh-pages', - nojekyll: true, - message: 'Test nojekyll creation', - user: { name: 'Test', email: 'test@test.com' }, - push: false - }; - - await new Promise((resolve, reject) => { - ghPages.publish(basePath, options, (err: Error | null) => { - if (err) reject(err); - else resolve(); - }); - }); - - const nojekyllPath = path.join(cacheDir, '.nojekyll'); - const exists = await pathExists(nojekyllPath); - expect(exists).toBe(true); - }); - - it('should NOT create .nojekyll file when nojekyll option is false', async () => { - const cacheDir = getCacheDir(bareRepoPath); - - const options = { - repo: bareRepoPath, - branch: 'gh-pages', - nojekyll: false, - message: 'Test no nojekyll', - user: { name: 'Test', email: 'test@test.com' }, - push: false - }; - - await new Promise((resolve, reject) => { - ghPages.publish(basePath, options, (err: Error | null) => { - if (err) reject(err); - else resolve(); - }); - }); - - const nojekyllPath = path.join(cacheDir, '.nojekyll'); - const exists = await pathExists(nojekyllPath); - expect(exists).toBe(false); - }); - }); - - describe('Both CNAME and .nojekyll together', () => { - it('should create both files when both options are set', async () => { - const testDomain = 'both-files.example.com'; - const cacheDir = getCacheDir(bareRepoPath); - - const options = { - repo: bareRepoPath, - branch: 'gh-pages', - cname: testDomain, - nojekyll: true, - message: 'Test both files', - user: { name: 'Test', email: 'test@test.com' }, - push: false - }; - - await new Promise((resolve, reject) => { - ghPages.publish(basePath, options, (err: Error | null) => { - if (err) reject(err); - else resolve(); - }); - }); - - // Verify CNAME - const cnamePath = path.join(cacheDir, 'CNAME'); - expect(await pathExists(cnamePath)).toBe(true); - expect(await fs.readFile(cnamePath, 'utf-8')).toBe(testDomain); - - // Verify .nojekyll - const nojekyllPath = path.join(cacheDir, '.nojekyll'); - expect(await pathExists(nojekyllPath)).toBe(true); - }); - }); -}); diff --git a/src/engine/engine.gh-pages-integration.spec.ts b/src/engine/engine.gh-pages-integration.spec.ts deleted file mode 100644 index 62958ac..0000000 --- a/src/engine/engine.gh-pages-integration.spec.ts +++ /dev/null @@ -1,632 +0,0 @@ -/** - * Integration tests for gh-pages library interaction - * - * These tests verify how engine.ts interacts with the gh-pages library: - * - When gh-pages.clean() is called - * - What arguments are passed to gh-pages.publish() - * - Which options are passed through vs. filtered out - * - Dry-run behavior isolation - * - Options transformation before passing to gh-pages - */ - -import { logging } from '@angular-devkit/core'; -import { Mock, MockInstance } from 'vitest'; - -import * as engine from './engine'; -import { cleanupMonkeypatch } from './engine.prepare-options-helpers'; - -// Mock utils.pathExists at module level -vi.mock('../utils', async () => ({ - ...(await vi.importActual('../utils')), - pathExists: vi.fn().mockResolvedValue(true) -})); - -// Mock Git class from gh-pages to avoid spawning actual git processes -vi.mock('gh-pages/lib/git', () => { - class MockGit { - getRemoteUrl = vi.fn().mockResolvedValue( - 'https://github.com/test/repo.git' - ); - } - - return { - default: MockGit - }; -}); - -describe('engine - gh-pages integration', () => { - const logger = new logging.NullLogger(); - const originalEnv = process.env; - - // Only spy on gh-pages methods - let ghpagesCleanSpy: MockInstance; - let ghpagesPublishSpy: MockInstance; - - beforeEach(() => { - // Clean up any previous monkeypatch so each test starts fresh - cleanupMonkeypatch(); - - const ghpages = require('gh-pages'); - - // Clear any existing mocks from previous tests - if (ghpagesCleanSpy) { - ghpagesCleanSpy.mockClear(); - } else { - ghpagesCleanSpy = vi.spyOn(ghpages, 'clean'); - } - - if (ghpagesPublishSpy) { - ghpagesPublishSpy.mockClear(); - } else { - ghpagesPublishSpy = vi.spyOn(ghpages, 'publish'); - } - - // engine uses the callback form of gh-pages.publish() β€” see #205 - ghpagesCleanSpy.mockImplementation(() => {}); - ghpagesPublishSpy.mockImplementation((_dir: string, _opts: unknown, callback?: (error: Error | null) => void) => { - if (callback) { - callback(null); - } - return Promise.resolve(undefined); - }); - - // Create fresh copy of environment for each test - // This preserves PATH, HOME, etc. needed by git - process.env = { ...originalEnv }; - // Clear only CI-specific vars we're testing - delete process.env.TRAVIS; - delete process.env.CIRCLECI; - delete process.env.GITHUB_ACTIONS; - delete process.env.GH_TOKEN; - delete process.env.PERSONAL_TOKEN; - delete process.env.GITHUB_TOKEN; - }); - - afterAll(() => { - // Clean up monkeypatch after all tests - cleanupMonkeypatch(); - // Restore original environment for other test files - process.env = originalEnv; - }); - - describe('gh-pages.clean() behavior', () => { - it('should call clean() before publishing in normal mode', async () => { - const testDir = '/test/dist'; - const options = { dotfiles: true, notfound: true, nojekyll: true }; - - await engine.run(testDir, options, logger); - - expect(ghpagesCleanSpy).toHaveBeenCalledTimes(1); - expect(ghpagesCleanSpy).toHaveBeenCalledWith(); - }); - - it('should NOT call clean() during dry-run', async () => { - const testDir = '/test/dist'; - const options = { - dotfiles: true, - notfound: true, - nojekyll: true, - dryRun: true - }; - - await engine.run(testDir, options, logger); - - expect(ghpagesCleanSpy).not.toHaveBeenCalled(); - }); - - it('should call clean() before publish() to avoid remote url mismatch', async () => { - const testDir = '/test/dist'; - const options = { dotfiles: true, notfound: true, nojekyll: true }; - - await engine.run(testDir, options, logger); - - // Verify clean() was called before publish() - const cleanCallOrder = ghpagesCleanSpy.mock.invocationCallOrder[0]; - const publishCallOrder = ghpagesPublishSpy.mock.invocationCallOrder[0]; - - expect(cleanCallOrder).toBeLessThan(publishCallOrder); - }); - }); - - describe('gh-pages.publish() - directory parameter', () => { - it('should pass the correct directory path to publish()', async () => { - const testDir = '/test/dist/my-app'; - const options = { dotfiles: true, notfound: true, nojekyll: true }; - - await engine.run(testDir, options, logger); - - expect(ghpagesPublishSpy).toHaveBeenCalledTimes(1); - // engine uses the callback form of gh-pages.publish() β€” see #205 - expect(ghpagesPublishSpy).toHaveBeenCalledWith( - testDir, - expect.any(Object), - expect.any(Function) - ); - }); - - it('should pass different directory paths correctly', async () => { - const firstDir = '/first/path'; - const options = { dotfiles: true, notfound: true, nojekyll: true }; - - await engine.run(firstDir, options, logger); - - const actualDir = ghpagesPublishSpy.mock.calls[0][0]; - expect(actualDir).toBe(firstDir); - }); - }); - - describe('gh-pages.publish() - options parameter', () => { - it('should pass core options to gh-pages', async () => { - const testDir = '/test/dist'; - const repo = 'https://github.com/test/repo.git'; - const branch = 'main'; - const message = 'Deploy to GitHub Pages'; - const remote = 'upstream'; - - const options = { - repo, - branch, - message, - remote, - dotfiles: true, - notfound: true, - nojekyll: true - }; - - await engine.run(testDir, options, logger); - - const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; - expect(actualOptions.repo).toBe(repo); - expect(actualOptions.branch).toBe(branch); - expect(actualOptions.message).toBe(message); - expect(actualOptions.remote).toBe(remote); - }); - - it('should pass transformed dotfiles boolean to gh-pages', async () => { - const testDir = '/test/dist'; - const options = { - dotfiles: true, - notfound: true, - nojekyll: true - }; - - await engine.run(testDir, options, logger); - - const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; - expect(actualOptions.dotfiles).toBe(true); - }); - - it('should pass dotfiles: false when transformed from noDotfiles: true', async () => { - const testDir = '/test/dist'; - const options = { - noDotfiles: true, - dotfiles: true, // will be overwritten by noDotfiles transformation - notfound: true, - nojekyll: true - }; - - await engine.run(testDir, options, logger); - - const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; - expect(actualOptions.dotfiles).toBe(false); - }); - - it('should pass user credentials object when both name and email provided', async () => { - const testDir = '/test/dist'; - const userName = 'CI Bot'; - const userEmail = 'ci@example.com'; - - const options = { - name: userName, - email: userEmail, - dotfiles: true, - notfound: true, - nojekyll: true - }; - - await engine.run(testDir, options, logger); - - const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; - expect(actualOptions.user).toEqual({ - name: userName, - email: userEmail - }); - }); - - it('should NOT have user object when only name is provided', async () => { - const testDir = '/test/dist'; - const options = { - name: 'CI Bot', - dotfiles: true, - notfound: true, - nojekyll: true - }; - - await engine.run(testDir, options, logger); - - const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; - expect(actualOptions.user).toBeUndefined(); - }); - - it('should pass add option for incremental deployment', async () => { - const testDir = '/test/dist'; - const options = { - add: true, - dotfiles: true, - notfound: true, - nojekyll: true - }; - - await engine.run(testDir, options, logger); - - const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; - expect(actualOptions.add).toBe(true); - }); - - it('should pass git option to gh-pages', async () => { - const testDir = '/test/dist'; - const gitPath = '/custom/path/to/git'; - - const options = { - git: gitPath, - dotfiles: true, - notfound: true, - nojekyll: true - }; - - await engine.run(testDir, options, logger); - - const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; - expect(actualOptions.git).toBe(gitPath); - }); - - it('should NOT pass internal dryRun option to gh-pages', async () => { - const testDir = '/test/dist'; - const options = { - dryRun: false, // Internal option, should be filtered out - dotfiles: true, - notfound: true, - nojekyll: true - }; - - await engine.run(testDir, options, logger); - - const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; - expect(actualOptions.dryRun).toBeUndefined(); - }); - - it('should NOT pass internal noDotfiles option to gh-pages', async () => { - const testDir = '/test/dist'; - const options = { - noDotfiles: false, - dotfiles: true, - notfound: true, - nojekyll: true - }; - - await engine.run(testDir, options, logger); - - const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; - expect(actualOptions.noDotfiles).toBeUndefined(); - }); - - it('should NOT pass internal noNotfound option to gh-pages', async () => { - const testDir = '/test/dist'; - const options = { - noNotfound: false, - dotfiles: true, - notfound: true, - nojekyll: true - }; - - await engine.run(testDir, options, logger); - - const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; - expect(actualOptions.noNotfound).toBeUndefined(); - }); - - it('should NOT pass internal noNojekyll option to gh-pages', async () => { - const testDir = '/test/dist'; - const options = { - noNojekyll: false, - dotfiles: true, - notfound: true, - nojekyll: true - }; - - await engine.run(testDir, options, logger); - - const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; - expect(actualOptions.noNojekyll).toBeUndefined(); - }); - - it('should NOT pass internal notfound option to gh-pages', async () => { - const testDir = '/test/dist'; - const options = { - dotfiles: true, - notfound: true, // Internal only, used for 404.html creation - nojekyll: true - }; - - await engine.run(testDir, options, logger); - - const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; - // notfound remains internal - 404.html is still created by angular-cli-ghpages - expect(actualOptions.notfound).toBeUndefined(); - }); - - it('should pass nojekyll option to gh-pages v6+', async () => { - const testDir = '/test/dist'; - const options = { - dotfiles: true, - notfound: true, - nojekyll: true // gh-pages v6+ handles .nojekyll file creation - }; - - await engine.run(testDir, options, logger); - - const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; - // nojekyll IS now passed to gh-pages v6+ (delegated file creation) - expect(actualOptions.nojekyll).toBe(true); - }); - - it('should pass cname option to gh-pages v6+', async () => { - const testDir = '/test/dist'; - const options = { - cname: 'example.com', // gh-pages v6+ handles CNAME file creation - dotfiles: true, - notfound: true, - nojekyll: true - }; - - await engine.run(testDir, options, logger); - - const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; - // cname IS now passed to gh-pages v6+ (delegated file creation) - expect(actualOptions.cname).toBe('example.com'); - }); - }); - - describe('gh-pages.publish() - dry-run mode isolation', () => { - it('should NOT call publish() during dry-run', async () => { - const testDir = '/test/dist'; - const options = { - dryRun: true, - dotfiles: true, - notfound: true, - nojekyll: true - }; - - await engine.run(testDir, options, logger); - - expect(ghpagesPublishSpy).not.toHaveBeenCalled(); - }); - - it('should NOT call clean() during dry-run', async () => { - const testDir = '/test/dist'; - const options = { - dryRun: true, - dotfiles: true, - notfound: true, - nojekyll: true - }; - - await engine.run(testDir, options, logger); - - expect(ghpagesCleanSpy).not.toHaveBeenCalled(); - }); - - it('should log what WOULD be published during dry-run', async () => { - const testLogger = new logging.Logger('test'); - const infoSpy = vi.spyOn(testLogger, 'info'); - - const testDir = '/test/dist'; - const repo = 'https://github.com/test/repo.git'; - const branch = 'gh-pages'; - - const options = { - dryRun: true, - repo, - branch, - dotfiles: true, - notfound: true, - nojekyll: true - }; - - await engine.run(testDir, options, testLogger); - - // Should log the dry-run preview - expect(infoSpy).toHaveBeenCalledWith( - expect.stringContaining("Dry-run / SKIPPED: publishing folder") - ); - expect(infoSpy).toHaveBeenCalledWith( - expect.stringContaining(testDir) - ); - }); - }); - - describe('options transformation verification', () => { - it('should transform noDotfiles: true to dotfiles: false before passing to gh-pages', async () => { - const testDir = '/test/dist'; - const options = { - noDotfiles: true, - dotfiles: true, // will be overwritten - notfound: true, - nojekyll: true - }; - - await engine.run(testDir, options, logger); - - const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; - expect(actualOptions.dotfiles).toBe(false); - expect(actualOptions.noDotfiles).toBeUndefined(); - }); - - it('should transform noDotfiles: false to dotfiles: true before passing to gh-pages', async () => { - const testDir = '/test/dist'; - const options = { - noDotfiles: false, - dotfiles: false, // will be overwritten - notfound: true, - nojekyll: true - }; - - await engine.run(testDir, options, logger); - - const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; - expect(actualOptions.dotfiles).toBe(true); - expect(actualOptions.noDotfiles).toBeUndefined(); - }); - - it('should use default dotfiles: true when no noDotfiles is provided', async () => { - const testDir = '/test/dist'; - const options = { - dotfiles: true, - notfound: true, - nojekyll: true - }; - - await engine.run(testDir, options, logger); - - const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; - expect(actualOptions.dotfiles).toBe(true); - }); - - it('should NOT pass transformed noNotfound/notfound to gh-pages', async () => { - const testDir = '/test/dist'; - const options = { - noNotfound: true, - dotfiles: true, - notfound: true, // will be overwritten to false - nojekyll: true - }; - - await engine.run(testDir, options, logger); - - const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; - // notfound remains internal - 404.html is still created by angular-cli-ghpages - expect(actualOptions.notfound).toBeUndefined(); - expect(actualOptions.noNotfound).toBeUndefined(); - }); - - it('should pass transformed noNojekyll to nojekyll: false to gh-pages v6+', async () => { - const testDir = '/test/dist'; - const options = { - noNojekyll: true, - dotfiles: true, - notfound: true, - nojekyll: true // will be overwritten to false - }; - - await engine.run(testDir, options, logger); - - const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; - // nojekyll IS now passed to gh-pages v6+ (delegated file creation) - expect(actualOptions.nojekyll).toBe(false); - expect(actualOptions.noNojekyll).toBeUndefined(); - }); - }); - - describe('Promise handling integration', () => { - // engine uses the callback form of gh-pages.publish() β€” see #205 - - it('should invoke gh-pages.publish() with a callback', async () => { - const testDir = '/test/dist'; - const options = { dotfiles: true, notfound: true, nojekyll: true }; - - await engine.run(testDir, options, logger); - - expect(ghpagesPublishSpy).toHaveBeenCalledWith( - expect.any(String), - expect.any(Object), - expect.any(Function) - ); - }); - - it('should resolve when gh-pages.publish() invokes the callback with no error', async () => { - ghpagesPublishSpy.mockImplementation((_dir: string, _opts: unknown, callback?: (error: Error | null) => void) => { - if (callback) { - callback(null); - } - return Promise.resolve(undefined); - }); - - const testDir = '/test/dist'; - const options = { dotfiles: true, notfound: true, nojekyll: true }; - - await expect( - engine.run(testDir, options, logger) - ).resolves.toBeUndefined(); - }); - - it('should reject when gh-pages.publish() invokes the callback with an error', async () => { - const publishError = new Error('Git push failed'); - ghpagesPublishSpy.mockImplementation((_dir: string, _opts: unknown, callback?: (error: Error | null) => void) => { - if (callback) { - callback(publishError); - } - return Promise.resolve(undefined); - }); - - const testDir = '/test/dist'; - const options = { dotfiles: true, notfound: true, nojekyll: true }; - - await expect( - engine.run(testDir, options, logger) - ).rejects.toThrow('Git push failed'); - }); - }); - - describe('silent-swallow regression (issue #205)', () => { - // gh-pages@6 absorbs errors into its returned Promise via an internal - // .then(_, onRejected) handler that doesn't rethrow. The callback, however, - // still fires with the error. Engine must use the callback form so git - // failures (e.g. auth errors during clone) surface as rejections. - - it('should reject when gh-pages resolves its promise but delivers an error via callback', async () => { - const authError = new Error( - "fatal: Authentication failed for 'https://github.com/owner/repo.git'" - ); - - ghpagesPublishSpy.mockImplementation((_dir: string, _opts: unknown, callback?: (error: Error | null) => void) => { - if (callback) { - callback(authError); - } - return Promise.resolve(undefined); - }); - - const testDir = '/test/dist'; - const options = { dotfiles: true, notfound: true, nojekyll: true }; - - await expect( - engine.run(testDir, options, logger) - ).rejects.toThrow(/Authentication failed/); - }); - - it('should NOT log the success banner when publish fails via callback', async () => { - const authError = new Error('fatal: Authentication failed'); - - ghpagesPublishSpy.mockImplementation((_dir: string, _opts: unknown, callback?: (error: Error | null) => void) => { - if (callback) { - callback(authError); - } - return Promise.resolve(undefined); - }); - - const testLogger = new logging.Logger('test'); - const infoSpy = vi.spyOn(testLogger, 'info'); - - const testDir = '/test/dist'; - const options = { dotfiles: true, notfound: true, nojekyll: true }; - - await expect( - engine.run(testDir, options, testLogger) - ).rejects.toThrow(); - - const bannerCall = infoSpy.mock.calls.find( - ([msg]) => typeof msg === 'string' && msg.includes('Successfully published') - ); - expect(bannerCall).toBeUndefined(); - }); - }); -}); diff --git a/src/engine/engine.gh-pages-no-package-json.spec.ts b/src/engine/engine.gh-pages-no-package-json.spec.ts deleted file mode 100644 index bd65128..0000000 --- a/src/engine/engine.gh-pages-no-package-json.spec.ts +++ /dev/null @@ -1,159 +0,0 @@ -/** - * End-to-end integration test for issue #203 β€” REAL git, REAL gh-pages, REAL filesystem. - * - * Reproduces the original failure ("path argument must be of type string ... Received - * undefined") that happens when `angular-cli-ghpages` runs in a directory with no - * `package.json` in any parent β€” typical for `npx angular-cli-ghpages` on a repo - * that is just a `dist/` folder. - * - * Root cause: gh-pages@6.3.0 uses `find-cache-dir` to determine where to clone. - * `find-cache-dir` walks up looking for `package.json`; if it finds none, it - * returns `undefined`, and gh-pages then does `path.join(undefined, ...)`. - * - * Fix: engine.run() calls `ensureGhPagesCacheDir()` before loading gh-pages, - * which sets `CACHE_DIR` to an os.tmpdir() fallback when no `package.json` is - * reachable. `find-cache-dir` honors `CACHE_DIR` as an explicit override. - * - * Flow: - * 1. Make a local bare repo and seed a gh-pages branch with one file. - * 2. Create a cwd that has NO package.json in any ancestor (os.mkdtemp). - * 3. `process.chdir()` there so engine.run() / gh-pages see that cwd. - * 4. Run engine.run(). - * 5. Assert no throw AND the new dist file lands in the remote. - */ - -import * as path from 'path'; -import * as fs from 'fs/promises'; -import * as os from 'os'; -import { execSync } from 'child_process'; - -import { logging } from '@angular-devkit/core'; - -import * as engine from './engine'; -import { cleanupMonkeypatch } from './engine.prepare-options-helpers'; - -// NO MOCKS β€” we want real gh-pages behavior end-to-end. -const ghPages = require('gh-pages'); - -function git(args: string, cwd: string): string { - return execSync(`git -C "${cwd}" ${args}`, { stdio: ['pipe', 'pipe', 'pipe'] }) - .toString() - .trim(); -} - -describe('engine.run() in a directory with no package.json (issue #203)', () => { - let originalCwd: string; - let originalEnv: NodeJS.ProcessEnv; - let workDir: string; // Outside any package.json tree; becomes cwd. - let bareRepoPath: string; - let distDir: string; - - beforeEach(async () => { - cleanupMonkeypatch(); - originalCwd = process.cwd(); - originalEnv = { ...process.env }; - delete process.env.CACHE_DIR; - delete process.env.TRAVIS; - delete process.env.CIRCLECI; - delete process.env.GITHUB_ACTIONS; - delete process.env.GH_TOKEN; - delete process.env.PERSONAL_TOKEN; - delete process.env.GITHUB_TOKEN; - - workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ghp-203-e2e-')); - - // Dist with just index.html. Note: no package.json is ever created in workDir. - distDir = path.join(workDir, 'dist'); - await fs.mkdir(distDir, { recursive: true }); - await fs.writeFile(path.join(distDir, 'index.html'), 'fresh\n'); - - // Bare repo + seed a gh-pages branch so gh-pages' clone + checkout succeed. - bareRepoPath = path.join(workDir, 'bare.git'); - execSync(`git init --bare "${bareRepoPath}"`, { stdio: 'pipe' }); - - const seedDir = path.join(workDir, 'seed'); - await fs.mkdir(seedDir, { recursive: true }); - execSync(`git init "${seedDir}"`, { stdio: 'pipe' }); - git('config user.email "seed@test.com"', seedDir); - git('config user.name "Seed"', seedDir); - git('checkout -b gh-pages', seedDir); - await fs.writeFile(path.join(seedDir, 'old.html'), 'old'); - git('add .', seedDir); - git('commit -m "seed"', seedDir); - git(`remote add origin "${bareRepoPath}"`, seedDir); - git('push origin gh-pages', seedDir); - - ghPages.clean(); - }); - - afterEach(async () => { - process.chdir(originalCwd); - process.env = originalEnv; - ghPages.clean(); - cleanupMonkeypatch(); - await fs.rm(workDir, { recursive: true, force: true }); - }); - - it('engine.run() succeeds and actually publishes when no package.json is reachable from cwd', async () => { - // This test exists because spawn-mock or unit coverage cannot prove the - // full chain (prepareOptions β†’ ensureGhPagesCacheDir β†’ require("gh-pages") - // β†’ gh-pages.clean() β†’ gh-pages.publish() β†’ real git clone/push). Only a - // real run can. - - process.chdir(workDir); - - await engine.run( - distDir, - { - repo: bareRepoPath, - branch: 'gh-pages', - dotfiles: true, - notfound: false, - nojekyll: false, - name: 'Test', - email: 'test@test.com', - message: 'test deploy (no package.json)' - }, - new logging.NullLogger() - ); - - // Would-have-been-the-bug: if ensureGhPagesCacheDir didn't run or failed, - // gh-pages.clean() / publish() would have thrown the "path ... Received - // undefined" TypeError before reaching here. - - // Positive proof that the deploy actually landed: - const tree = git('ls-tree -r gh-pages --name-only', bareRepoPath) - .split('\n') - .filter(Boolean) - .sort(); - expect(tree).toEqual(['index.html']); - - // And CACHE_DIR was set to our tmpdir fallback (not some boolean-ish or empty). - expect(process.env.CACHE_DIR).toBe(path.join(os.tmpdir(), 'angular-cli-ghpages-cache')); - }, 30_000); - - it('baseline: bare gh-pages.clean() in a no-package-json cwd throws the #203 error (fresh child process)', () => { - // Run in a separate Node process so there is no shared require.cache / env - // pollution from the primary test. This proves the upstream bug is real β€” - // NOT a mock story. - const ghPagesPath = require.resolve('gh-pages'); - const script = - `process.chdir(${JSON.stringify(workDir)});` + - `const ghPages = require(${JSON.stringify(ghPagesPath)});` + - `try { ghPages.clean(); console.log('NOTHROW'); process.exit(1); }` + - `catch (e) { process.stdout.write(String(e && e.message)); process.exit(0); }`; - - // Explicitly unset CACHE_DIR in the child's env so find-cache-dir must - // fall back to the package.json walk (which finds nothing here). - const childEnv = { ...process.env }; - delete childEnv.CACHE_DIR; - - const out = execSync(`node -e ${JSON.stringify(script)}`, { - cwd: workDir, - env: childEnv, - stdio: ['pipe', 'pipe', 'pipe'] - }).toString(); - - expect(out).toMatch(/must be of type string|Received undefined/); - }, 10_000); -}); diff --git a/src/engine/gh-pages.d.ts b/src/engine/gh-pages.d.ts deleted file mode 100644 index aa3828f..0000000 --- a/src/engine/gh-pages.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Type declarations for gh-pages internal modules - * - * gh-pages/lib/git is an internal API we use to get the remote URL. - * See engine.prepare-options-helpers.ts for usage and upgrade risk documentation. - */ -declare module 'gh-pages/lib/git' { - class Git { - constructor(cwd: string, git?: string); - getRemoteUrl(remote?: string): Promise; - } - export = Git; -} diff --git a/src/tsconfig.build.json b/src/tsconfig.build.json index 3f777d5..0b245b3 100644 --- a/src/tsconfig.build.json +++ b/src/tsconfig.build.json @@ -4,5 +4,5 @@ "declaration": true, "types": ["node"] }, - "exclude": ["node_modules", "dist", "**/*spec.ts", "vitest.config.ts"] + "exclude": ["node_modules", "dist", "**/*spec.ts", "vitest.config.mts"] } diff --git a/src/vitest.config.ts b/src/vitest.config.mts similarity index 51% rename from src/vitest.config.ts rename to src/vitest.config.mts index 93d3940..97fb8e1 100644 --- a/src/vitest.config.ts +++ b/src/vitest.config.mts @@ -4,8 +4,8 @@ export default defineConfig({ test: { globals: true, environment: 'node', - // Run tests serially to avoid ghPages.clean() race conditions - // engine.gh-pages-clean.spec.ts calls ghPages.clean() which affects ALL cache dirs + // Run test files serially: several suites run real git commands and the + // standalone CLI against temporary repositories fileParallelism: false } }); From 5699a75b0a2b5a7819f650dd1cf428ee8080a29d Mon Sep 17 00:00:00 2001 From: Johannes Hoppe Date: Fri, 9 Oct 2026 23:30:49 +0200 Subject: [PATCH 02/17] feat: publish via gh-pages-fork instead of the gh-pages package src/gh-pages-fork is a minimal TypeScript fork of gh-pages (base: main at 48f62a7), internal to angular-cli-ghpages, with the upstream file structure and function names (lib/index.ts, lib/git.ts, lib/util.ts). It uses async/await throughout, has no dependencies and keeps only the options angular-cli-ghpages passes. This removes all workarounds around gh-pages: - the util.debuglog monkeypatch (the fork takes a log callback) - the CACHE_DIR fallback for directories without package.json (#203) - the beforeAdd cleanup hook (#204/#217); the fork removes all tracked files with git rm before copying dist - the callback-to-promise bridge (#205); publish() rejects on every error - the type declarations for gh-pages/lib/git Removes the gh-pages dependency and its tree (48 packages, including the braces advisory CVE-2026-93687). Deploy results are unchanged: 14 real-git scenarios (first deploy, redeploy, seeded branch with dotfiles and submodule, --add, --no-dotfiles, user handling, symlinks, push rejection, dry run, --remote) produce identical branches, commits and exit codes with 3.1.2 and this build. setupMonkeypatch and the GHPages/PublishOptions types stay exported as deprecated for API compatibility. Closes #215 --- src/engine/engine-filesystem.spec.ts | 170 +----- .../engine.prepare-options-helpers.spec.ts | 187 +----- src/engine/engine.prepare-options-helpers.ts | 231 +------ src/engine/engine.spec.ts | 220 ++----- src/engine/engine.ts | 122 +--- src/gh-pages-fork/LICENSE | 21 + src/gh-pages-fork/README.md | 90 +++ src/gh-pages-fork/lib/git.ts | 200 ++++++ src/gh-pages-fork/lib/index.ts | 177 ++++++ src/gh-pages-fork/lib/util.ts | 72 +++ src/gh-pages-fork/test/cache-dir.spec.ts | 76 +++ src/gh-pages-fork/test/git-commands.spec.ts | 165 +++++ src/gh-pages-fork/test/publish.spec.ts | 188 ++++++ src/interfaces.ts | 19 +- src/package-lock.json | 568 +----------------- src/package.json | 3 +- .../builder-integration.spec.ts | 65 +- src/parameter-tests/edge-cases.spec.ts | 5 - .../parameter-passthrough.spec.ts | 9 +- .../pr-186-commander-defaults.spec.ts | 8 - 20 files changed, 1145 insertions(+), 1451 deletions(-) create mode 100644 src/gh-pages-fork/LICENSE create mode 100644 src/gh-pages-fork/README.md create mode 100644 src/gh-pages-fork/lib/git.ts create mode 100644 src/gh-pages-fork/lib/index.ts create mode 100644 src/gh-pages-fork/lib/util.ts create mode 100644 src/gh-pages-fork/test/cache-dir.spec.ts create mode 100644 src/gh-pages-fork/test/git-commands.spec.ts create mode 100644 src/gh-pages-fork/test/publish.spec.ts diff --git a/src/engine/engine-filesystem.spec.ts b/src/engine/engine-filesystem.spec.ts index d0ee0f4..bc736ad 100644 --- a/src/engine/engine-filesystem.spec.ts +++ b/src/engine/engine-filesystem.spec.ts @@ -6,8 +6,8 @@ * - Dry-run mode prevents file creation * - Error handling works as expected * - * Note: CNAME and .nojekyll files are now handled by gh-pages v6+ via options. - * See "gh-pages v6 delegation" tests below for verification. + * CNAME and .nojekyll are written by the publish step of gh-pages-fork; + * the "publish options" tests below verify that the options reach it. */ import { logging } from '@angular-devkit/core'; @@ -17,18 +17,24 @@ import * as path from 'path'; import { MockInstance } from 'vitest'; import * as engine from './engine'; -import { cleanupMonkeypatch } from './engine.prepare-options-helpers'; +import * as ghpages from '../gh-pages-fork/lib'; import { pathExists } from '../utils'; +vi.mock('../gh-pages-fork/lib', async () => { + const actual = await vi.importActual('../gh-pages-fork/lib'); + return { + ...actual, + clean: vi.fn().mockResolvedValue(undefined), + publish: vi.fn().mockResolvedValue(undefined) + }; +}); + describe('engine - real filesystem tests', () => { const logger = new logging.Logger('test'); let testDir: string; let loggerInfoSpy: MockInstance; beforeEach(async () => { - // Clean up any previous monkeypatch so each test starts fresh - cleanupMonkeypatch(); - // Create a unique temp directory for each test const tmpBase = os.tmpdir(); const uniqueDir = `angular-cli-ghpages-test-${Date.now()}-${Math.random().toString(36).substring(7)}`; @@ -37,6 +43,8 @@ describe('engine - real filesystem tests', () => { // Spy on logger to capture warnings loggerInfoSpy = vi.spyOn(logger, 'info'); + vi.mocked(ghpages.clean).mockClear(); + vi.mocked(ghpages.publish).mockClear(); }); afterEach(async () => { @@ -47,11 +55,6 @@ describe('engine - real filesystem tests', () => { loggerInfoSpy.mockRestore(); }); - afterAll(() => { - // Clean up monkeypatch after all tests - cleanupMonkeypatch(); - }); - describe('404.html file creation', () => { it('should create 404.html as exact copy of index.html when notfound is true', async () => { // First create an index.html file @@ -59,14 +62,6 @@ describe('engine - real filesystem tests', () => { const indexContent = 'Test

Test App

'; await fs.writeFile(indexPath, indexContent); - const ghpages = require('gh-pages'); - vi.spyOn(ghpages, 'clean').mockImplementation(() => {}); - vi.spyOn(ghpages, 'publish').mockImplementation((_dir: unknown, _opts: unknown, callback?: (error: Error | null) => void) => { - if (callback) { - callback(null); - } - return Promise.resolve(undefined); - }); const options = { notfound: true, @@ -88,14 +83,6 @@ describe('engine - real filesystem tests', () => { const indexPath = path.join(testDir, 'index.html'); await fs.writeFile(indexPath, 'Test'); - const ghpages = require('gh-pages'); - vi.spyOn(ghpages, 'clean').mockImplementation(() => {}); - vi.spyOn(ghpages, 'publish').mockImplementation((_dir: unknown, _opts: unknown, callback?: (error: Error | null) => void) => { - if (callback) { - callback(null); - } - return Promise.resolve(undefined); - }); const options = { notfound: false, @@ -113,14 +100,6 @@ describe('engine - real filesystem tests', () => { it('should gracefully continue when index.html does not exist (not throw error)', async () => { // No index.html created - directory is empty - const ghpages = require('gh-pages'); - vi.spyOn(ghpages, 'clean').mockImplementation(() => {}); - vi.spyOn(ghpages, 'publish').mockImplementation((_dir: unknown, _opts: unknown, callback?: (error: Error | null) => void) => { - if (callback) { - callback(null); - } - return Promise.resolve(undefined); - }); const options = { notfound: true, @@ -147,56 +126,6 @@ describe('engine - real filesystem tests', () => { const indexPath = path.join(testDir, 'index.html'); await fs.writeFile(indexPath, 'Test'); - const ghpages = require('gh-pages'); - vi.spyOn(ghpages, 'clean').mockImplementation(() => {}); - - const options = { - notfound: true, - nojekyll: false, - dotfiles: true, - dryRun: true - }; - - await engine.run(testDir, options, logger); - - const notFoundPath = path.join(testDir, '404.html'); - const exists = await pathExists(notFoundPath); - expect(exists).toBe(false); - }); - }); - - /** - * gh-pages v6+ Delegation Tests - * - * gh-pages v6.1.0 added native support for creating CNAME and .nojekyll files: - * - See: https://github.com/tschaub/gh-pages/pull/533 - * - * We now delegate file creation to gh-pages via the cname/nojekyll options - * instead of creating them ourselves. This is cleaner and avoids duplication. - * - * What we're testing: - * - Verify we DO pass cname option to gh-pages when provided - * - Verify we DO pass nojekyll option to gh-pages when enabled - * - Verify 404.html is still created by us (gh-pages doesn't handle this) - */ - describe('gh-pages v6 delegation - cname and nojekyll', () => { - it('should pass cname option to gh-pages when provided', async () => { - const indexPath = path.join(testDir, 'index.html'); - await fs.writeFile(indexPath, 'test'); - - const ghpages = require('gh-pages'); - vi.spyOn(ghpages, 'clean').mockImplementation(() => {}); - - let capturedOptions: { cname?: string; nojekyll?: boolean } = {}; - const publishSpy = vi.spyOn(ghpages, 'publish').mockImplementation( - (_dir: string, options: { cname?: string; nojekyll?: boolean }, callback?: (error: Error | null) => void) => { - capturedOptions = options; - if (callback) { - callback(null); - } - return Promise.resolve(); - } - ); const testDomain = 'example.com'; const options = { @@ -208,27 +137,15 @@ describe('engine - real filesystem tests', () => { await engine.run(testDir, options, logger); - expect(publishSpy).toHaveBeenCalled(); + expect(ghpages.publish).toHaveBeenCalled(); + const capturedOptions = vi.mocked(ghpages.publish).mock.calls[0][1]; expect(capturedOptions.cname).toBe(testDomain); }); - it('should pass nojekyll option to gh-pages when enabled', async () => { + it('should pass nojekyll option to publish() when enabled', async () => { const indexPath = path.join(testDir, 'index.html'); await fs.writeFile(indexPath, 'test'); - const ghpages = require('gh-pages'); - vi.spyOn(ghpages, 'clean').mockImplementation(() => {}); - - let capturedOptions: { cname?: string; nojekyll?: boolean } = {}; - const publishSpy = vi.spyOn(ghpages, 'publish').mockImplementation( - (_dir: string, options: { cname?: string; nojekyll?: boolean }, callback?: (error: Error | null) => void) => { - capturedOptions = options; - if (callback) { - callback(null); - } - return Promise.resolve(); - } - ); const options = { nojekyll: true, @@ -238,7 +155,8 @@ describe('engine - real filesystem tests', () => { await engine.run(testDir, options, logger); - expect(publishSpy).toHaveBeenCalled(); + expect(ghpages.publish).toHaveBeenCalled(); + const capturedOptions = vi.mocked(ghpages.publish).mock.calls[0][1]; expect(capturedOptions.nojekyll).toBe(true); }); @@ -246,19 +164,6 @@ describe('engine - real filesystem tests', () => { const indexPath = path.join(testDir, 'index.html'); await fs.writeFile(indexPath, 'test'); - const ghpages = require('gh-pages'); - vi.spyOn(ghpages, 'clean').mockImplementation(() => {}); - - let capturedOptions: { cname?: string; nojekyll?: boolean } = {}; - const publishSpy = vi.spyOn(ghpages, 'publish').mockImplementation( - (_dir: string, options: { cname?: string; nojekyll?: boolean }, callback?: (error: Error | null) => void) => { - capturedOptions = options; - if (callback) { - callback(null); - } - return Promise.resolve(); - } - ); const testDomain = 'test.example.com'; const options = { @@ -270,11 +175,12 @@ describe('engine - real filesystem tests', () => { await engine.run(testDir, options, logger); - expect(publishSpy).toHaveBeenCalled(); + expect(ghpages.publish).toHaveBeenCalled(); + const capturedOptions = vi.mocked(ghpages.publish).mock.calls[0][1]; expect(capturedOptions.cname).toBe(testDomain); expect(capturedOptions.nojekyll).toBe(true); - // Verify 404.html is still created by us (not delegated to gh-pages) + // Verify 404.html is still created by us (not delegated to publish()) const notFoundPath = path.join(testDir, '404.html'); expect(await pathExists(notFoundPath)).toBe(true); }); @@ -283,19 +189,6 @@ describe('engine - real filesystem tests', () => { const indexPath = path.join(testDir, 'index.html'); await fs.writeFile(indexPath, 'test'); - const ghpages = require('gh-pages'); - vi.spyOn(ghpages, 'clean').mockImplementation(() => {}); - - let capturedOptions: { cname?: string; nojekyll?: boolean } = {}; - const publishSpy = vi.spyOn(ghpages, 'publish').mockImplementation( - (_dir: string, options: { cname?: string; nojekyll?: boolean }, callback?: (error: Error | null) => void) => { - capturedOptions = options; - if (callback) { - callback(null); - } - return Promise.resolve(); - } - ); const options = { nojekyll: false, @@ -306,7 +199,8 @@ describe('engine - real filesystem tests', () => { await engine.run(testDir, options, logger); - expect(publishSpy).toHaveBeenCalled(); + expect(ghpages.publish).toHaveBeenCalled(); + const capturedOptions = vi.mocked(ghpages.publish).mock.calls[0][1]; expect(capturedOptions.cname).toBeUndefined(); }); @@ -314,19 +208,6 @@ describe('engine - real filesystem tests', () => { const indexPath = path.join(testDir, 'index.html'); await fs.writeFile(indexPath, 'test'); - const ghpages = require('gh-pages'); - vi.spyOn(ghpages, 'clean').mockImplementation(() => {}); - - let capturedOptions: { cname?: string; nojekyll?: boolean } = {}; - const publishSpy = vi.spyOn(ghpages, 'publish').mockImplementation( - (_dir: string, options: { cname?: string; nojekyll?: boolean }, callback?: (error: Error | null) => void) => { - capturedOptions = options; - if (callback) { - callback(null); - } - return Promise.resolve(); - } - ); const options = { nojekyll: false, @@ -336,7 +217,8 @@ describe('engine - real filesystem tests', () => { await engine.run(testDir, options, logger); - expect(publishSpy).toHaveBeenCalled(); + expect(ghpages.publish).toHaveBeenCalled(); + const capturedOptions = vi.mocked(ghpages.publish).mock.calls[0][1]; expect(capturedOptions.nojekyll).toBe(false); }); }); diff --git a/src/engine/engine.prepare-options-helpers.spec.ts b/src/engine/engine.prepare-options-helpers.spec.ts index 444c75a..ac137d4 100644 --- a/src/engine/engine.prepare-options-helpers.spec.ts +++ b/src/engine/engine.prepare-options-helpers.spec.ts @@ -58,64 +58,14 @@ describe('prepareOptions helpers - intensive tests', () => { process.env = originalEnv; }); - describe('setupMonkeypatch', () => { - let originalDebuglog: typeof import('util').debuglog; - - beforeEach(() => { - // First, clean up any previous monkeypatch state - helpers.cleanupMonkeypatch(); - const util = require('util'); - originalDebuglog = util.debuglog; - }); - - afterEach(() => { - // Use our cleanup function to properly restore state - helpers.cleanupMonkeypatch(); - }); - - it('should replace util.debuglog with custom implementation', () => { + describe('setupMonkeypatch (deprecated)', () => { + it('should leave util.debuglog untouched', () => { const util = require('util'); const debuglogBefore = util.debuglog; helpers.setupMonkeypatch(testLogger); - expect(util.debuglog).not.toBe(debuglogBefore); - }); - - it('should forward gh-pages debuglog calls to logger', () => { - helpers.setupMonkeypatch(testLogger); - - const util = require('util'); - const ghPagesLogger = util.debuglog('gh-pages'); - const testMessage = 'Test gh-pages message'; - - ghPagesLogger(testMessage); - - expect(infoSpy).toHaveBeenCalledWith(testMessage); - }); - - it('should format messages with placeholders before forwarding', () => { - helpers.setupMonkeypatch(testLogger); - - const util = require('util'); - const ghPagesLogger = util.debuglog('gh-pages'); - - ghPagesLogger('Publishing %d files to %s branch', 42, 'gh-pages'); - - expect(infoSpy).toHaveBeenCalledWith('Publishing 42 files to gh-pages branch'); - }); - - it('should call original debuglog for non-gh-pages modules', () => { - const util = require('util'); - const originalDebuglogSpy = vi.fn(originalDebuglog); - util.debuglog = originalDebuglogSpy; - - helpers.setupMonkeypatch(testLogger); - - const otherLogger = util.debuglog('some-other-module'); - - expect(originalDebuglogSpy).toHaveBeenCalledWith('some-other-module'); - expect(infoSpy).not.toHaveBeenCalled(); + expect(util.debuglog).toBe(debuglogBefore); }); }); @@ -581,24 +531,7 @@ describe('prepareOptions helpers - intensive tests', () => { }); - describe('getRemoteUrl - gh-pages/lib/git internal API', () => { - /** - * CRITICAL: This tests our dependency on gh-pages internal API - * - * These tests will BREAK if gh-pages changes: - * - gh-pages/lib/git module structure - * - Git class constructor signature - * - getRemoteUrl() method signature or behavior - * - * Testing approach: - * - We're IN a git repository, so getRemoteUrl() succeeds and returns a URL - * - We verify the function is callable and returns string URLs - * - We test error cases by using invalid parameters - * - * If these tests fail after upgrading gh-pages, see the WARNING - * comment in engine.prepare-options-helpers.ts for fallback options. - */ - + describe('getRemoteUrl', () => { /** * Environment assumptions for this test: * - Tests must be run from a git clone of angular-schule/angular-cli-ghpages @@ -630,15 +563,13 @@ describe('prepareOptions helpers - intensive tests', () => { remote: 'nonexistent-remote-12345' // Remote that definitely doesn't exist }; - // Expected message from gh-pages v6.3.0 (lib/git.js) - // If this fails after upgrading gh-pages, the internal API changed await expect(helpers.getRemoteUrl(options)) .rejects .toThrow('Failed to get remote.nonexistent-remote-12345.url (task must either be run in a git repository with a configured nonexistent-remote-12345 remote or must be configured with the "repo" option).'); }); it('should throw helpful error when not in a git repository', async () => { - // Change to a non-git directory (also incidentally exercises ensureGhPagesCacheDir via engine.run elsewhere) + // Change to a non-git directory const originalCwd = process.cwd(); const fs = require('fs/promises'); const tempDir = path.join(require('os').tmpdir(), 'not-a-git-repo-test-' + Date.now()); @@ -648,8 +579,7 @@ describe('prepareOptions helpers - intensive tests', () => { process.chdir(tempDir); const options = { remote: 'origin' }; - // Expected message from gh-pages v6.3.0 (lib/git.js) - // Note: gh-pages returns same error for both "not in git repo" and "remote doesn't exist" + // Same error for both "not in a git repo" and "remote doesn't exist" await expect(helpers.getRemoteUrl(options)) .rejects .toThrow('Failed to get remote.origin.url (task must either be run in a git repository with a configured origin remote or must be configured with the "repo" option).'); @@ -659,109 +589,4 @@ describe('prepareOptions helpers - intensive tests', () => { } }); }); - - describe('ensureGhPagesCacheDir - find-cache-dir fallback (issue #203)', () => { - // Each test must explicitly manage CACHE_DIR because the outer beforeEach - // clones `originalEnv` but does NOT delete CACHE_DIR. - beforeEach(() => { - delete process.env.CACHE_DIR; - }); - - it('sets CACHE_DIR to an os.tmpdir() fallback when cwd has no package.json', async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'ghp-203-unit-')); - try { - helpers.ensureGhPagesCacheDir(tmp); - expect(process.env.CACHE_DIR).toBeDefined(); - // Must be inside os.tmpdir() and non-empty - expect(process.env.CACHE_DIR!.startsWith(os.tmpdir())).toBe(true); - } finally { - await fs.rm(tmp, { recursive: true, force: true }); - } - }); - - it('does NOT set CACHE_DIR when cwd has a reachable package.json', () => { - // This repo root (parent of __dirname) has a package.json, so find-cache-dir succeeds. - const repoDir = path.resolve(__dirname, '..'); - helpers.ensureGhPagesCacheDir(repoDir); - expect(process.env.CACHE_DIR).toBeUndefined(); - }); - - it('respects a user-set CACHE_DIR (does not overwrite)', async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'ghp-203-respect-')); - try { - const userChosen = '/user/chosen/cache'; - process.env.CACHE_DIR = userChosen; - helpers.ensureGhPagesCacheDir(tmp); - expect(process.env.CACHE_DIR).toBe(userChosen); - } finally { - await fs.rm(tmp, { recursive: true, force: true }); - } - }); - - it('DOES apply fallback when CACHE_DIR is a boolean-ish value (matches find-cache-dir semantics)', async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'ghp-203-boolish-')); - try { - process.env.CACHE_DIR = 'true'; // find-cache-dir treats this as "unset" - helpers.ensureGhPagesCacheDir(tmp); - expect(process.env.CACHE_DIR).not.toBe('true'); - expect(process.env.CACHE_DIR!.startsWith(os.tmpdir())).toBe(true); - } finally { - await fs.rm(tmp, { recursive: true, force: true }); - } - }); - - it('fallback path is inside os.tmpdir() and includes our namespace', async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'ghp-203-ns-')); - try { - helpers.ensureGhPagesCacheDir(tmp); - expect(process.env.CACHE_DIR).toBeDefined(); - expect(process.env.CACHE_DIR!).toBe(path.join(os.tmpdir(), 'angular-cli-ghpages-cache')); - } finally { - await fs.rm(tmp, { recursive: true, force: true }); - } - }); - }); - - describe('createCleanupBeforeAddHook', () => { - let distDir: string; - - beforeEach(async () => { - distDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ghp-hook-')); - await fs.writeFile(path.join(distDir, 'index.html'), ''); - }); - - afterEach(async () => { - await fs.rm(distDir, { recursive: true, force: true }); - }); - - function createGit(tracked: string[]) { - const git: helpers.GhPagesGit = { - cwd: '/clone', - output: '', - exec: vi.fn(async () => { - git.output = tracked.join('\0'); - return git; - }), - rm: vi.fn(async () => git) - }; - return git; - } - - it('keeps generated files that are tracked but not in dist', async () => { - const git = createGit(['.nojekyll', 'CNAME', 'index.html']); - const generatedFiles = ['.nojekyll', 'CNAME']; - - await helpers.createCleanupBeforeAddHook(distDir, true, testLogger, generatedFiles)(git); - - expect(git.rm).not.toHaveBeenCalled(); - }); - - it('removes tracked files that are neither in dist nor generated', async () => { - const git = createGit(['.nojekyll', 'index.html', 'stale.html']); - - await helpers.createCleanupBeforeAddHook(distDir, true, testLogger, [])(git); - - expect(git.rm).toHaveBeenCalledWith(['.nojekyll', 'stale.html']); - }); - }); }); diff --git a/src/engine/engine.prepare-options-helpers.ts b/src/engine/engine.prepare-options-helpers.ts index 793d357..982d2bb 100644 --- a/src/engine/engine.prepare-options-helpers.ts +++ b/src/engine/engine.prepare-options-helpers.ts @@ -6,14 +6,9 @@ */ import { logging } from '@angular-devkit/core'; -import * as fs from 'fs/promises'; -import * as os from 'os'; -import * as path from 'path'; -import * as util from 'util'; import { Schema } from '../deploy/schema'; -// Internal API dependency - by design. See getRemoteUrl() JSDoc for rationale and fallback options. -import Git from 'gh-pages/lib/git'; +import { Git } from '../gh-pages-fork/lib/git'; /** * Type for options with the three boolean flags that prepareOptions adds, @@ -23,119 +18,16 @@ export type PreparedOptions = Schema & { dotfiles: boolean; notfound: boolean; nojekyll: boolean; + git?: string; user?: { name: string; email: string }; }; -// Store original debuglog for cleanup (using CommonJS require for mutable access) -// eslint-disable-next-line @typescript-eslint/no-var-requires -const utilMutable = require('util'); -let originalDebuglog: typeof util.debuglog | null = null; - /** - * Setup monkeypatch for util.debuglog to intercept gh-pages logging - * - * gh-pages uses util.debuglog('gh-pages') internally for all verbose logging. - * We intercept it and forward to Angular logger instead of stderr. - * - * CRITICAL: This must be called BEFORE requiring gh-pages, otherwise gh-pages - * will cache the original util.debuglog and our interception won't work. - * - * NOTE: We use require('util') instead of ES import because ES module namespace - * objects are read-only. CommonJS require returns a mutable object that we can patch. + * @deprecated No-op. Kept for API compatibility; the deploy engine passes its + * logger to the publish step directly. Will be removed in the next major version. */ -export function setupMonkeypatch(logger: logging.LoggerApi): void { - // Guard against multiple calls - only patch once - // If we're already patched, just return (prevents stack overflow from recursive calls) - if (originalDebuglog !== null) { - return; - } - - originalDebuglog = utilMutable.debuglog; - - utilMutable.debuglog = (set: string) => { - // gh-pages uses util.debuglog('gh-pages') internally for all verbose logging - // Intercept it and forward to Angular logger instead of stderr - if (set === 'gh-pages') { - return function (...args: unknown[]) { - const message = util.format.apply(util, args); - logger.info(message); - }; - } - return originalDebuglog!(set); - }; -} - -/** - * Cleanup monkeypatch - restore original util.debuglog - * Exported for testing - */ -export function cleanupMonkeypatch(): void { - if (originalDebuglog) { - utilMutable.debuglog = originalDebuglog; - originalDebuglog = null; - } -} - -/** - * Ensure gh-pages' internal `find-cache-dir` call resolves to a real path. - * - * gh-pages@6.3.0 calls `findCacheDir({ name: 'gh-pages' })` for both `clean()` - * and `publish()`. `find-cache-dir` walks up from cwd looking for a - * `package.json`; if none is found (e.g. `npx angular-cli-ghpages` run in a - * repo that is just a `dist/` folder), it returns `undefined`, and gh-pages - * then blows up with `TypeError: The "path" argument must be of type string - * ... Received undefined` inside `path.join(undefined, ...)`. - * - * Workaround: `find-cache-dir` honors the `CACHE_DIR` env var β€” if set (and - * not a boolean-ish value), it returns `path.join(CACHE_DIR, name)` without - * any package.json lookup. We set it to an os.tmpdir() fallback, but only - * when (a) the user hasn't already set it themselves, and (b) no package.json - * is reachable from cwd. See issue #203. - * - * We don't use find-cache-dir to probe because it captures `process.env` by - * reference at module load and is then cached in `require.cache`; that makes - * it brittle under test reassignment of `process.env`. - * - * MUST run before gh-pages' `clean()` / `publish()` invoke find-cache-dir. - */ -export function ensureGhPagesCacheDir(cwd: string = process.cwd()): void { - // Respect a user-set CACHE_DIR. find-cache-dir itself treats boolean-ish - // values as "not set" (it opts out of its early return), so mirror that. - const existing = process.env.CACHE_DIR; - if (existing && !['true', 'false', '1', '0'].includes(existing)) { - return; - } - - if (hasReachablePackageJson(cwd)) { - return; - } - - // Stable, per-user fallback location. gh-pages itself creates - // `/gh-pages/` underneath this. - process.env.CACHE_DIR = path.join(os.tmpdir(), 'angular-cli-ghpages-cache'); -} - -/** - * Walk up from `startDir` looking for a `package.json`. Mirrors what - * pkg-dir/find-cache-dir do internally, but avoids their module-level - * `process.env` caching (see `ensureGhPagesCacheDir`). - */ -function hasReachablePackageJson(startDir: string): boolean { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const fsSync = require('fs'); - let dir = path.resolve(startDir); - // Safety cap against pathological infinite loops; real trees are <50 deep. - for (let i = 0; i < 50; i++) { - if (fsSync.existsSync(path.join(dir, 'package.json'))) { - return true; - } - const parent = path.dirname(dir); - if (parent === dir) { - return false; - } - dir = parent; - } - return false; +export function setupMonkeypatch(_logger: logging.LoggerApi): void { + // intentionally empty } /** @@ -291,116 +183,13 @@ export async function injectTokenIntoRepoUrl(options: PreparedOptions): Promise< } /** - * Minimal subset of the gh-pages internal Git class that our cleanup hook relies on. - * See src/node_modules/gh-pages/lib/git.js β€” `Git.prototype.exec`, `Git.prototype.rm`, `cwd`, `output`. - */ -export interface GhPagesGit { - cwd: string; - output: string; - exec(...args: string[]): Promise; - rm(files: string[]): Promise; -} - -/** - * Recursively walk a directory and return the set of relative file paths, - * using POSIX separators so the output matches what `git ls-files` prints. - * Honors the same dotfile-inclusion semantics as gh-pages' `options.dotfiles`. - */ -export async function collectDistFiles( - baseDir: string, - includeDotfiles: boolean -): Promise> { - const files = new Set(); - - async function walk(relDir: string): Promise { - const fullDir = relDir ? path.join(baseDir, relDir) : baseDir; - const entries = await fs.readdir(fullDir, { withFileTypes: true }); - for (const entry of entries) { - if (!includeDotfiles && entry.name.startsWith('.')) { - continue; - } - const relPath = relDir ? `${relDir}/${entry.name}` : entry.name; - if (entry.isDirectory()) { - await walk(relPath); - } else if (entry.isFile()) { - files.add(relPath); - } - } - } - - await walk(''); - return files; -} - -/** - * Create a `beforeAdd` hook that removes leftover files from the gh-pages branch - * before gh-pages stages our dist for commit. - * - * Why this exists (issue #204): - * gh-pages@6.3.0's "Removing files" step calls globby without `dot: true`, so - * dotfiles (.gitignore, .gitmodules, .github/…) and submodule gitlinks from the - * gh-pages branch are NOT removed before our dist is copied on top. They then - * get re-committed and leak into the deploy. - * - * Fix: after gh-pages' broken remove + our file copy, ask git what it still has - * indexed (`git ls-files -z`), diff against the set of files in our dist, and - * `git rm` the leftovers. `git rm` correctly handles submodule gitlinks too. - * - * `generatedFiles` lists files that gh-pages itself writes into the clone - * (`.nojekyll`, `CNAME`). They never exist in dist, so they are kept explicitly; - * otherwise a redeploy would remove the `.nojekyll` that gh-pages just created. - * - * Upstream fix: tschaub/gh-pages#612 (merged 2025-08-09, unreleased as of - * gh-pages@6.3.0). When a release containing that PR lands, this hook becomes - * redundant and can be removed. - */ -export function createCleanupBeforeAddHook( - distDir: string, - dotfiles: boolean, - logger: logging.LoggerApi, - generatedFiles: string[] -): (git: GhPagesGit) => Promise { - return async (git) => { - const distFiles = await collectDistFiles(distDir, dotfiles); - await git.exec('ls-files', '-z'); - const tracked = (git.output || '').split('\0').filter(Boolean); - const toRemove = tracked.filter((f) => !distFiles.has(f) && !generatedFiles.includes(f)); - if (toRemove.length === 0) { - return; - } - logger.info( - `Removing ${toRemove.length} leftover file(s) from gh-pages branch not in dist: ${toRemove.join(', ')}` - ); - await git.rm(toRemove); - }; -} - -/** - * Get the remote URL from the git repository - * - * ⚠️ WARNING: This uses gh-pages internal API (gh-pages/lib/git) - * - * UPGRADE RISK: - * - This function depends on gh-pages/lib/git which is an internal module - * - Not part of gh-pages public API - could break in any version - * - When upgrading gh-pages, verify this still works: - * 1. Check if gh-pages/lib/git still exists - * 2. Check if Git class constructor signature is unchanged - * 3. Check if getRemoteUrl() method still exists and works - * - * FALLBACK OPTIONS if this breaks: - * - Option 1: Shell out to `git config --get remote.origin.url` directly - * - Option 2: Use a dedicated git library (simple-git, nodegit) - * - Option 3: Require users to always pass --repo explicitly + * Get the URL of `options.remote` from the git repository in the current working directory. * - * IMPORTANT: This function expects options.remote to be set (our defaults provide 'origin') - * It should NOT be called with undefined remote, as gh-pages will convert it to string "undefined" + * process.cwd() is the directory from which ng deploy was invoked, i.e. the project root. + * Expects options.remote to be set (our defaults provide 'origin'). * * Exported for testing - internal use only */ export async function getRemoteUrl(options: Schema & { git?: string; remote?: string }): Promise { - // process.cwd() returns the directory from which ng deploy was invoked. - // This is the expected behavior - users run ng deploy from their project root. - const git = new Git(process.cwd(), options.git); - return await git.getRemoteUrl(options.remote); + return new Git(process.cwd(), options.git).getRemoteUrl(options.remote ?? 'origin'); } diff --git a/src/engine/engine.spec.ts b/src/engine/engine.spec.ts index 62d14a5..9563543 100644 --- a/src/engine/engine.spec.ts +++ b/src/engine/engine.spec.ts @@ -1,9 +1,8 @@ import { logging } from '@angular-devkit/core'; -import { Mock, MockInstance } from 'vitest'; +import { Mock } from 'vitest'; import * as engine from './engine'; -import { cleanupMonkeypatch } from './engine.prepare-options-helpers'; // Mock utils.pathExists at module level vi.mock('../utils', async () => { @@ -16,14 +15,23 @@ vi.mock('../utils', async () => { import { pathExists } from '../utils'; +vi.mock('../gh-pages-fork/lib', async () => { + const actual = await vi.importActual('../gh-pages-fork/lib'); + return { + ...actual, + clean: vi.fn(), + publish: vi.fn() + }; +}); + +import * as ghpages from '../gh-pages-fork/lib'; + describe('engine', () => { describe('prepareOptions', () => { const logger = new logging.NullLogger(); const originalEnv = process.env; beforeEach(() => { - // Clean up any previous monkeypatch so each test starts fresh - cleanupMonkeypatch(); // Create fresh copy of environment for each test // This preserves PATH, HOME, etc. needed by git @@ -38,8 +46,6 @@ describe('engine', () => { }); afterAll(() => { - // Clean up monkeypatch after all tests - cleanupMonkeypatch(); // Restore original environment for other test files process.env = originalEnv; }); @@ -261,207 +267,63 @@ describe('engine', () => { }); }); - describe('run - gh-pages Promise error handling', () => { - // gh-pages v5+ supports Promise-based API (fixed the bug where early errors didn't reject) - // We now use await ghPages.publish() directly instead of callback-based approach + describe('run - publish error handling', () => { const logger = new logging.NullLogger(); - - let ghpagesCleanSpy: MockInstance; - let ghpagesPublishSpy: MockInstance; + const testDir = '/test/dist'; + const options = { dotfiles: true, notfound: true, nojekyll: true }; beforeEach(() => { - // Setup persistent mocks for utils.pathExists vi.mocked(pathExists).mockResolvedValue(true); - - // Setup persistent mocks for gh-pages - const ghpages = require('gh-pages'); - ghpagesCleanSpy = vi.spyOn(ghpages, 'clean').mockImplementation(() => {}); - ghpagesPublishSpy = vi.spyOn(ghpages, 'publish'); + vi.mocked(ghpages.clean).mockResolvedValue(undefined); }); afterEach(() => { - // Clean up spies vi.mocked(pathExists).mockReset(); - ghpagesCleanSpy.mockRestore(); - ghpagesPublishSpy.mockRestore(); + vi.mocked(ghpages.clean).mockReset(); + vi.mocked(ghpages.publish).mockReset(); }); - // engine uses the callback form of gh-pages.publish() β€” see #205 - const mockPublishCallback = (error: Error | null) => - (_dir: unknown, _opts: unknown, callback?: (err: Error | null) => void) => { - if (callback) { - callback(error); - } - return Promise.resolve(undefined); - }; - - it('should reject when gh-pages.publish rejects with error', async () => { + it('should reject with the error of publish()', async () => { const publishError = new Error('Git push failed: permission denied'); + vi.mocked(ghpages.publish).mockRejectedValue(publishError); - ghpagesPublishSpy.mockImplementation(mockPublishCallback(publishError)); - - const testDir = '/test/dist'; - const options = { dotfiles: true, notfound: true, nojekyll: true }; - - await expect( - engine.run(testDir, options, logger) - ).rejects.toThrow('Git push failed: permission denied'); - }); - - it('should preserve error message through rejection', async () => { - const detailedError = new Error('Remote url mismatch. Expected https://github.com/user/repo.git but got https://github.com/other/repo.git'); - - ghpagesPublishSpy.mockImplementation(mockPublishCallback(detailedError)); - - const testDir = '/test/dist'; - const options = { dotfiles: true, notfound: true, nojekyll: true }; - - await expect( - engine.run(testDir, options, logger) - ).rejects.toThrow(detailedError); - }); - - it('should reject with authentication error from gh-pages', async () => { - const authError = new Error('Authentication failed: Invalid credentials'); - - ghpagesPublishSpy.mockImplementation(mockPublishCallback(authError)); - - const testDir = '/test/dist'; - const options = { dotfiles: true, notfound: true, nojekyll: true }; - - await expect( - engine.run(testDir, options, logger) - ).rejects.toThrow('Authentication failed: Invalid credentials'); - }); - - it('should resolve successfully when gh-pages.publish resolves', async () => { - ghpagesPublishSpy.mockImplementation(mockPublishCallback(null)); - - const testDir = '/test/dist'; - const options = { dotfiles: true, notfound: true, nojekyll: true }; - - await expect( - engine.run(testDir, options, logger) - ).resolves.toBeUndefined(); - }); - }); - - describe('prepareOptions - monkeypatch verification', () => { - beforeEach(() => { - // Clean up monkeypatch before each test to start fresh - cleanupMonkeypatch(); - }); - - afterEach(() => { - // Clean up monkeypatch after each test - cleanupMonkeypatch(); + await expect(engine.run(testDir, options, logger)).rejects.toBe(publishError); }); - it('should replace util.debuglog with custom implementation', async () => { - const testLogger = new logging.Logger('test'); - const util = require('util'); - const debuglogBeforePrepare = util.debuglog; - - await engine.prepareOptions({}, testLogger); + it('should resolve when publish() resolves', async () => { + vi.mocked(ghpages.publish).mockResolvedValue(undefined); - // After prepareOptions, util.debuglog should be replaced - expect(util.debuglog).not.toBe(debuglogBeforePrepare); + await expect(engine.run(testDir, options, logger)).resolves.toBeUndefined(); }); - it('should forward gh-pages debuglog calls to Angular logger', async () => { - const testLogger = new logging.Logger('test'); - const infoSpy = vi.spyOn(testLogger, 'info'); - - await engine.prepareOptions({}, testLogger); - - // Now get the patched debuglog for 'gh-pages' - const util = require('util'); - const ghPagesLogger = util.debuglog('gh-pages'); - - // Call it with a test message - const testMessage = 'Publishing to gh-pages branch'; - ghPagesLogger(testMessage); - - // Should have forwarded to logger.info() - expect(infoSpy).toHaveBeenCalledWith(testMessage); - }); - - it('should forward gh-pages debuglog calls with formatting to Angular logger', async () => { - const testLogger = new logging.Logger('test'); - const infoSpy = vi.spyOn(testLogger, 'info'); - - await engine.prepareOptions({}, testLogger); - - const util = require('util'); - const ghPagesLogger = util.debuglog('gh-pages'); + it('should clean the cache before publishing', async () => { + const calls: string[] = []; + vi.mocked(ghpages.clean).mockImplementation(async () => { calls.push('clean'); }); + vi.mocked(ghpages.publish).mockImplementation(async () => { calls.push('publish'); }); - // Test with util.format style placeholders - ghPagesLogger('Pushing %d files to %s', 42, 'gh-pages'); + await engine.run(testDir, options, logger); - // Should format the message and forward to logger.info() - expect(infoSpy).toHaveBeenCalledWith('Pushing 42 files to gh-pages'); + expect(calls).toEqual(['clean', 'publish']); }); - it('should call original debuglog for non-gh-pages modules', async () => { - const testLogger = new logging.Logger('test'); - const infoSpy = vi.spyOn(testLogger, 'info'); - - const util = require('util'); - const originalDebuglogFn = util.debuglog; - const originalDebuglogSpy = vi.fn(originalDebuglogFn); - util.debuglog = originalDebuglogSpy; - - await engine.prepareOptions({}, testLogger); - - // Now util.debuglog is patched - const otherModuleLogger = util.debuglog('some-other-module'); + it('should neither clean nor publish during dry-run', async () => { + await engine.run(testDir, { ...options, dryRun: true }, logger); - // Should have called the original debuglog (via our spy) - expect(originalDebuglogSpy).toHaveBeenCalledWith('some-other-module'); - - // Should NOT have forwarded to Angular logger - expect(infoSpy).not.toHaveBeenCalled(); + expect(ghpages.clean).not.toHaveBeenCalled(); + expect(ghpages.publish).not.toHaveBeenCalled(); }); - it('should monkeypatch util.debuglog before requiring gh-pages', async () => { - // This test verifies the critical ordering requirement: - // The monkeypatch MUST occur before requiring gh-pages, otherwise gh-pages caches - // the original util.debuglog and our interception won't work. - + it('should forward publish() log messages to the logger', async () => { const testLogger = new logging.Logger('test'); const infoSpy = vi.spyOn(testLogger, 'info'); + const message = 'Cloning https://github.com/test/repo.git into /cache'; + vi.mocked(ghpages.publish).mockImplementation(async (_dir, _options, log) => { + log?.(message); + }); - // Clear gh-pages from require cache to simulate fresh load - const ghPagesPath = require.resolve('gh-pages'); - delete require.cache[ghPagesPath]; - - await engine.prepareOptions({}, testLogger); - - // Now require gh-pages for the first time (after monkeypatch) - require('gh-pages'); - - // Verify our patched debuglog('gh-pages') forwards to the logger - const util = require('util'); - const ghPagesLogger = util.debuglog('gh-pages'); - ghPagesLogger('test message'); - expect(infoSpy).toHaveBeenCalledWith('test message'); - }); - - it('should restore original util.debuglog when cleanupMonkeypatch is called', async () => { - const testLogger = new logging.Logger('test'); - const util = require('util'); - const debuglogBeforeSetup = util.debuglog; - - await engine.prepareOptions({}, testLogger); - - // After prepareOptions, util.debuglog should be patched - expect(util.debuglog).not.toBe(debuglogBeforeSetup); - - // Call cleanup - cleanupMonkeypatch(); + await engine.run(testDir, options, testLogger); - // After cleanup, util.debuglog should be restored - expect(util.debuglog).toBe(debuglogBeforeSetup); + expect(infoSpy).toHaveBeenCalledWith(message); }); }); }); diff --git a/src/engine/engine.ts b/src/engine/engine.ts index 67f8d57..c0c1a66 100644 --- a/src/engine/engine.ts +++ b/src/engine/engine.ts @@ -3,19 +3,16 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import {Schema} from '../deploy/schema'; +import * as ghpages from '../gh-pages-fork/lib'; import {pathExists} from '../utils'; -import {GHPages, PublishOptions} from '../interfaces'; import {defaults} from './defaults'; import { PreparedOptions, - setupMonkeypatch, mapNegatedBooleans, handleUserCredentials, warnDeprecatedParameters, appendCIMetadata, - injectTokenIntoRepoUrl, - createCleanupBeforeAddHook, - ensureGhPagesCacheDir + injectTokenIntoRepoUrl } from './engine.prepare-options-helpers'; export async function run( @@ -25,32 +22,16 @@ export async function run( ) { const prepared = await prepareOptions(options, logger); - // Provide a cache-dir fallback when there's no package.json in cwd - // (e.g. `npx angular-cli-ghpages` in a static-content repo). Without this, - // gh-pages' internal find-cache-dir returns undefined and path.join throws. - // See issue #203. Must run before the first find-cache-dir call. - ensureGhPagesCacheDir(); - - // CRITICAL: Must require gh-pages AFTER monkeypatching util.debuglog - // gh-pages calls util.debuglog('gh-pages') during module initialization to set up its logger. - // If we require gh-pages before monkeypatching, it caches the original util.debuglog, - // and our monkeypatch won't capture the logging output. - // See prepareOptions() for the monkeypatch implementation. - // Note: Dynamic import required for monkeypatch timing (static import would cache before patch) - const ghpages = require('gh-pages'); - - // always clean the cache directory. - // avoids "Error: Remote url mismatch." + // Every deploy starts from a fresh clone of the target branch if (prepared.dryRun) { logger.info('Dry-run / SKIPPED: cleaning of the cache directory'); } else { - ghpages.clean(); + await ghpages.clean(); } await checkIfDistFolderExists(dir); await createNotFoundFile(dir, prepared, logger); - // gh-pages writes CNAME and .nojekyll into its clone (cname/nojekyll options) - await publishViaGhPages(ghpages, dir, prepared, logger); + await publishViaGhPages(dir, prepared, logger); if (!prepared.dryRun) { logger.info( @@ -64,13 +45,12 @@ export async function run( * * This orchestrator function: * 1. Merges defaults with user options - * 2. Sets up monkeypatch for gh-pages logging - * 3. Maps negated boolean options (noDotfiles β†’ dotfiles) - * 4. Handles user credentials - * 5. Warns about deprecated parameters - * 6. Appends CI environment metadata - * 7. Discovers and injects remote URL with authentication tokens - * 8. Logs dry-run message if applicable + * 2. Maps negated boolean options (noDotfiles β†’ dotfiles) + * 3. Handles user credentials + * 4. Warns about deprecated parameters + * 5. Appends CI environment metadata + * 6. Discovers and injects remote URL with authentication tokens + * 7. Logs dry-run message if applicable */ export async function prepareOptions( origOptions: Schema, @@ -82,25 +62,22 @@ export async function prepareOptions( ...origOptions }; - // 2. Setup monkeypatch for gh-pages logging (MUST be before requiring gh-pages) - setupMonkeypatch(logger); - - // 3. Map negated boolean options + // 2. Map negated boolean options mapNegatedBooleans(options, origOptions); - // 4. Handle user credentials + // 3. Handle user credentials handleUserCredentials(options, origOptions, logger); - // 5. Warn about deprecated parameters + // 4. Warn about deprecated parameters warnDeprecatedParameters(origOptions, logger); - // 6. Append CI environment metadata + // 5. Append CI environment metadata appendCIMetadata(options); - // 7. Discover and inject remote URL with authentication tokens + // 6. Discover and inject remote URL with authentication tokens await injectTokenIntoRepoUrl(options); - // 8. Log dry-run message if applicable + // 7. Log dry-run message if applicable if (options.dryRun) { logger.info('Dry-run: No changes are applied at all.'); } @@ -150,24 +127,7 @@ async function createNotFoundFile( } } -/** - * Files that gh-pages writes into its clone (not into dist) for the given options. - * gh-pages' own remove step already unstages CNAME before rewriting it; listing it - * here keeps the cleanup hook correct independently of that detail. - */ -function generatedFiles(options: PreparedOptions): string[] { - const files: string[] = []; - if (options.nojekyll) { - files.push('.nojekyll'); - } - if (options.cname) { - files.push('CNAME'); - } - return files; -} - async function publishViaGhPages( - ghPages: GHPages, dir: string, options: PreparedOptions, logger: logging.LoggerApi @@ -203,36 +163,20 @@ async function publishViaGhPages( logger.info('πŸš€ Uploading via git, please wait...'); - // Only pass options that gh-pages understands - // gh-pages v6+ supports cname and nojekyll options natively (PR #533) - const ghPagesOptions: PublishOptions = { - repo: options.repo, - branch: options.branch, - message: options.message, - remote: options.remote, - git: options.git as string | undefined, - add: options.add, - dotfiles: options.dotfiles, - user: options.user, - cname: options.cname, - nojekyll: options.nojekyll, - // Workaround for gh-pages#612 (unreleased in v6.3.0): clean up leftover - // gh-pages branch files (dotfiles, submodule gitlinks) that the broken - // remove step misses. Skipped when the user opts into additive mode. - beforeAdd: options.add ? undefined : createCleanupBeforeAddHook(dir, options.dotfiles, logger, generatedFiles(options)) - }; - - // gh-pages@6 silently absorbs errors via its internal .then(_, onRejected) - // handler (and returns undefined from some early-exit paths, upstream #465). - // The callback always fires with the error, so we bridge that to a rejection. - // See issue #205. - await new Promise((resolve, reject) => { - ghPages.publish(dir, ghPagesOptions, (error: Error | null) => { - if (error) { - reject(error); - return; - } - resolve(); - }); - }); + await ghpages.publish( + dir, + { + repo: options.repo, + branch: options.branch, + message: options.message, + remote: options.remote, + git: options.git, + add: options.add, + dotfiles: options.dotfiles, + user: options.user, + cname: options.cname, + nojekyll: options.nojekyll + }, + (message) => logger.info(message) + ); } diff --git a/src/gh-pages-fork/LICENSE b/src/gh-pages-fork/LICENSE new file mode 100644 index 0000000..44ab001 --- /dev/null +++ b/src/gh-pages-fork/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2014 Tim Schaub + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/gh-pages-fork/README.md b/src/gh-pages-fork/README.md new file mode 100644 index 0000000..66bf8c4 --- /dev/null +++ b/src/gh-pages-fork/README.md @@ -0,0 +1,90 @@ +# gh-pages Fork + +**Minimal TypeScript fork of [gh-pages](https://github.com/tschaub/gh-pages) for angular-cli-ghpages.** + +## 🚨 INTERNAL USE ONLY + +**This fork exists SOLELY for angular-cli-ghpages and will NEVER be a general-purpose library.** + +- ❌ NOT for public adoption or use by other projects +- ❌ NOT a community fork +- ❌ NO migration support or guidance +- ❌ NO feature requests accepted +- βœ… ONLY maintained for angular-cli-ghpages internal needs + +**If you need gh-pages, use the official upstream library:** https://github.com/tschaub/gh-pages + +--- + +⚠️ **This is NOT a drop-in replacement for gh-pages** + +This fork keeps only the publish flow that angular-cli-ghpages uses. It has no CLI, no callback API and no third-party dependencies (only Node.js built-ins). + +## Structure + +The file structure and function names follow upstream, so upstream changes can be compared and ported file by file: + +| Upstream | Fork | Contents | +| -------------- | -------------- | ---------------------------------------------------------------------- | +| `lib/index.js` | `lib/index.ts` | `publish()`, `clean()`, `getCacheDir()`, `getRepo()`, `defaults` | +| `lib/git.js` | `lib/git.ts` | `Git` class, `ProcessError`, `spawn()` | +| `lib/util.js` | `lib/util.ts` | `copy()`, `getUser()`, plus `listFiles()` instead of a glob library | + +## What This Fork Contains + +### Features Kept βœ… + +- `publish(basePath, options, log?)` β€” async, rejects on any error +- `clean()` and `getCacheDir()` +- Options: `repo`, `branch`, `remote`, `message`, `git`, `depth`, `add`, `dotfiles`, `nojekyll`, `cname`, `user` +- `Git`: `clone`, `exec`, `clean`, `reset`, `fetch`, `checkout`, `rm`, `add`, `commit`, `push`, `getRemoteUrl` + +### Features Removed ❌ + +- The `gh-pages` / `gh-pages-clean` CLI +- The callback API of `publish()` +- Options `src`, `remove`, `only`, `dest`, `tag`, `history`, `push`, `silent`, `beforeAdd` +- `Git`: `init`, `tag`, `deleteRef` +- All dependencies: `async`, `commander`, `email-addresses`, `filenamify`, `find-cache-dir`, `fs-extra`, `globby`/`tinyglobby` + +## Deviations from Upstream + +| Topic | Upstream | Fork | +| ----------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| Logging | `util.debuglog('gh-pages')` | `log` callback parameter of `publish()` | +| Removing files | glob `remove` pattern, then `git rm` in batches | `git rm -r -f --ignore-unmatch -- .` (all tracked files, including dotfiles and submodules) | +| File list | glob `src` pattern via tinyglobby | `listFiles()`: all files below `basePath`, follows symlinks, honors `dotfiles` | +| Cache directory | `find-cache-dir` + `filenamify(repo)` | Same lookup (`$CACHE_DIR`, closest `package.json`) plus an os temp dir fallback; clone directory named after a hash of the repo URL, so tokens never end up in a path | +| `checkout()` | `main` only runs `ls-remote` for an existing branch | `checkout`, `clean -f -d`, `reset --hard remote/branch` (the behavior of v6.3.0) | +| Log messages | full repository URL | credentials in the repository URL are replaced with `***` | +| Empty `basePath` | `The pattern in the "src" property didn't match any files.` | `The directory "" doesn't contain any files to publish.` | + +## Maintenance + +**This fork is maintained EXCLUSIVELY for angular-cli-ghpages.** + +We update it only when: +1. A critical security vulnerability is found that affects angular-cli-ghpages +2. A bug is discovered that affects angular-cli-ghpages functionality +3. An upstream change is relevant for angular-cli-ghpages +4. Node.js or git changes break angular-cli-ghpages compatibility + +**We do NOT accept:** +- Feature requests from other projects +- Pull requests for general gh-pages features +- Issues from external users + +For internal changes (angular-cli-ghpages team only): +1. Update the matching file in `gh-pages-fork/lib/` +2. Add or adjust a test in `gh-pages-fork/test/` +3. When porting an upstream change, update the base commit below + +## Original Project + +This is a stripped-down fork of: +- **Repository**: https://github.com/tschaub/gh-pages +- **Base**: `main` at [`48f62a7`](https://github.com/tschaub/gh-pages/commit/48f62a750216e72c5e89058bb61dffdb8450949d) (2026-01-11, after v6.3.0) +- **License**: MIT (see [LICENSE](LICENSE)) +- **Original Author**: Tim Schaub + +We are grateful for the original gh-pages project; this fork exists only to keep angular-cli-ghpages free of third-party dependencies for publishing. diff --git a/src/gh-pages-fork/lib/git.ts b/src/gh-pages-fork/lib/git.ts new file mode 100644 index 0000000..191d306 --- /dev/null +++ b/src/gh-pages-fork/lib/git.ts @@ -0,0 +1,200 @@ +/** + * gh-pages-fork: lib/git.js of gh-pages, see ../README.md + * + * Original: https://github.com/tschaub/gh-pages (MIT License) + * Copyright (c) 2014 Tim Schaub + */ + +import * as cp from 'child_process'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +/** + * Error for a git process that exited with a non-zero code. + * `message` holds the combined stdout/stderr output of the process. + */ +export class ProcessError extends Error { + constructor( + public readonly code: number, + message: string + ) { + super(message); + this.name = 'ProcessError'; + } +} + +/** + * Run a process and resolve with its combined stdout/stderr output. + * Rejects with a ProcessError on a non-zero exit code. + */ +export function spawn(exe: string, args: string[], cwd?: string): Promise { + return new Promise((resolve, reject) => { + const child = cp.spawn(exe, args, { cwd: cwd || process.cwd() }); + const buffer: string[] = []; + child.stderr.on('data', (chunk: Buffer) => buffer.push(chunk.toString())); + child.stdout.on('data', (chunk: Buffer) => buffer.push(chunk.toString())); + child.on('error', reject); + child.on('close', (code: number | null) => { + const output = buffer.join(''); + if (code) { + reject(new ProcessError(code, output || 'Process failed: ' + code)); + } else { + resolve(output); + } + }); + }); +} + +async function pathExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +export interface CloneOptions { + git: string; + remote: string; + depth: number; +} + +/** + * Executes git commands in one repository directory. + * `output` holds the output of the last executed command. + */ +export class Git { + output = ''; + + constructor( + public readonly cwd: string, + public readonly cmd = 'git' + ) {} + + /** + * Clone a repo into the given dir if it doesn't already exist. + * Falls back to a full clone of the default branch when the branch doesn't exist yet. + */ + static async clone(repo: string, dir: string, branch: string, options: CloneOptions): Promise { + if (await pathExists(dir)) { + return new Git(dir, options.git); + } + + await fs.mkdir(path.dirname(path.resolve(dir)), { recursive: true }); + + try { + await spawn(options.git, [ + 'clone', repo, dir, + '--branch', branch, + '--single-branch', + '--origin', options.remote, + '--depth', String(options.depth) + ]); + } catch { + await spawn(options.git, ['clone', repo, dir, '--origin', options.remote]); + } + return new Git(dir, options.git); + } + + /** + * Execute an arbitrary git command. + */ + async exec(...args: string[]): Promise { + this.output = await spawn(this.cmd, args, this.cwd); + return this; + } + + /** + * Clean up unversioned files. + */ + clean(): Promise { + return this.exec('clean', '-f', '-d'); + } + + /** + * Hard reset to remote/branch. + */ + reset(remote: string, branch: string): Promise { + return this.exec('reset', '--hard', remote + '/' + branch); + } + + /** + * Fetch from a remote. + */ + fetch(remote: string): Promise { + return this.exec('fetch', remote); + } + + /** + * Checkout a branch (create an orphan if it doesn't exist on the remote). + */ + async checkout(remote: string, branch: string): Promise { + const treeish = remote + '/' + branch; + try { + await this.exec('ls-remote', '--exit-code', '.', treeish); + } catch (error) { + if (error instanceof ProcessError && error.code === 2) { + return this.exec('checkout', '--orphan', branch); + } + throw error; + } + + await this.exec('checkout', branch); + await this.clean(); + return this.reset(remote, branch); + } + + /** + * Remove files from the index and the working tree. + */ + rm(files: string[]): Promise { + return this.exec('rm', '--ignore-unmatch', '-r', '-f', '--', ...files); + } + + /** + * Add files. + */ + add(files: string[]): Promise { + return this.exec('add', ...files); + } + + /** + * Commit (if there are any changes). + */ + async commit(message: string): Promise { + try { + return await this.exec('diff-index', '--quiet', 'HEAD'); + } catch { + return this.exec('commit', '-m', message); + } + } + + /** + * Push a branch. + */ + push(remote: string, branch: string): Promise { + return this.exec('push', '--tags', remote, branch); + } + + /** + * Get the URL for a remote. + */ + async getRemoteUrl(remote: string): Promise { + let repo: string | undefined; + try { + await this.exec('config', '--get', 'remote.' + remote + '.url'); + repo = this.output.split(/[\n\r]/).shift(); + } catch { + repo = undefined; + } + if (!repo) { + throw new Error( + 'Failed to get remote.' + remote + '.url (task must either be ' + + 'run in a git repository with a configured ' + remote + ' remote ' + + 'or must be configured with the "repo" option).' + ); + } + return repo; + } +} diff --git a/src/gh-pages-fork/lib/index.ts b/src/gh-pages-fork/lib/index.ts new file mode 100644 index 0000000..eca4a06 --- /dev/null +++ b/src/gh-pages-fork/lib/index.ts @@ -0,0 +1,177 @@ +/** + * gh-pages-fork: lib/index.js of gh-pages, see ../README.md + * + * Original: https://github.com/tschaub/gh-pages (MIT License) + * Copyright (c) 2014 Tim Schaub + */ + +import { createHash } from 'crypto'; +import { existsSync } from 'fs'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; + +import { Git } from './git'; +import { copy, getUser, listFiles, User } from './util'; + +export interface PublishOptions { + repo?: string; + branch?: string; + remote?: string; + message?: string; + git?: string; + depth?: number; + add?: boolean; + dotfiles?: boolean; + nojekyll?: boolean; + cname?: string; + user?: User; +} + +export type Log = (message: string) => void; + +export const defaults = { + add: false, + git: 'git', + depth: 1, + dotfiles: false, + branch: 'gh-pages', + remote: 'origin', + message: 'Updates', + nojekyll: false +}; + +/** + * Get the cache directory. + * + * Resolution order: + * 1. `$CACHE_DIR/gh-pages` (boolean-like values such as `true` or `0` are ignored) + * 2. `node_modules/.cache/gh-pages` next to the closest package.json above cwd + * 3. `/angular-cli-ghpages-cache/gh-pages` + * + * With `optPath`, returns a subdirectory named after a hash of it, so repository + * URLs (which may contain tokens) never end up in a path on disk. + */ +export function getCacheDir(optPath?: string): string { + const dir = findCacheDir(process.cwd()); + if (!optPath) { + return dir; + } + return path.join(dir, createHash('sha256').update(optPath).digest('hex').slice(0, 16)); +} + +function findCacheDir(cwd: string): string { + const cacheDirEnv = process.env.CACHE_DIR; + if (cacheDirEnv && !['true', 'false', '1', '0'].includes(cacheDirEnv)) { + return path.join(cacheDirEnv, 'gh-pages'); + } + + let dir = path.resolve(cwd); + while (true) { + if (existsSync(path.join(dir, 'package.json'))) { + return path.join(dir, 'node_modules', '.cache', 'gh-pages'); + } + const parent = path.dirname(dir); + if (parent === dir) { + return path.join(os.tmpdir(), 'angular-cli-ghpages-cache', 'gh-pages'); + } + dir = parent; + } +} + +/** + * Clean the cache directory. + */ +export async function clean(): Promise { + await fs.rm(getCacheDir(), { recursive: true, force: true }); +} + +async function getRepo(options: { repo?: string; git: string; remote: string }): Promise { + if (options.repo) { + return options.repo; + } + return new Git(process.cwd(), options.git).getRemoteUrl(options.remote); +} + +/** + * Hide credentials (e.g. an injected token) in a repository URL for logging. + */ +function redact(repo: string): string { + return repo.replace(/\/\/[^/@]+@/, '//***@'); +} + +/** + * Publish the files in `basePath` to a branch of a git repository. + * + * Clones the branch into the cache directory, replaces its content with the + * files from `basePath` (unless `add` is set), commits and pushes. + */ +export async function publish(basePath: string, config: PublishOptions, log: Log = () => undefined): Promise { + const options = { ...defaults, ...config }; + + if (!(await fs.stat(basePath)).isDirectory()) { + throw new Error('The "base" option must be an existing directory'); + } + + const files = await listFiles(basePath, options.dotfiles); + if (files.length === 0) { + throw new Error(`The directory "${basePath}" doesn't contain any files to publish.`); + } + + const user = options.user ?? await getUser(); + const repoUrl = await getRepo(options); + const clone = getCacheDir(repoUrl); + + log(`Cloning ${redact(repoUrl)} into ${clone}`); + const git = await Git.clone(repoUrl, clone, options.branch, options); + const url = await git.getRemoteUrl(options.remote); + if (url !== repoUrl) { + throw new Error( + `Remote url mismatch. Got "${redact(url)}" but expected "${redact(repoUrl)}" in ${git.cwd}. Remove the cache directory and try again.` + ); + } + + // only required if someone mucks with the checkout between builds + log('Cleaning'); + await git.clean(); + + log(`Fetching ${options.remote}`); + await git.fetch(options.remote); + + log(`Checking out ${options.remote}/${options.branch}`); + await git.checkout(options.remote, options.branch); + + if (!options.add) { + log('Removing files'); + await git.rm(['.']); + } + + if (options.nojekyll) { + log('Creating .nojekyll'); + await fs.writeFile(path.join(git.cwd, '.nojekyll'), ''); + } + + if (options.cname) { + log(`Creating CNAME for ${options.cname}`); + await fs.writeFile(path.join(git.cwd, 'CNAME'), options.cname); + } + + log('Copying files'); + await copy(files, basePath, git.cwd); + + log('Adding all'); + await git.add(['.']); + + if (user) { + await git.exec('config', 'user.email', user.email); + if (user.name) { + await git.exec('config', 'user.name', user.name); + } + } + + log('Committing'); + await git.commit(options.message); + + log('Pushing'); + await git.push(options.remote, options.branch); +} diff --git a/src/gh-pages-fork/lib/util.ts b/src/gh-pages-fork/lib/util.ts new file mode 100644 index 0000000..b47fb87 --- /dev/null +++ b/src/gh-pages-fork/lib/util.ts @@ -0,0 +1,72 @@ +/** + * gh-pages-fork: lib/util.js of gh-pages, see ../README.md + * + * Original: https://github.com/tschaub/gh-pages (MIT License) + * Copyright (c) 2014 Tim Schaub + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; + +import { Git } from './git'; + +export interface User { + name: string; + email: string; +} + +/** + * List all files below `base` as relative paths with POSIX separators. + * Symbolic links are followed (their targets get copied). + * Entries starting with `.` are skipped unless `dotfiles` is set. + */ +export async function listFiles(base: string, dotfiles: boolean): Promise { + const files: string[] = []; + + async function walk(relDir: string): Promise { + const entries = await fs.readdir(path.join(base, relDir), { withFileTypes: true }); + for (const entry of entries) { + if (!dotfiles && entry.name.startsWith('.')) { + continue; + } + const relPath = relDir ? `${relDir}/${entry.name}` : entry.name; + const stat = entry.isSymbolicLink() ? await fs.stat(path.join(base, relPath)) : entry; + if (stat.isDirectory()) { + await walk(relPath); + } else if (stat.isFile()) { + files.push(relPath); + } + } + } + + await walk(''); + return files; +} + +/** + * Copy a list of files. + */ +export async function copy(files: string[], base: string, dest: string): Promise { + for (const file of files) { + const src = path.resolve(base, file); + const target = path.join(dest, path.relative(base, src)); + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.copyFile(src, target); + } +} + +/** + * Read the git user of the current working directory. + * Resolves with null when name or email is not configured. + */ +export async function getUser(cwd?: string): Promise { + try { + const [name, email] = await Promise.all([ + new Git(cwd ?? process.cwd()).exec('config', 'user.name'), + new Git(cwd ?? process.cwd()).exec('config', 'user.email') + ]); + return { name: name.output.trim(), email: email.output.trim() }; + } catch { + return null; + } +} diff --git a/src/gh-pages-fork/test/cache-dir.spec.ts b/src/gh-pages-fork/test/cache-dir.spec.ts new file mode 100644 index 0000000..c771e81 --- /dev/null +++ b/src/gh-pages-fork/test/cache-dir.spec.ts @@ -0,0 +1,76 @@ +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; + +import { clean, getCacheDir } from '../lib'; + +describe('gh-pages-fork getCacheDir() / clean()', () => { + let tempDir: string; + let originalEnv: NodeJS.ProcessEnv; + let originalCwd: string; + + beforeEach(async () => { + originalEnv = { ...process.env }; + originalCwd = process.cwd(); + delete process.env.CACHE_DIR; + tempDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gh-pages-fork-cache-'))); + }); + + afterEach(async () => { + process.chdir(originalCwd); + process.env = originalEnv; + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('uses $CACHE_DIR when set', () => { + process.env.CACHE_DIR = path.join(tempDir, 'custom'); + + expect(getCacheDir()).toBe(path.join(tempDir, 'custom', 'gh-pages')); + }); + + it('ignores boolean-like $CACHE_DIR values', async () => { + await fs.writeFile(path.join(tempDir, 'package.json'), '{}'); + process.chdir(tempDir); + + for (const value of ['true', 'false', '1', '0']) { + process.env.CACHE_DIR = value; + expect(getCacheDir()).toBe(path.join(tempDir, 'node_modules', '.cache', 'gh-pages')); + } + }); + + it('uses node_modules/.cache next to the closest package.json', async () => { + await fs.writeFile(path.join(tempDir, 'package.json'), '{}'); + const nested = path.join(tempDir, 'a', 'b'); + await fs.mkdir(nested, { recursive: true }); + process.chdir(nested); + + expect(getCacheDir()).toBe(path.join(tempDir, 'node_modules', '.cache', 'gh-pages')); + }); + + it('falls back to the os temp directory without a package.json (issue #203)', () => { + process.chdir(tempDir); + + expect(getCacheDir()).toBe(path.join(os.tmpdir(), 'angular-cli-ghpages-cache', 'gh-pages')); + }); + + it('names the clone directory after a hash, so tokens never end up on disk', () => { + process.env.CACHE_DIR = tempDir; + const repo = 'https://x-access-token:secret123@github.com/user/repo.git'; + + const cloneDir = getCacheDir(repo); + + expect(path.dirname(cloneDir)).toBe(path.join(tempDir, 'gh-pages')); + expect(cloneDir).not.toContain('secret123'); + expect(getCacheDir(repo)).toBe(cloneDir); + }); + + it('clean() removes the cache directory', async () => { + process.env.CACHE_DIR = tempDir; + const cloneDir = getCacheDir('https://github.com/user/repo.git'); + await fs.mkdir(cloneDir, { recursive: true }); + + await clean(); + + await expect(fs.access(path.join(tempDir, 'gh-pages'))).rejects.toThrow(); + }); +}); diff --git a/src/gh-pages-fork/test/git-commands.spec.ts b/src/gh-pages-fork/test/git-commands.spec.ts new file mode 100644 index 0000000..c502bb2 --- /dev/null +++ b/src/gh-pages-fork/test/git-commands.spec.ts @@ -0,0 +1,165 @@ +/** + * Exact sequence of git commands that publish() runs. + * child_process is mocked; every command succeeds unless configured otherwise. + */ + +import { EventEmitter } from 'events'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; + +const { mockSpawn } = vi.hoisted(() => ({ mockSpawn: vi.fn() })); + +vi.mock('child_process', () => ({ spawn: mockSpawn })); + +import { publish } from '../lib'; + +interface FakeResult { + code?: number; + output?: string; +} + +describe('gh-pages-fork git commands', () => { + const repo = 'https://github.com/user/repo.git'; + let calls: string[]; + let results: Record; + let tempDir: string; + let dist: string; + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(async () => { + originalEnv = { ...process.env }; + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gh-pages-fork-cmd-')); + process.env.CACHE_DIR = path.join(tempDir, 'cache'); + dist = path.join(tempDir, 'dist'); + await fs.mkdir(dist); + await fs.writeFile(path.join(dist, 'index.html'), '

test

'); + + calls = []; + results = { + 'config --get remote.origin.url': { output: repo }, + 'diff-index --quiet HEAD': { code: 1 } + }; + mockSpawn.mockImplementation((cmd: string, args: string[], options: { cwd: string }) => { + const command = args.join(' '); + calls.push(`${cmd} ${command}`); + if (args[0] === 'clone') { + // git clone creates the target directory + void fs.mkdir(args[2], { recursive: true }); + } + const result = Object.entries(results).find(([key]) => command.startsWith(key))?.[1] ?? {}; + const child = Object.assign(new EventEmitter(), { + stdout: new EventEmitter(), + stderr: new EventEmitter() + }); + setImmediate(() => { + if (result.output) { + child.stdout.emit('data', Buffer.from(result.output)); + } + child.emit('close', result.code ?? 0); + }); + expect(options.cwd).toBeTruthy(); + return child; + }); + }); + + afterEach(async () => { + process.env = originalEnv; + mockSpawn.mockReset(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + function cloneDirOf(command: string): string { + return command.split(' ')[3]; + } + + it('deploys to an existing branch', async () => { + await publish(dist, { + repo, + branch: 'gh-pages', + message: 'Deploy', + user: { name: 'Bot', email: 'bot@example.org' }, + nojekyll: true + }); + + const cloneDir = cloneDirOf(calls[0]); + expect(calls).toEqual([ + `git clone ${repo} ${cloneDir} --branch gh-pages --single-branch --origin origin --depth 1`, + 'git config --get remote.origin.url', + 'git clean -f -d', + 'git fetch origin', + 'git ls-remote --exit-code . origin/gh-pages', + 'git checkout gh-pages', + 'git clean -f -d', + 'git reset --hard origin/gh-pages', + 'git rm --ignore-unmatch -r -f -- .', + 'git add .', + 'git config user.email bot@example.org', + 'git config user.name Bot', + 'git diff-index --quiet HEAD', + 'git commit -m Deploy', + 'git push --tags origin gh-pages' + ]); + }); + + it('creates an orphan branch when the branch does not exist on the remote', async () => { + results['clone ' + repo] = { code: 128 }; + results['ls-remote'] = { code: 2 }; + const defaultImplementation = mockSpawn.getMockImplementation()!; + mockSpawn.mockImplementation((cmd: string, args: string[], options: { cwd: string }) => { + // only the clone with --branch fails, the fallback clone succeeds + if (args[0] === 'clone' && !args.includes('--branch')) { + delete results['clone ' + repo]; + } + return defaultImplementation(cmd, args, options); + }); + + await publish(dist, { repo, user: { name: 'Bot', email: 'bot@example.org' } }); + + const cloneDir = cloneDirOf(calls[0]); + expect(calls.slice(0, 7)).toEqual([ + `git clone ${repo} ${cloneDir} --branch gh-pages --single-branch --origin origin --depth 1`, + `git clone ${repo} ${cloneDir} --origin origin`, + 'git config --get remote.origin.url', + 'git clean -f -d', + 'git fetch origin', + 'git ls-remote --exit-code . origin/gh-pages', + 'git checkout --orphan gh-pages' + ]); + }); + + it('skips removing files with add', async () => { + await publish(dist, { repo, add: true, user: { name: 'Bot', email: 'bot@example.org' } }); + + expect(calls.some((call) => call.startsWith('git rm'))).toBe(false); + }); + + it('skips the commit when nothing changed', async () => { + results['diff-index --quiet HEAD'] = { code: 0 }; + + await publish(dist, { repo, user: { name: 'Bot', email: 'bot@example.org' } }); + + expect(calls.some((call) => call.startsWith('git commit'))).toBe(false); + expect(calls[calls.length - 1]).toBe('git push --tags origin gh-pages'); + }); + + it('reads the git user when no user is given', async () => { + results['config user.name'] = { output: 'Local User\n' }; + results['config user.email'] = { output: 'local@example.org\n' }; + + await publish(dist, { repo }); + + expect(calls.slice(0, 2).sort()).toEqual(['git config user.email', 'git config user.name']); + expect(calls).toContain('git config user.email local@example.org'); + expect(calls).toContain('git config user.name Local User'); + }); + + it('uses the configured git executable and remote', async () => { + results['config --get remote.upstream.url'] = { output: repo }; + + await publish(dist, { repo, git: '/usr/bin/git', remote: 'upstream', user: { name: 'Bot', email: 'bot@example.org' } }); + + expect(calls.every((call) => call.startsWith('/usr/bin/git '))).toBe(true); + expect(calls[calls.length - 1]).toBe('/usr/bin/git push --tags upstream gh-pages'); + }); +}); diff --git a/src/gh-pages-fork/test/publish.spec.ts b/src/gh-pages-fork/test/publish.spec.ts new file mode 100644 index 0000000..21c24bd --- /dev/null +++ b/src/gh-pages-fork/test/publish.spec.ts @@ -0,0 +1,188 @@ +/** + * publish() against real git repositories (local bare repos as remotes). + */ + +import { execFileSync } from 'child_process'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; + +import { clean, publish, PublishOptions } from '../lib'; + +const user = { name: 'Deploy Bot', email: 'bot@example.org' }; + +function git(cwd: string, ...args: string[]): string { + return execFileSync('git', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim(); +} + +function tree(remote: string, branch = 'gh-pages'): string[] { + return git(remote, 'ls-tree', '-r', '--name-only', branch).split('\n').filter(Boolean).sort(); +} + +function commitCount(remote: string, branch = 'gh-pages'): number { + return Number(git(remote, 'rev-list', '--count', branch)); +} + +describe('gh-pages-fork publish()', () => { + let tempDir: string; + let remote: string; + let dist: string; + let originalEnv: NodeJS.ProcessEnv; + + function deploy(options: PublishOptions = {}, log?: (message: string) => void) { + return publish(dist, { repo: remote, user, message: 'deploy', ...options }, log); + } + + beforeEach(async () => { + originalEnv = { ...process.env }; + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gh-pages-fork-')); + process.env.CACHE_DIR = path.join(tempDir, 'cache'); + + remote = path.join(tempDir, 'remote.git'); + git(tempDir, 'init', '--bare', remote); + + dist = path.join(tempDir, 'dist'); + await fs.mkdir(path.join(dist, 'assets'), { recursive: true }); + await fs.writeFile(path.join(dist, 'index.html'), '

v1

'); + await fs.writeFile(path.join(dist, 'assets', 'app.js'), 'console.log(1);'); + await fs.writeFile(path.join(dist, '.htaccess'), 'RewriteEngine On'); + }); + + afterEach(async () => { + await clean(); + process.env = originalEnv; + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + async function seedBranch(): Promise { + const seed = path.join(tempDir, 'seed'); + git(tempDir, 'init', '-b', 'gh-pages', seed); + git(seed, 'config', 'user.name', 'Seed'); + git(seed, 'config', 'user.email', 'seed@example.org'); + await fs.mkdir(path.join(seed, '.github', 'workflows'), { recursive: true }); + await fs.writeFile(path.join(seed, '.github', 'workflows', 'deploy.yml'), 'name: deploy'); + await fs.writeFile(path.join(seed, '.gitignore'), 'node_modules'); + await fs.writeFile(path.join(seed, 'stale.html'), 'stale'); + git(seed, 'add', '.'); + git(seed, 'commit', '-m', 'seed'); + git(seed, 'update-index', '--add', '--cacheinfo', `160000,${git(seed, 'rev-parse', 'HEAD')},build`); + git(seed, 'commit', '-m', 'gitlink'); + git(seed, 'push', remote, 'gh-pages'); + } + + it('creates the branch on the first deploy', async () => { + await deploy({ dotfiles: true }); + + expect(tree(remote)).toEqual(['.htaccess', 'assets/app.js', 'index.html']); + expect(git(remote, 'log', '-1', '--format=%an <%ae> | %s', 'gh-pages')).toBe('Deploy Bot | deploy'); + }); + + it('replaces the whole branch content, including dotfiles and submodule gitlinks', async () => { + await seedBranch(); + + await deploy(); + + expect(tree(remote)).toEqual(['assets/app.js', 'index.html']); + }); + + it('keeps .nojekyll and CNAME on every redeploy', async () => { + const options = { nojekyll: true, cname: 'example.org' }; + + await deploy(options); + await fs.writeFile(path.join(dist, 'index.html'), '

v2

'); + await deploy(options); + + expect(tree(remote)).toEqual(['.nojekyll', 'CNAME', 'assets/app.js', 'index.html']); + expect(git(remote, 'show', 'gh-pages:CNAME')).toBe('example.org'); + expect(commitCount(remote)).toBe(2); + }); + + it('removes .nojekyll when nojekyll is disabled', async () => { + await deploy({ nojekyll: true }); + await deploy({ nojekyll: false }); + + expect(tree(remote)).toEqual(['assets/app.js', 'index.html']); + }); + + it('keeps existing files with add', async () => { + await seedBranch(); + + await deploy({ add: true }); + + expect(tree(remote)).toEqual([ + '.github/workflows/deploy.yml', '.gitignore', 'assets/app.js', 'build', 'index.html', 'stale.html' + ]); + }); + + it('excludes dotfiles of dist unless dotfiles is set', async () => { + await deploy({ dotfiles: false }); + + expect(tree(remote)).toEqual(['assets/app.js', 'index.html']); + }); + + it('creates no commit when nothing changed', async () => { + await deploy(); + await deploy({ message: 'unchanged' }); + + expect(commitCount(remote)).toBe(1); + }); + + it('follows symbolic links in dist', async () => { + const shared = path.join(tempDir, 'shared'); + await fs.mkdir(shared); + await fs.writeFile(path.join(shared, 'data.txt'), 'shared'); + await fs.symlink(shared, path.join(dist, 'linked')); + await fs.symlink('index.html', path.join(dist, 'alias.html')); + + await deploy(); + + expect(tree(remote)).toEqual(['alias.html', 'assets/app.js', 'index.html', 'linked/data.txt']); + expect(git(remote, 'show', 'gh-pages:linked/data.txt')).toBe('shared'); + }); + + it('uses the git user of the current working directory when no user is given', async () => { + const app = path.join(tempDir, 'app'); + git(tempDir, 'init', app); + git(app, 'config', 'user.name', 'Local User'); + git(app, 'config', 'user.email', 'local@example.org'); + const cwd = process.cwd(); + process.chdir(app); + + try { + await publish(dist, { repo: remote, message: 'deploy' }); + } finally { + process.chdir(cwd); + } + + expect(git(remote, 'log', '-1', '--format=%an <%ae>', 'gh-pages')).toBe('Local User '); + }); + + it('rejects when the remote rejects the push', async () => { + const hook = path.join(remote, 'hooks', 'pre-receive'); + await fs.writeFile(hook, '#!/bin/sh\necho "rejected by hook" >&2\nexit 1\n'); + await fs.chmod(hook, 0o755); + + await expect(deploy()).rejects.toThrow('rejected by hook'); + }); + + it('rejects when dist contains no files', async () => { + const empty = path.join(tempDir, 'empty'); + await fs.mkdir(empty); + + await expect(publish(empty, { repo: remote, user })).rejects.toThrow( + `The directory "${empty}" doesn't contain any files to publish.` + ); + }); + + it('hides credentials of the repository URL in log messages', async () => { + const messages: string[] = []; + const repoWithToken = 'https://x-access-token:secret123@example.invalid/user/repo.git'; + + await expect( + publish(dist, { repo: repoWithToken, user }, (message) => messages.push(message)) + ).rejects.toThrow(); + + expect(messages[0]).toContain('https://***@example.invalid/user/repo.git'); + expect(messages.join('\n')).not.toContain('secret123'); + }); +}); diff --git a/src/interfaces.ts b/src/interfaces.ts index 3d8be5b..6f7213e 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,4 +1,4 @@ -import type {GhPagesGit} from './engine/engine.prepare-options-helpers'; +import type {Git} from './gh-pages-fork/lib/git'; /** * Angular outputPath configuration @@ -49,12 +49,10 @@ export interface DeployUser { } /** - * Options for gh-pages.publish() - * Based on https://github.com/tschaub/gh-pages#options + * Options of the publish() function of the gh-pages npm package. * - * Note: Only includes options that gh-pages actually accepts. - * Internal options (notfound, noDotfiles, noNotfound, noNojekyll, dryRun) - * are handled by angular-cli-ghpages before calling gh-pages. + * @deprecated angular-cli-ghpages doesn't use the gh-pages package. Kept for API + * compatibility; will be removed in the next major version. */ export interface PublishOptions { repo?: string; @@ -67,12 +65,17 @@ export interface PublishOptions { cname?: string; add?: boolean; git?: string; - beforeAdd?: (git: GhPagesGit) => void | Promise; + beforeAdd?: (git: Git) => void | Promise; [key: string]: unknown; // Allow additional gh-pages options } +/** + * API of the gh-pages npm package. + * + * @deprecated angular-cli-ghpages doesn't use the gh-pages package. Kept for API + * compatibility; will be removed in the next major version. + */ export interface GHPages { - // gh-pages v5+ supports both callback and Promise-based APIs publish(dir: string, options: PublishOptions, callback: (error: Error | null) => void): void; publish(dir: string, options: PublishOptions): Promise; clean?(): void; diff --git a/src/package-lock.json b/src/package-lock.json index f1c6656..843f43f 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -11,8 +11,7 @@ "dependencies": { "@angular-devkit/architect": ">=0.1800.0 <0.2300.0", "@angular-devkit/core": ">=18.0.0 <23.0.0", - "@angular-devkit/schematics": ">=18.0.0 <23.0.0", - "gh-pages": "6.3.0" + "@angular-devkit/schematics": ">=18.0.0 <23.0.0" }, "bin": { "angular-cli-ghpages": "angular-cli-ghpages", @@ -592,41 +591,6 @@ "node": ">=20" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@oxc-parser/binding-android-arm-eabi": { "version": "0.150.0", "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.150.0.tgz", @@ -1818,15 +1782,6 @@ "dev": true, "license": "Python-2.0" }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1837,24 +1792,6 @@ "node": ">=12" } }, - "node_modules/async": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "license": "MIT" - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/chai": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", @@ -2002,21 +1939,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/commander": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", - "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/commondir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", - "license": "MIT" - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2028,24 +1950,6 @@ "node": ">=8" } }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/email-addresses": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/email-addresses/-/email-addresses-5.0.0.tgz", - "integrity": "sha512-4OIPYlA6JXqtVn8zpHpGiI7vE6EQOAg16aGnDMIAlZVinnoZ8208tW1hAbjWydgN/4PLTT9q+O1K6AH/vALJGw==", - "license": "MIT" - }, "node_modules/emoji-regex": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", @@ -2096,15 +2000,6 @@ "node": ">=6" } }, - "node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -2131,22 +2026,6 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, "node_modules/fast-string-truncated-width": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", @@ -2190,15 +2069,6 @@ "fast-string-width": "^3.0.2" } }, - "node_modules/fastq": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", - "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2217,88 +2087,6 @@ } } }, - "node_modules/filename-reserved-regex": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-2.0.0.tgz", - "integrity": "sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/filenamify": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/filenamify/-/filenamify-4.3.0.tgz", - "integrity": "sha512-hcFKyUG57yWGAzu1CMt/dPzYZuv+jAJUT85bL8mrXvNe6hWj6yEHEc4EdcgiA6Z3oi1/9wXJdZPXF2dZNgwgOg==", - "license": "MIT", - "dependencies": { - "filename-reserved-regex": "^2.0.0", - "strip-outer": "^1.0.1", - "trim-repeated": "^1.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-cache-dir": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", - "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", - "license": "MIT", - "dependencies": { - "commondir": "^1.0.1", - "make-dir": "^3.0.2", - "pkg-dir": "^4.1.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/avajs/find-cache-dir?sponsor=1" - } - }, - "node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fs-extra": { - "version": "11.4.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz", - "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2337,66 +2125,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/gh-pages": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/gh-pages/-/gh-pages-6.3.0.tgz", - "integrity": "sha512-Ot5lU6jK0Eb+sszG8pciXdjMXdBJ5wODvgjR+imihTqsUWF2K6dJ9HST55lgqcs8wWcw6o6wAsUzfcYRhJPXbA==", - "license": "MIT", - "dependencies": { - "async": "^3.2.4", - "commander": "^13.0.0", - "email-addresses": "^5.0.0", - "filenamify": "^4.3.0", - "find-cache-dir": "^3.3.1", - "fs-extra": "^11.1.1", - "globby": "^11.1.0" - }, - "bin": { - "gh-pages": "bin/gh-pages.js", - "gh-pages-clean": "bin/gh-pages-clean.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "license": "MIT", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" - }, "node_modules/hosted-git-info": { "version": "10.1.1", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-10.1.1.tgz", @@ -2427,24 +2155,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-fullwidth-code-point": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", @@ -2461,18 +2171,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-interactive": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", @@ -2485,15 +2183,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-unicode-supported": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", @@ -2564,18 +2253,6 @@ "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", "license": "MIT" }, - "node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -2876,18 +2553,6 @@ "node": ">=22.13.0" } }, - "node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -2951,64 +2616,6 @@ "@jridgewell/sourcemap-codec": "^1.6.0" } }, - "node_modules/make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "license": "MIT", - "dependencies": { - "semver": "^6.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/make-dir/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/mimic-function": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", @@ -3165,42 +2772,6 @@ "@oxc-parser/binding-win32-x64-msvc": "0.150.0" } }, - "node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -3242,24 +2813,6 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3280,18 +2833,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pkg-dir": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", - "license": "MIT", - "dependencies": { - "find-up": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/postcss": { "version": "8.5.29", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", @@ -3348,26 +2889,6 @@ "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -3393,16 +2914,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rolldown": { "version": "1.2.13", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", @@ -3449,29 +2960,6 @@ "url": "https://github.com/sponsors/oxc-project" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -3513,15 +3001,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/slice-ansi": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.1.tgz", @@ -3622,18 +3101,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/strip-outer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/strip-outer/-/strip-outer-1.0.1.tgz", - "integrity": "sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==", - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^1.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/tinybench": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.1.tgz", @@ -3671,30 +3138,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/trim-repeated": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz", - "integrity": "sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg==", - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^1.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -3743,15 +3186,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/validate-npm-package-name": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-8.0.0.tgz", diff --git a/src/package.json b/src/package.json index ff12760..177e59c 100644 --- a/src/package.json +++ b/src/package.json @@ -70,8 +70,7 @@ "dependencies": { "@angular-devkit/architect": ">=0.1800.0 <0.2300.0", "@angular-devkit/core": ">=18.0.0 <23.0.0", - "@angular-devkit/schematics": ">=18.0.0 <23.0.0", - "gh-pages": "6.3.0" + "@angular-devkit/schematics": ">=18.0.0 <23.0.0" }, "peerDependencies": { "@angular/cli": ">=18.0.0 <23.0.0" diff --git a/src/parameter-tests/builder-integration.spec.ts b/src/parameter-tests/builder-integration.spec.ts index 16f0f6e..8b1ff8e 100644 --- a/src/parameter-tests/builder-integration.spec.ts +++ b/src/parameter-tests/builder-integration.spec.ts @@ -1,8 +1,8 @@ import { BuilderContext, BuilderRun, ScheduleOptions, Target } from '@angular-devkit/architect/src'; import { JsonObject, logging } from '@angular-devkit/core'; -import { BuildTarget, PublishOptions } from '../interfaces'; +import { BuildTarget } from '../interfaces'; +import type { PublishOptions } from '../gh-pages-fork/lib'; import { Schema } from '../deploy/schema'; -import { cleanupMonkeypatch } from '../engine/engine.prepare-options-helpers'; import { Mock } from 'vitest'; /** @@ -13,7 +13,7 @@ import { Mock } from 'vitest'; * β†’ deploy/actions.ts (builder entry point) * β†’ engine.run() (REAL engine, not mocked) * β†’ engine.prepareOptions() (parameter transformation) - * β†’ gh-pages.publish() (MOCKED to capture final options) + * β†’ gh-pages-fork publish() (MOCKED to capture final options) * * This ensures that parameter transformation from Angular Builder format * (noDotfiles, noNotfound, noNojekyll) to engine format (dotfiles, notfound, nojekyll) @@ -21,19 +21,20 @@ import { Mock } from 'vitest'; * * WHAT'S REAL vs MOCKED: * βœ… REAL: deploy/actions.ts, engine/engine.ts, prepareOptions() - * ❌ MOCKED: gh-pages.publish() (to capture final options), utils.pathExists, gh-pages/lib/git + * ❌ MOCKED: gh-pages-fork publish()/clean() (to capture final options), utils.pathExists * This IS a true integration test - we test the full internal code path with external dependencies mocked. */ -// Captured options from gh-pages.publish() +// Captured options from publish() const { capturedOptions } = vi.hoisted(() => { const capturedOptions = { value: null as PublishOptions | null }; return { capturedOptions }; }); -// Mock gh-pages/lib/git module (imported by engine.ts) -vi.mock('gh-pages/lib/git', () => ({ - default: vi.fn().mockImplementation(() => ({})) +vi.mock('../gh-pages-fork/lib', async () => ({ + ...(await vi.importActual('../gh-pages-fork/lib')), + clean: vi.fn(), + publish: vi.fn() })); // Mock utils.pathExists @@ -45,9 +46,7 @@ vi.mock('../utils', async () => ({ // Import after mocking import deploy from '../deploy/actions'; import * as engine from '../engine/engine'; - -// Spy on gh-pages at module level β€” vi.mock can't intercept dynamic require() in engine.ts -const ghPagesModule = require('gh-pages'); +import * as ghpages from '../gh-pages-fork/lib'; describe('Angular Builder Integration Tests', () => { let context: BuilderContext; @@ -65,22 +64,13 @@ describe('Angular Builder Integration Tests', () => { delete process.env.CIRCLECI; delete process.env.GITHUB_ACTIONS; - // Clean up any previous monkeypatch so each test starts fresh - cleanupMonkeypatch(); capturedOptions.value = null; context = createMockContext(); - // Spy on gh-pages to intercept calls from engine.run() - vi.spyOn(ghPagesModule, 'clean').mockImplementation(() => {}); - vi.spyOn(ghPagesModule, 'publish').mockImplementation( - (_dir: string, options: PublishOptions, callback?: (error: Error | null) => void) => { - capturedOptions.value = options; - if (callback) { - callback(null); - } - return Promise.resolve(); - } - ); + vi.mocked(ghpages.clean).mockResolvedValue(undefined); + vi.mocked(ghpages.publish).mockImplementation(async (_dir, options) => { + capturedOptions.value = options; + }); }); afterEach(() => { @@ -89,11 +79,6 @@ describe('Angular Builder Integration Tests', () => { vi.restoreAllMocks(); }); - afterAll(() => { - // Clean up monkeypatch after all tests - cleanupMonkeypatch(); - }); - describe('Boolean negation transformation (CRITICAL)', () => { it('should transform noDotfiles: true to dotfiles: false in complete flow', async () => { const options: Schema = { @@ -106,7 +91,7 @@ describe('Angular Builder Integration Tests', () => { expect(capturedOptions.value).not.toBeNull(); expect(capturedOptions.value!.dotfiles).toBe(false); - // Internal options should NOT be passed to gh-pages + // Internal options should NOT be passed to publish() expect(capturedOptions.value!.noDotfiles).toBeUndefined(); }); @@ -120,7 +105,7 @@ describe('Angular Builder Integration Tests', () => { await deploy(engine, context, BUILD_TARGET, options); expect(capturedOptions.value).not.toBeNull(); - // notfound is internal to angular-cli-ghpages, NOT passed to gh-pages + // notfound is handled by the engine (404.html), NOT passed to publish() // (notfound controls 404.html creation which we do ourselves) expect(capturedOptions.value!.notfound).toBeUndefined(); expect(capturedOptions.value!.noNotfound).toBeUndefined(); @@ -136,7 +121,7 @@ describe('Angular Builder Integration Tests', () => { await deploy(engine, context, BUILD_TARGET, options); expect(capturedOptions.value).not.toBeNull(); - // nojekyll IS passed to gh-pages v6+ (delegated to gh-pages) + // nojekyll IS passed to publish(), which writes .nojekyll expect(capturedOptions.value!.nojekyll).toBe(false); expect(capturedOptions.value!.noNojekyll).toBeUndefined(); }); @@ -154,7 +139,7 @@ describe('Angular Builder Integration Tests', () => { expect(capturedOptions.value).not.toBeNull(); expect(capturedOptions.value!.dotfiles).toBe(false); - // nojekyll IS passed to gh-pages v6+ (delegated) + // nojekyll IS passed to publish() expect(capturedOptions.value!.nojekyll).toBe(false); // notfound is internal (404.html creation by angular-cli-ghpages) expect(capturedOptions.value!.notfound).toBeUndefined(); @@ -174,7 +159,7 @@ describe('Angular Builder Integration Tests', () => { expect(capturedOptions.value).not.toBeNull(); expect(capturedOptions.value!.dotfiles).toBe(true); - // nojekyll IS passed to gh-pages v6+ (delegated) + // nojekyll IS passed to publish() expect(capturedOptions.value!.nojekyll).toBe(true); // notfound is internal (404.html creation by angular-cli-ghpages) expect(capturedOptions.value!.notfound).toBeUndefined(); @@ -227,7 +212,7 @@ describe('Angular Builder Integration Tests', () => { expect(capturedOptions.value!.user).toEqual(expectedUser); }); - it('should pass cname to gh-pages v6+ (delegated to gh-pages)', async () => { + it('should pass cname to publish()', async () => { const repo = 'https://github.com/test/repo.git'; const cname = 'example.com'; const options: Schema = { repo, cname, noBuild: true }; @@ -235,7 +220,7 @@ describe('Angular Builder Integration Tests', () => { await deploy(engine, context, BUILD_TARGET, options); expect(capturedOptions.value).not.toBeNull(); - // cname IS now passed to gh-pages v6+ (delegated file creation) + // cname IS passed to publish(), which writes CNAME expect(capturedOptions.value!.cname).toBe(cname); }); }); @@ -252,9 +237,9 @@ describe('Angular Builder Integration Tests', () => { expect(capturedOptions.value!.add).toBe(add); }); - it('should not call gh-pages.publish when dryRun is true', async () => { + it('should not call publish() when dryRun is true', async () => { // Reset mock to clear calls from previous tests - vi.mocked(ghPagesModule.publish).mockClear(); + vi.mocked(ghpages.publish).mockClear(); const repo = 'https://github.com/test/repo.git'; const options: Schema = { repo, dryRun: true, noBuild: true }; @@ -262,7 +247,7 @@ describe('Angular Builder Integration Tests', () => { await deploy(engine, context, BUILD_TARGET, options); // Verify publish was not called in this test - expect(ghPagesModule.publish).not.toHaveBeenCalled(); + expect(ghpages.publish).not.toHaveBeenCalled(); }); }); @@ -303,7 +288,7 @@ describe('Angular Builder Integration Tests', () => { expect(capturedOptions.value!.message).toBe(message); expect(capturedOptions.value!.add).toBe(add); - // cname and nojekyll ARE passed to gh-pages v6+ (delegated) + // cname and nojekyll ARE passed to publish() expect(capturedOptions.value!.cname).toBe(cname); expect(capturedOptions.value!.nojekyll).toBe(false); diff --git a/src/parameter-tests/edge-cases.spec.ts b/src/parameter-tests/edge-cases.spec.ts index a0230d3..5e63f0b 100644 --- a/src/parameter-tests/edge-cases.spec.ts +++ b/src/parameter-tests/edge-cases.spec.ts @@ -1,6 +1,5 @@ import { logging } from '@angular-devkit/core'; import * as engine from '../engine/engine'; -import { cleanupMonkeypatch } from '../engine/engine.prepare-options-helpers'; import { DeployUser } from '../interfaces'; /** @@ -22,8 +21,6 @@ describe('Edge Case Tests', () => { const originalEnv = process.env; beforeEach(() => { - // Clean up any previous monkeypatch so each test starts fresh - cleanupMonkeypatch(); logger = new logging.NullLogger(); // Create fresh copy of environment for each test @@ -51,8 +48,6 @@ describe('Edge Case Tests', () => { }); afterAll(() => { - // Clean up monkeypatch after all tests - cleanupMonkeypatch(); // Restore original environment for other test files process.env = originalEnv; }); diff --git a/src/parameter-tests/parameter-passthrough.spec.ts b/src/parameter-tests/parameter-passthrough.spec.ts index c3c554d..f480b5b 100644 --- a/src/parameter-tests/parameter-passthrough.spec.ts +++ b/src/parameter-tests/parameter-passthrough.spec.ts @@ -1,13 +1,12 @@ import { logging } from '@angular-devkit/core'; import * as engine from '../engine/engine'; -import { cleanupMonkeypatch } from '../engine/engine.prepare-options-helpers'; /** * CRITICAL TEST SUITE: Parameter Passthrough Validation * * This suite ensures that ALL parameters are correctly passed from our API - * to gh-pages.publish(). This is essential for upgrading gh-pages and commander - * without introducing regressions. + * to the publish step (gh-pages-fork). This guards changes to the fork and to + * the commander fork against regressions. * * Testing Philosophy: * - Use EXPLICIT expected values, never .toContain() for value testing @@ -21,8 +20,6 @@ describe('Parameter Passthrough Tests', () => { const originalEnv = process.env; beforeEach(() => { - // Clean up any previous monkeypatch so each test starts fresh - cleanupMonkeypatch(); logger = new logging.NullLogger(); // Create fresh copy of environment for each test @@ -50,8 +47,6 @@ describe('Parameter Passthrough Tests', () => { }); afterAll(() => { - // Clean up monkeypatch after all tests - cleanupMonkeypatch(); // Restore original environment for other test files process.env = originalEnv; }); diff --git a/src/parameter-tests/pr-186-commander-defaults.spec.ts b/src/parameter-tests/pr-186-commander-defaults.spec.ts index 3b488d9..ecb2c8b 100644 --- a/src/parameter-tests/pr-186-commander-defaults.spec.ts +++ b/src/parameter-tests/pr-186-commander-defaults.spec.ts @@ -33,23 +33,15 @@ import { logging } from '@angular-devkit/core'; import * as engine from '../engine/engine'; -import { cleanupMonkeypatch } from '../engine/engine.prepare-options-helpers'; import { defaults } from '../engine/defaults'; describe('PR #186 - Commander Boolean Defaults Compatibility', () => { let logger: logging.LoggerApi; beforeEach(() => { - // Clean up any previous monkeypatch so each test starts fresh - cleanupMonkeypatch(); logger = new logging.NullLogger(); }); - afterAll(() => { - // Clean up monkeypatch after all tests - cleanupMonkeypatch(); - }); - describe('defaults.ts values', () => { it('should have dotfiles: true as default', () => { expect(defaults.dotfiles).toBe(true); From 7fb70898fde74d5483a0459a9b93a617171d7fa8 Mon Sep 17 00:00:00 2001 From: Johannes Hoppe Date: Fri, 9 Oct 2026 23:30:49 +0200 Subject: [PATCH 03/17] docs: describe gh-pages-fork --- CLAUDE.md | 20 ++++++++++++++------ README.md | 1 - docs/README_contributors.md | 26 ++++---------------------- docs/README_standalone.md | 5 ++--- 4 files changed, 20 insertions(+), 32 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 34f8884..4dcf348 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance when working with code in this repository. ## Overview -`angular-cli-ghpages` is an Angular CLI builder/schematic that deploys Angular applications to GitHub Pages, Cloudflare Pages, or any Git repository. It wraps the `gh-pages` npm package and integrates with Angular CLI's deployment infrastructure via `ng deploy`. +`angular-cli-ghpages` is an Angular CLI builder/schematic that deploys Angular applications to GitHub Pages, Cloudflare Pages, or any Git repository. It publishes via `src/gh-pages-fork/`, a minimal TypeScript fork of the `gh-pages` npm package, and integrates with Angular CLI's deployment infrastructure via `ng deploy`. ## Development Commands @@ -106,9 +106,13 @@ npm dist-tag add angular-cli-ghpages@X.X.X-rc.X next - Uses `commander` for CLI parsing 4. **Core Engine** (`src/engine/`): - - `engine.ts` - Core deployment logic (wraps gh-pages) + - `engine.ts` - Core deployment logic (publishes via gh-pages-fork) - `defaults.ts` - Default configuration values +5. **gh-pages fork** (`src/gh-pages-fork/`): + - Minimal TypeScript fork of `gh-pages`, internal use only (see its `README.md`) + - Same file structure and function names as upstream (`lib/index.ts`, `lib/git.ts`, `lib/util.ts`) + ### Deployment Flow ``` @@ -121,10 +125,14 @@ actions.ts (deploy function) β”‚ Uses BuilderContext.scheduleTarget() └─→ engine.run() β”œβ”€β†’ Prepare options (tokens, CI env vars) - β”œβ”€β†’ Create .nojekyll file (bypasses Jekyll on GitHub) - β”œβ”€β†’ Create 404.html (copy of index.html for SPAs) - β”œβ”€β†’ Create CNAME file (if custom domain) - └─→ Publish via gh-pages package + β”œβ”€β†’ Clean the cache (every deploy starts from a fresh clone) + β”œβ”€β†’ Create 404.html in dist (copy of index.html for SPAs) + └─→ gh-pages-fork publish() + β”œβ”€β†’ Clone the target branch (or create it as an orphan branch) + β”œβ”€β†’ Remove the branch content (skipped with --add) + β”œβ”€β†’ Write .nojekyll (bypasses Jekyll on GitHub) and CNAME (if custom domain) + β”œβ”€β†’ Copy dist, git add, commit (only if something changed) + └─→ Push ``` ### Build Target Resolution diff --git a/README.md b/README.md index e7467a1..bf972b8 100644 --- a/README.md +++ b/README.md @@ -354,7 +354,6 @@ A CNAME file will be created enabling you to use a custom domain. If it is set to `true`, it will only add, and never remove existing files. By default, existing files in the target branch are removed before adding new ones. -[More information](https://www.npmjs.com/package/gh-pages#optionsadd). #### --dir diff --git a/docs/README_contributors.md b/docs/README_contributors.md index 439cab7..f6284cf 100644 --- a/docs/README_contributors.md +++ b/docs/README_contributors.md @@ -13,8 +13,7 @@ - [Programmatic Usage](#programmatic-usage) - [Available Types](#available-types) - [Advanced: Angular Builder Integration](#advanced-angular-builder-integration) - - [Dependency on gh-pages Internal API](#dependency-on-gh-pages-internal-api) - - [Remote URL Discovery](#remote-url-discovery) + - [gh-pages fork](#gh-pages-fork) - [Supporting a new Angular version](#supporting-a-new-angular-version) - [Keeping track of all the forks](#keeping-track-of-all-the-forks) @@ -252,7 +251,7 @@ The package exports these TypeScript types for programmatic usage: - `Schema` - Complete options interface - `PreparedOptions` - Internal options after processing - `DeployUser` - User credentials type -- `GHPages` - gh-pages library wrapper interface +- `GHPages`, `PublishOptions` - deprecated, describe the API of the gh-pages npm package - `defaults` - Default configuration object ### Advanced: Angular Builder Integration @@ -268,26 +267,9 @@ const result = await angularDeploy(context, builderConfig, 'your-project-name'); **Note:** The CLI (`ng deploy`) remains the primary and recommended way to use this tool. Programmatic usage is considered advanced/experimental and may change between versions. -## Dependency on gh-pages Internal API +## gh-pages fork -### Remote URL Discovery - -The `getRemoteUrl` function in `src/engine/engine.prepare-options-helpers.ts` calls into `gh-pages/lib/git`, which is an **internal API** not documented in gh-pages' public interface. This dependency carries upgrade risk. - -**What we depend on:** -- `new Git(process.cwd(), options.git).getRemoteUrl(options.remote)` from `gh-pages/lib/git` -- The exact error message format when remote doesn't exist or not in a git repository - -**Upgrade process for gh-pages v6+:** - -1. Check test failures in `src/engine/engine.prepare-options-helpers.spec.ts` first, specifically the `getRemoteUrl` test block -2. If those tests fail, it likely indicates a breaking change in gh-pages' internal Git API -3. Options: - - If `gh-pages/lib/git` still exists with same interface: update our error message assertions - - If the internal API changed significantly: implement our own git remote discovery using `child_process.execSync('git config --get remote.{remote}.url')` - - If gh-pages added a public API for this: switch to the public API - -**Current baseline:** Tests are pinned to gh-pages v6.3.0 behavior and error messages. +Publishing (clone the target branch, replace its content, commit, push) lives in [`src/gh-pages-fork/`](../src/gh-pages-fork/README.md), a minimal TypeScript fork of the [gh-pages](https://github.com/tschaub/gh-pages) npm package. It exists solely for angular-cli-ghpages and keeps the upstream file structure and function names, so upstream changes can be compared and ported file by file. Its README lists the upstream base commit, what was kept and what was removed. ## Compatibility principle diff --git a/docs/README_standalone.md b/docs/README_standalone.md index 25f3ece..542fc6d 100644 --- a/docs/README_standalone.md +++ b/docs/README_standalone.md @@ -119,7 +119,7 @@ Output the version number. Please provide the version number on any bug report! - Default: URL of the origin remote of the current dir (assumes a git repository) - Example: `npx angular-cli-ghpages --repo=https://GH_TOKEN@github.com//.git` -By default, **gh-pages** assumes that the current working directory is a git repository, +By default, **angular-cli-ghpages** assumes that the current working directory is a git repository, and that you want to push changes to the `origin` remote. If instead, your files are not in a git repository, or if you want to push to another repository, you can provide the repository URL in the `repo` option. @@ -130,7 +130,7 @@ you can provide the repository URL in the `repo` option. - Default: `origin` - Example: `npx angular-cli-ghpages --remote=github` -By default, **gh-pages** assumes that the current working directory is a git repository, +By default, **angular-cli-ghpages** assumes that the current working directory is a git repository, and that you want to push changes to the `origin` remote. If you want to push to another remote, you can provide the remote name in the `remote` option. @@ -216,7 +216,6 @@ A CNAME file will be created enabling you to use a custom domain. [More informat If is set to `true`, it will only add, and never remove existing files. By default, existing files in the target branch are removed before adding the ones. -[More information](https://www.npmjs.com/package/gh-pages#optionsadd). #### --no-notfound From e896cd3420a2d4b339489b3e82f77bc30ee41f48 Mon Sep 17 00:00:00 2001 From: Johannes Hoppe Date: Sat, 10 Oct 2026 00:34:17 +0200 Subject: [PATCH 04/17] refactor: remove setupMonkeypatch and the gh-pages types No external code depends on them, so they don't need to stay as deprecated exports. --- docs/README_contributors.md | 1 - .../engine.prepare-options-helpers.spec.ts | 14 +------- src/engine/engine.prepare-options-helpers.ts | 8 ----- src/interfaces.ts | 35 ------------------- src/public-api.spec.ts | 4 --- src/public_api.ts | 3 -- 6 files changed, 1 insertion(+), 64 deletions(-) diff --git a/docs/README_contributors.md b/docs/README_contributors.md index f6284cf..4b5d3a0 100644 --- a/docs/README_contributors.md +++ b/docs/README_contributors.md @@ -251,7 +251,6 @@ The package exports these TypeScript types for programmatic usage: - `Schema` - Complete options interface - `PreparedOptions` - Internal options after processing - `DeployUser` - User credentials type -- `GHPages`, `PublishOptions` - deprecated, describe the API of the gh-pages npm package - `defaults` - Default configuration object ### Advanced: Angular Builder Integration diff --git a/src/engine/engine.prepare-options-helpers.spec.ts b/src/engine/engine.prepare-options-helpers.spec.ts index ac137d4..0e9cd63 100644 --- a/src/engine/engine.prepare-options-helpers.spec.ts +++ b/src/engine/engine.prepare-options-helpers.spec.ts @@ -58,17 +58,6 @@ describe('prepareOptions helpers - intensive tests', () => { process.env = originalEnv; }); - describe('setupMonkeypatch (deprecated)', () => { - it('should leave util.debuglog untouched', () => { - const util = require('util'); - const debuglogBefore = util.debuglog; - - helpers.setupMonkeypatch(testLogger); - - expect(util.debuglog).toBe(debuglogBefore); - }); - }); - describe('mapNegatedBooleans', () => { it('should set dotfiles to false when noDotfiles is true', () => { const options: helpers.PreparedOptions = { dotfiles: true, notfound: true, nojekyll: true }; @@ -571,8 +560,7 @@ describe('prepareOptions helpers - intensive tests', () => { it('should throw helpful error when not in a git repository', async () => { // Change to a non-git directory const originalCwd = process.cwd(); - const fs = require('fs/promises'); - const tempDir = path.join(require('os').tmpdir(), 'not-a-git-repo-test-' + Date.now()); + const tempDir = path.join(os.tmpdir(), 'not-a-git-repo-test-' + Date.now()); await fs.mkdir(tempDir, { recursive: true }); try { diff --git a/src/engine/engine.prepare-options-helpers.ts b/src/engine/engine.prepare-options-helpers.ts index 982d2bb..057196c 100644 --- a/src/engine/engine.prepare-options-helpers.ts +++ b/src/engine/engine.prepare-options-helpers.ts @@ -22,14 +22,6 @@ export type PreparedOptions = Schema & { user?: { name: string; email: string }; }; -/** - * @deprecated No-op. Kept for API compatibility; the deploy engine passes its - * logger to the publish step directly. Will be removed in the next major version. - */ -export function setupMonkeypatch(_logger: logging.LoggerApi): void { - // intentionally empty -} - /** * Map negated boolean options to positive boolean options * diff --git a/src/interfaces.ts b/src/interfaces.ts index 6f7213e..f3dd13e 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,5 +1,3 @@ -import type {Git} from './gh-pages-fork/lib/git'; - /** * Angular outputPath configuration * Can be either a string (path) or an object with base + browser properties @@ -48,39 +46,6 @@ export interface DeployUser { email: string; } -/** - * Options of the publish() function of the gh-pages npm package. - * - * @deprecated angular-cli-ghpages doesn't use the gh-pages package. Kept for API - * compatibility; will be removed in the next major version. - */ -export interface PublishOptions { - repo?: string; - remote?: string; - branch?: string; - message?: string; - user?: { name: string; email: string }; - dotfiles?: boolean; - nojekyll?: boolean; - cname?: string; - add?: boolean; - git?: string; - beforeAdd?: (git: Git) => void | Promise; - [key: string]: unknown; // Allow additional gh-pages options -} - -/** - * API of the gh-pages npm package. - * - * @deprecated angular-cli-ghpages doesn't use the gh-pages package. Kept for API - * compatibility; will be removed in the next major version. - */ -export interface GHPages { - publish(dir: string, options: PublishOptions, callback: (error: Error | null) => void): void; - publish(dir: string, options: PublishOptions): Promise; - clean?(): void; -} - export interface ArchitectTarget { builder: string; options?: { diff --git a/src/public-api.spec.ts b/src/public-api.spec.ts index f291bbe..0928192 100644 --- a/src/public-api.spec.ts +++ b/src/public-api.spec.ts @@ -81,10 +81,6 @@ describe('Public API', () => { }); describe('Advanced Option Processing Functions', () => { - it('should export setupMonkeypatch function', () => { - expect(typeof publicApi.setupMonkeypatch).toBe('function'); - }); - it('should export mapNegatedBooleans function', () => { expect(typeof publicApi.mapNegatedBooleans).toBe('function'); }); diff --git a/src/public_api.ts b/src/public_api.ts index 5bdec14..616c124 100644 --- a/src/public_api.ts +++ b/src/public_api.ts @@ -13,8 +13,6 @@ export * from './deploy/builder'; // Schema and options types export { Schema } from './deploy/schema'; export { - GHPages, - PublishOptions, DeployUser, AngularOutputPath, AngularOutputPathObject, @@ -30,7 +28,6 @@ export { run as deployToGHPages } from './engine/engine'; // Advanced: Extracted option processing functions for custom workflows export { PreparedOptions, - setupMonkeypatch, mapNegatedBooleans, handleUserCredentials, warnDeprecatedParameters, From 50cfa13879c5eba8e1710899aff939e337a25e23 Mon Sep 17 00:00:00 2001 From: Johannes Hoppe Date: Sat, 10 Oct 2026 00:39:13 +0200 Subject: [PATCH 05/17] test: port the gh-pages specs to gh-pages-fork instead of deleting them The behavior, clean, cleanup, file creation, integration and no-package-json specs pin the deploy behavior, so they now run against gh-pages-fork with their expectations unchanged where the behavior is the same. Tests that only demonstrated upstream gh-pages bugs are dropped. The behavior spec used done callbacks, which Vitest doesn't support: the first parameter is the test context, so these 24 tests passed regardless of their assertions. They are async now and fail on a wrong expectation. --- src/engine/engine.gh-pages-behavior.spec.ts | 1062 +++++++++++++++++ src/engine/engine.gh-pages-clean.spec.ts | 44 + src/engine/engine.gh-pages-cleanup.spec.ts | 199 +++ .../engine.gh-pages-filecreation.spec.ts | 182 +++ .../engine.gh-pages-integration.spec.ts | 589 +++++++++ .../engine.gh-pages-no-package-json.spec.ts | 113 ++ src/engine/engine.spec.ts | 2 - 7 files changed, 2189 insertions(+), 2 deletions(-) create mode 100644 src/engine/engine.gh-pages-behavior.spec.ts create mode 100644 src/engine/engine.gh-pages-clean.spec.ts create mode 100644 src/engine/engine.gh-pages-cleanup.spec.ts create mode 100644 src/engine/engine.gh-pages-filecreation.spec.ts create mode 100644 src/engine/engine.gh-pages-integration.spec.ts create mode 100644 src/engine/engine.gh-pages-no-package-json.spec.ts diff --git a/src/engine/engine.gh-pages-behavior.spec.ts b/src/engine/engine.gh-pages-behavior.spec.ts new file mode 100644 index 0000000..b01e20f --- /dev/null +++ b/src/engine/engine.gh-pages-behavior.spec.ts @@ -0,0 +1,1062 @@ +/** + * Behavioral snapshot tests for gh-pages-fork publish() + * + * These tests pin the git commands that publish() executes, in order and with + * their arguments, to the behavior of gh-pages v6.3.0 that angular-cli-ghpages + * has always relied on. Any change to the fork that alters this behavior fails here. + * + * Approach: Mock child_process and gh-pages-fork/lib/util (copy, getUser), use real filesystem + */ + +import { ChildProcess } from 'child_process'; + +import * as engine from './engine'; +import { logging } from '@angular-devkit/core'; + +const path = require('path'); +const fs = require('fs/promises'); +const os = require('os'); +const { EventEmitter } = require('events'); + +// Mock utils.pathExists so engine.run() passes the dist folder check +vi.mock('../utils', async () => { + const actual = await vi.importActual('../utils'); + return { + ...actual, + pathExists: vi.fn().mockResolvedValue(true) + }; +}); + +// Types for better type safety +interface SpawnCall { + cmd: string; + args: string[]; + options: unknown; +} + +interface TestContext { + repo?: string; +} + +// Mock child_process to capture all git commands +let spawnCalls: SpawnCall[] = []; + +// Track current test context for deterministic mock behavior +let currentTestContext: TestContext = {}; + +// Mock child process with what gh-pages-fork lib/git.ts uses: stdout/stderr 'data', 'close' +function createMockChildProcess(): Partial { + const child: Partial = new EventEmitter(); + child.stdout = new EventEmitter() as unknown as ChildProcess['stdout']; + child.stderr = new EventEmitter() as unknown as ChildProcess['stderr']; + return child; +} + +/** + * Whitelist of git subcommands that publish() may run. + * + * Strict whitelist: any new git operation must be added here first, + * so it can't slip in unnoticed. + */ +const EXPECTED_GIT_COMMANDS = [ + 'clone', 'clean', 'fetch', 'checkout', 'ls-remote', 'reset', + 'rm', 'add', 'config', 'diff-index', 'commit', 'push' +]; + +const { mockSpawn, mockCopy, mockGetUser } = vi.hoisted(() => { + const mockCopy = vi.fn(() => Promise.resolve()); + const mockGetUser = vi.fn(() => Promise.resolve(null)); + const mockSpawn = vi.fn(); + return { mockSpawn, mockCopy, mockGetUser }; +}); + +vi.mock('child_process', () => ({ + spawn: mockSpawn +})); + +vi.mock('../gh-pages-fork/lib/util', async () => ({ + ...(await vi.importActual('../gh-pages-fork/lib/util')), + copy: mockCopy, + getUser: mockGetUser +})); + +// Default implementation; tests that need other responses override it, beforeEach restores it +function defaultSpawn(cmd: string, args: string[] | undefined, opts: unknown) { + const capturedArgs = args || []; + spawnCalls.push({ cmd, args: capturedArgs, options: opts }); + + // Validate git commands are expected + if (cmd === 'git' && capturedArgs[0]) { + if (!EXPECTED_GIT_COMMANDS.includes(capturedArgs[0])) { + throw new Error(`Unexpected git command: ${capturedArgs[0]}. Add to whitelist if intentional.`); + } + } + + const mockChild = createMockChildProcess(); + + // Simulate appropriate response based on git command + setImmediate(() => { + let output = ''; + + // git config --get remote.X.url should return the repo URL + if (cmd === 'git' && capturedArgs[0] === 'config' && capturedArgs[1] === '--get' && + capturedArgs[2] && capturedArgs[2].startsWith('remote.')) { + // Use tracked repo URL for deterministic behavior + output = currentTestContext.repo || ''; + } + // ls-remote should return something for branch existence check + else if (cmd === 'git' && capturedArgs[0] === 'ls-remote') { + output = 'refs/heads/gh-pages'; + } + // diff-index for checking if commit needed (exit 1 means changes exist) + else if (cmd === 'git' && capturedArgs[0] === 'diff-index') { + // Return exit code 1 to indicate changes exist + mockChild.emit!('close', 1); + return; + } + + mockChild.stdout!.emit('data', Buffer.from(output)); + mockChild.emit!('close', 0); + }); + + return mockChild; +} + +import * as ghPages from '../gh-pages-fork/lib'; + +async function publishAndHandle( + basePath: string, + options: ghPages.PublishOptions, + assertions: () => void +): Promise { + await ghPages.publish(basePath, options); + assertions(); +} + +describe('gh-pages-fork publish() - behavioral snapshot (gh-pages v6.3.0 behavior)', () => { + let tempDir: string; + let basePath: string; + + beforeAll(async () => { + // Create a real temp directory with test files + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gh-pages-test-')); + basePath = path.join(tempDir, 'dist'); + await fs.mkdir(basePath, { recursive: true }); + + // Create test files (including dotfiles for testing dotfiles option) + await fs.writeFile(path.join(basePath, 'index.html'), 'test'); + await fs.writeFile(path.join(basePath, 'main.js'), 'console.log("test");'); + await fs.writeFile(path.join(basePath, 'styles.css'), 'body { }'); + await fs.writeFile(path.join(basePath, '.htaccess'), 'RewriteEngine On'); + }); + + afterAll(async () => { + // Clean up temp directory + // force: true matches fse.remove() behavior (no error if path doesn't exist) + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + beforeEach(() => { + // Note: We DON'T call ghPages.clean() here because it interferes with test isolation + // Each test uses a unique repo URL which naturally isolates cache directories + + // Clear all mock calls and context + spawnCalls = []; + currentTestContext = {}; + mockSpawn.mockReset(); + mockSpawn.mockImplementation(defaultSpawn); + mockCopy.mockClear(); + mockGetUser.mockClear(); + }); + + describe('Git command execution order', () => { + it('should execute critical git commands in sequence', async () => { + const repo = 'https://github.com/test/order-test.git'; + const branch = 'gh-pages'; + currentTestContext.repo = repo; + + const options = { repo, branch, message: 'Deploy' }; + + await publishAndHandle(basePath, options, () => { + // Extract git commands in order + const gitCommands = spawnCalls + .filter(call => call.cmd === 'git') + .map(call => call.args[0]); + + // Verify critical commands appear + expect(gitCommands).toContain('clone'); + expect(gitCommands).toContain('add'); + expect(gitCommands).toContain('commit'); + expect(gitCommands).toContain('push'); + + // Verify critical ordering constraints (these MUST be in order) + const cloneIndex = gitCommands.indexOf('clone'); + const addIndex = gitCommands.indexOf('add'); + const commitIndex = gitCommands.indexOf('commit'); + const pushIndex = gitCommands.indexOf('push'); + + expect(cloneIndex).toBeGreaterThan(-1); + expect(addIndex).toBeGreaterThan(cloneIndex); // add after clone + expect(commitIndex).toBeGreaterThan(addIndex); // commit after add + expect(pushIndex).toBeGreaterThan(commitIndex); // push after commit + }); + }); + + it('should execute ls-remote before checkout', async () => { + const repo = 'https://github.com/test/ls-remote-test.git'; + currentTestContext.repo = repo; + const options = { repo, message: 'Deploy' }; + + await publishAndHandle(basePath, options, () => { + const gitCommands = spawnCalls + .filter(call => call.cmd === 'git') + .map(call => call.args[0]); + + const lsRemoteIndex = gitCommands.indexOf('ls-remote'); + const checkoutIndex = gitCommands.indexOf('checkout'); + + // ls-remote checks if branch exists before checkout + expect(lsRemoteIndex).toBeGreaterThan(-1); + expect(checkoutIndex).toBeGreaterThan(-1); + expect(lsRemoteIndex).toBeLessThan(checkoutIndex); + }); + }); + }); + + describe('Git clone command', () => { + it('should clone with exact repository URL', async () => { + const repo = 'https://github.com/angular-schule/test-repo.git'; + const branch = 'production'; + currentTestContext.repo = repo; + const options = { repo, branch, message: 'Deploy' }; + + await publishAndHandle(basePath, options, () => { + const cloneCall = spawnCalls.find(call => + call.cmd === 'git' && call.args[0] === 'clone' + ); + + expect(cloneCall).toBeDefined(); + expect(cloneCall?.args).toContain(repo); + expect(cloneCall?.args).toContain('--branch'); + expect(cloneCall?.args).toContain(branch); + expect(cloneCall?.args).toContain('--single-branch'); + }); + }); + + /** + * Git clone depth controls shallow clone behavior. + * + * What is depth? + * - `--depth N` limits git clone history to N commits + * - gh-pages defaults to depth=1 (only latest commit, optimal for deployments) + * - Smaller depth = faster clones, less disk space, faster CI/CD + * + * Why angular-cli-ghpages does NOT expose this option: + * - We're deploying build artifacts (compiled Angular app) + * - We only care about the latest built version + * - History is in the gh-pages branch itself + * - depth=1 is perfect - no benefit to cloning more history + * + * What we're testing: + * - That gh-pages uses its default depth=1 + * - Exact argument position and value + * - This ensures optimal performance for all deployments + */ + it('should use default depth=1 for optimal performance', async () => { + const repo = 'https://github.com/test/depth-test.git'; + const branch = 'gh-pages'; + currentTestContext.repo = repo; + + // Don't specify depth - rely on gh-pages default + const options = { repo, branch, message: 'Deploy' }; + + await publishAndHandle(basePath, options, () => { + const cloneCall = spawnCalls.find(call => + call.cmd === 'git' && call.args[0] === 'clone' + ); + expect(cloneCall).toBeDefined(); + + // Find --depth flag and verify next argument is exactly 1 + const depthFlagIndex = cloneCall?.args.indexOf('--depth'); + expect(depthFlagIndex).toBeGreaterThan(-1); + expect(cloneCall?.args[depthFlagIndex! + 1]).toBe('1'); + }); + }); + }); + + describe('Git add and commit', () => { + it('should add all files with dot notation', async () => { + const repo = 'https://github.com/test/add-test.git'; + currentTestContext.repo = repo; + const options = { repo, message: 'Deploy' }; + + await publishAndHandle(basePath, options, () => { + const addCall = spawnCalls.find(call => + call.cmd === 'git' && call.args[0] === 'add' + ); + + expect(addCall).toBeDefined(); + expect(addCall?.args[1]).toBe('.'); // Exact position check + }); + }); + + it('should commit with exact message provided', async () => { + const repo = 'https://github.com/test/commit-test.git'; + const message = 'Custom deployment message with special chars: Γ©mojis πŸš€'; + currentTestContext.repo = repo; + const options = { repo, message }; + + await publishAndHandle(basePath, options, () => { + const commitCall = spawnCalls.find(call => + call.cmd === 'git' && call.args[0] === 'commit' + ); + + expect(commitCall).toBeDefined(); + expect(commitCall?.args).toContain('-m'); + const messageIndex = commitCall?.args.indexOf('-m'); + expect(commitCall?.args[messageIndex! + 1]).toBe(message); + }); + }); + }); + + /** + * Git push command + * + * What does angular-cli-ghpages do? + * - We DO NOT pass the 'history' option to gh-pages + * - gh-pages defaults to history: true (normal push, no --force) + * - We rely on this default behavior + * + * What we're testing: + * - Push command includes correct remote and branch + * - Push ALWAYS includes --tags flag (gh-pages behavior) + * - NO --force flag (because we don't pass history: false) + */ + describe('Git push command', () => { + it('should push to correct remote and branch with --tags flag', async () => { + const repo = 'https://github.com/test/push-test.git'; + const branch = 'gh-pages'; + const remote = 'upstream'; + currentTestContext.repo = repo; + const options = { repo, branch, remote, message: 'Deploy' }; + + await publishAndHandle(basePath, options, () => { + const pushCall = spawnCalls.find(call => + call.cmd === 'git' && call.args[0] === 'push' + ); + + expect(pushCall).toBeDefined(); + expect(pushCall?.args).toContain(remote); + expect(pushCall?.args).toContain(branch); + // gh-pages ALWAYS includes --tags in push (lib/git.js line 189) + expect(pushCall?.args).toContain('--tags'); + }); + }); + + it('should NOT use force push (we rely on gh-pages default history: true)', async () => { + const repo = 'https://github.com/test/no-force-test.git'; + currentTestContext.repo = repo; + const options = { repo, message: 'Deploy' }; + + await publishAndHandle(basePath, options, () => { + const pushCall = spawnCalls.find(call => + call.cmd === 'git' && call.args[0] === 'push' + ); + + expect(pushCall).toBeDefined(); + // We don't pass history: false, so gh-pages defaults to history: true (no --force) + expect(pushCall?.args).not.toContain('--force'); + }); + }); + }); + + describe('File copy operation', () => { + it('should copy exact test files from basePath to cache destination', async () => { + const repo = 'https://github.com/test/copy-test.git'; + currentTestContext.repo = repo; + const options = { repo, message: 'Deploy' }; + + await publishAndHandle(basePath, options, () => { + expect(mockCopy).toHaveBeenCalledTimes(1); + + const callArgs = mockCopy.mock.calls[0]; + expect(callArgs).toBeDefined(); + expect(callArgs.length).toBe(3); + + const [files, source, destination] = callArgs as unknown as [string[], string, string]; + + // Verify files array contains EXACTLY our test files + expect(Array.isArray(files)).toBe(true); + expect(files.length).toBe(3); + expect(files).toContain('index.html'); + expect(files).toContain('main.js'); + expect(files).toContain('styles.css'); + + // Verify source is our basePath + expect(source).toBe(basePath); + + // Verify destination is in gh-pages cache + expect(destination).toContain('gh-pages'); + }); + }); + }); + + /** + * Git rm behavior with add option + * + * What is the add option? + * - add: false (default) - Removes existing files before deploying new ones (clean slate) + * - add: true - Adds files without removing existing ones (incremental deployment) + * + * Why this matters: + * - add: false ensures no stale files remain (recommended for most cases) + * - add: true useful for multi-source deployments or preserving manually added files + * + * Critical behavior from gh-pages source (lib/index.js lines 156-170): + * if (options.add) { + * return git; // SKIP removal logic entirely + * } + * // Otherwise: execute globby to find files, then git rm if files exist + * + * Testing strategy: + * - We can't directly test git rm is called because it depends on existing files in cache + * - Instead, we test command ORDERING which differs between add: true and add: false + * - With add: false, checkout happens BEFORE add (removal step in between, even if no files) + * - With add: true, checkout happens BEFORE add (no removal step at all) + * - We also verify that add: true NEVER calls git rm (critical) + */ + describe('Git rm command behavior', () => { + it('should maintain standard command order with add: false (default)', async () => { + const repo = 'https://github.com/test/rm-default-test.git'; + currentTestContext.repo = repo; + const options = { repo, message: 'Deploy', add: false }; + + await publishAndHandle(basePath, options, () => { + const gitCommands = spawnCalls + .filter(call => call.cmd === 'git') + .map(call => call.args[0]); + + // Verify standard flow: checkout β†’ (removal logic) β†’ add β†’ commit + const checkoutIndex = gitCommands.indexOf('checkout'); + const addIndex = gitCommands.indexOf('add'); + const commitIndex = gitCommands.indexOf('commit'); + + expect(checkoutIndex).toBeGreaterThan(-1); + expect(addIndex).toBeGreaterThan(checkoutIndex); + expect(commitIndex).toBeGreaterThan(addIndex); + + // Note: git rm may or may not be called depending on existing files + // The important thing is the removal logic is ATTEMPTED (not skipped) + }); + }); + + it('should NEVER execute git rm when add: true (skips removal entirely)', async () => { + const repo = 'https://github.com/test/rm-add-test.git'; + currentTestContext.repo = repo; + const options = { repo, message: 'Deploy', add: true }; + + await publishAndHandle(basePath, options, () => { + const rmCall = spawnCalls.find(call => + call.cmd === 'git' && call.args[0] === 'rm' + ); + + // CRITICAL: git rm MUST NEVER be called when add: true + // This verifies the removal logic is completely skipped + expect(rmCall).toBeUndefined(); + }); + }); + }); + + /** + * Dotfiles option + * + * What does angular-cli-ghpages do? + * - We PASS dotfiles option to gh-pages (engine.ts line 242) + * - Default in angular-cli-ghpages: dotfiles: true (include dotfiles) + * - User can disable with --no-dotfiles flag + * + * How gh-pages uses it: + * - Controls globby pattern matching for files to copy + * - dotfiles: true β†’ includes files starting with '.' (like .htaccess) + * - dotfiles: false β†’ ignores files starting with '.' + * + * Why we test this: + * - CRITICAL: We expose this option and pass it to gh-pages + * - Must verify it affects file selection behavior + * + * Test fixture has these files: + * - index.html (normal file) + * - main.js (normal file) + * - styles.css (normal file) + * - .htaccess (dotfile) + */ + describe('Dotfiles option', () => { + it('should include dotfiles when dotfiles: true (our default)', async () => { + const repo = 'https://github.com/test/dotfiles-true.git'; + currentTestContext.repo = repo; + const options = { repo, message: 'Deploy', dotfiles: true }; + + await publishAndHandle(basePath, options, () => { + // gh-pages uses globby with { dot: options.dotfiles } (lib/index.js line 90) + expect(mockCopy).toHaveBeenCalledTimes(1); + + const callArgs = mockCopy.mock.calls[0]; + expect(callArgs).toBeDefined(); + expect(callArgs.length).toBe(3); + + const [files, source, destination] = callArgs as unknown as [string[], string, string]; + + // CRITICAL: Verify files array INCLUDES the .htaccess dotfile + expect(Array.isArray(files)).toBe(true); + expect(files.length).toBe(4); // index.html, main.js, styles.css, .htaccess + expect(files).toContain('index.html'); + expect(files).toContain('main.js'); + expect(files).toContain('styles.css'); + expect(files).toContain('.htaccess'); // This is the dotfile + + expect(source).toBe(basePath); + expect(destination).toContain('gh-pages'); + }); + }); + + it('should exclude dotfiles when dotfiles: false', async () => { + const repo = 'https://github.com/test/dotfiles-false.git'; + currentTestContext.repo = repo; + const options = { repo, message: 'Deploy', dotfiles: false }; + + await publishAndHandle(basePath, options, () => { + expect(mockCopy).toHaveBeenCalledTimes(1); + + const callArgs = mockCopy.mock.calls[0]; + expect(callArgs).toBeDefined(); + expect(callArgs.length).toBe(3); + + const [files, source, destination] = callArgs as unknown as [string[], string, string]; + + // CRITICAL: Verify files array EXCLUDES the .htaccess dotfile + expect(Array.isArray(files)).toBe(true); + expect(files.length).toBe(3); // Only index.html, main.js, styles.css + expect(files).toContain('index.html'); + expect(files).toContain('main.js'); + expect(files).toContain('styles.css'); + expect(files).not.toContain('.htaccess'); // Dotfile must be excluded + + expect(source).toBe(basePath); + expect(destination).toContain('gh-pages'); + }); + }); + }); + + /** + * Git executable option + * + * What does angular-cli-ghpages do? + * - We PASS git option to gh-pages (engine.ts line 240) + * - Default: 'git' (relies on PATH) + * - gh-pages defaults.git = 'git' (lib/index.js line 29) + * + * Why we test this: + * - We expose this option through defaults.ts + * - Must verify the option flows through to gh-pages + * + * Testing approach: + * - We pass git: 'git' (our default) + * - Verify git commands are executed + * - This confirms the option is accepted by gh-pages + */ + describe('Git executable option', () => { + it('should accept git executable option (we pass our default "git")', async () => { + const repo = 'https://github.com/test/git-exe-test.git'; + const gitExecutable = 'git'; // Our default from defaults.ts line 15 + currentTestContext.repo = repo; + const options = { repo, message: 'Deploy', git: gitExecutable }; + + await publishAndHandle(basePath, options, () => { + // Find all git commands + const gitCalls = spawnCalls.filter(call => call.cmd === 'git'); + + // CRITICAL: Verify git commands were executed + // This confirms gh-pages accepted our git option + expect(gitCalls.length).toBeGreaterThan(0); + + // Verify critical commands were executed with the git executable + expect(gitCalls.some(call => call.args[0] === 'clone')).toBe(true); + expect(gitCalls.some(call => call.args[0] === 'add')).toBe(true); + expect(gitCalls.some(call => call.args[0] === 'commit')).toBe(true); + }); + }); + }); + + describe('User credentials', () => { + it('should configure git user.email with exact value', async () => { + const repo = 'https://github.com/test/user-test.git'; + const name = 'Deploy Bot'; + const email = 'bot@example.com'; + currentTestContext.repo = repo; + const options = { repo, message: 'Deploy', user: { name, email } }; + + await publishAndHandle(basePath, options, () => { + const configEmailCall = spawnCalls.find(call => + call.cmd === 'git' && + call.args[0] === 'config' && + call.args[1] === 'user.email' + ); + + expect(configEmailCall).toBeDefined(); + expect(configEmailCall?.args[2]).toBe(email); // Exact position + }); + }); + + it('should configure git user.name with exact value', async () => { + const repo = 'https://github.com/test/username-test.git'; + const name = 'Deploy Bot'; + const email = 'bot@example.com'; + currentTestContext.repo = repo; + const options = { repo, message: 'Deploy', user: { name, email } }; + + await publishAndHandle(basePath, options, () => { + const configNameCall = spawnCalls.find(call => + call.cmd === 'git' && + call.args[0] === 'config' && + call.args[1] === 'user.name' + ); + + expect(configNameCall).toBeDefined(); + expect(configNameCall?.args[2]).toBe(name); // Exact position + }); + }); + }); + + /** + * Git tag command - Verify we DON'T use tagging + * + * What does angular-cli-ghpages do? + * - We DO NOT pass the 'tag' option to gh-pages + * - We DO NOT expose tag option in schema.json + * - Therefore, no git tags should ever be created + * + * Why test this? + * - Ensures our deployments don't accidentally create tags + * - If gh-pages changes defaults, we'll catch it + */ + describe('Git tag command', () => { + it('should NEVER create git tags (we do not use tag option)', async () => { + const repo = 'https://github.com/test/no-tag-test.git'; + currentTestContext.repo = repo; + const options = { repo, message: 'Deploy' }; + + await publishAndHandle(basePath, options, () => { + const tagCall = spawnCalls.find(call => + call.cmd === 'git' && call.args[0] === 'tag' + ); + + // CRITICAL: We should NEVER create tags + expect(tagCall).toBeUndefined(); + }); + }); + }); + + /** + * gh-pages default options we DON'T pass + * + * These tests verify gh-pages default behavior for options we don't pass. + * This ensures we understand what gh-pages is doing on our behalf. + * + * Options we DON'T pass (gh-pages uses defaults): + * - push: true (default) - Always pushes to remote + * - dest: '.' (default) - Deploys to root of branch + * - src: '**\/*' (default) - Includes all files + * - remove: '.' (default) - Removes existing files (unless add: true) + * - silent: false (default) - gh-pages logs output (we used to have noSilent but it's DEPRECATED) + * + * Note on 'silent' option: + * - We have 'noSilent' in schema.json but it's DEPRECATED and IGNORED + * - We do NOT pass 'silent' to gh-pages + * - gh-pages defaults to silent: false (logging enabled) + * - This is what we want: verbose logging for deployments + */ + describe('gh-pages default options (we do not pass these)', () => { + it('should push to remote (gh-pages default push: true)', async () => { + const repo = 'https://github.com/test/default-push-test.git'; + currentTestContext.repo = repo; + const options = { repo, message: 'Deploy' }; + // We DON'T pass push option, gh-pages defaults to push: true + + await publishAndHandle(basePath, options, () => { + const pushCall = spawnCalls.find(call => + call.cmd === 'git' && call.args[0] === 'push' + ); + + // CRITICAL: Push MUST be called (gh-pages default: push: true) + expect(pushCall).toBeDefined(); + }); + }); + + it('should use root destination (gh-pages default dest: ".")', async () => { + const repo = 'https://github.com/test/default-dest-test.git'; + currentTestContext.repo = repo; + const options = { repo, message: 'Deploy' }; + // We DON'T pass dest option, gh-pages defaults to dest: '.' + + await publishAndHandle(basePath, options, () => { + // Copy destination should be at root of cache (not in subdirectory) + expect(mockCopy).toHaveBeenCalledTimes(1); + + const callArgs = mockCopy.mock.calls[0]; + const [files, source, destination] = callArgs as unknown as [string[], string, string]; + + // Destination should be in gh-pages cache, not in a subdirectory + expect(destination).toContain('gh-pages'); + // Should NOT contain additional path segments beyond cache dir + // (dest: '.' means deploy to root of branch) + }); + }); + + it('should include all files (gh-pages default src: "**/*")', async () => { + const repo = 'https://github.com/test/default-src-test.git'; + currentTestContext.repo = repo; + const options = { repo, message: 'Deploy' }; + // We DON'T pass src option, gh-pages defaults to src: '**/*' + + await publishAndHandle(basePath, options, () => { + expect(mockCopy).toHaveBeenCalledTimes(1); + + const callArgs = mockCopy.mock.calls[0]; + const [files] = callArgs as unknown as [string[], string, string]; + + // All our test files should be included (src: '**/*' means all files) + expect(files.length).toBe(3); + expect(files).toContain('index.html'); + expect(files).toContain('main.js'); + expect(files).toContain('styles.css'); + }); + }); + + it('should use default remove pattern (gh-pages default remove: ".")', async () => { + const repo = 'https://github.com/test/default-remove-test.git'; + currentTestContext.repo = repo; + const options = { repo, message: 'Deploy', add: false }; + // We DON'T pass remove option, gh-pages defaults to remove: '.' + // With add: false, gh-pages will attempt to remove files matching '.' + + await publishAndHandle(basePath, options, () => { + // Since it's a fresh clone, there may be no files to remove + // But we verify the removal logic is executed (not skipped like with add: true) + const gitCommands = spawnCalls + .filter(call => call.cmd === 'git') + .map(call => call.args[0]); + + // Standard flow includes removal logic (even if no files to remove) + expect(gitCommands).toContain('checkout'); + expect(gitCommands).toContain('add'); + }); + }); + }); + + /** + * Error scenarios + * + * gh-pages has robust error handling. We test critical failure paths: + * 1. Git clone failure and retry (branch doesn't exist) + * 2. No changes to commit (diff-index returns 0) + * 3. Git commit when no user configured (uses getUser) + * + * Why test error scenarios: + * - Ensures deployments don't fail silently + * - Verifies retry/fallback mechanisms work + * - Documents expected error recovery behavior + */ + describe('Error scenarios', () => { + it('should retry git clone without branch/depth options on failure', async () => { + const repo = 'https://github.com/test/clone-failure-test.git'; + currentTestContext.repo = repo; + const branch = 'new-branch'; + const options = { repo, branch, message: 'Deploy' }; + + // Reset mock to implement failure behavior + mockSpawn.mockClear(); + + let cloneAttempts = 0; + mockSpawn.mockImplementation((cmd: string, args: string[] | undefined, opts: unknown) => { + const capturedArgs = args || []; + spawnCalls.push({ cmd, args: capturedArgs, options: opts }); + + // Validate git commands are expected + if (cmd === 'git' && capturedArgs[0]) { + if (!EXPECTED_GIT_COMMANDS.includes(capturedArgs[0])) { + throw new Error(`Unexpected git command: ${capturedArgs[0]}`); + } + } + + const mockChild = createMockChildProcess(); + + setImmediate(() => { + // First clone attempt with --branch and --depth should FAIL + if (cmd === 'git' && capturedArgs[0] === 'clone' && cloneAttempts === 0) { + cloneAttempts++; + mockChild.stderr!.emit('data', Buffer.from('fatal: Remote branch new-branch not found')); + mockChild.emit!('close', 128); // Git error code + return; + } + + // Second clone attempt without --branch/--depth should SUCCEED + if (cmd === 'git' && capturedArgs[0] === 'clone' && cloneAttempts === 1) { + cloneAttempts++; + mockChild.stdout!.emit('data', Buffer.from('')); + mockChild.emit!('close', 0); + return; + } + + // All other commands succeed normally + let output = ''; + if (cmd === 'git' && capturedArgs[0] === 'config' && capturedArgs[1] === '--get') { + output = repo; + } else if (cmd === 'git' && capturedArgs[0] === 'ls-remote') { + output = 'refs/heads/gh-pages'; + } else if (cmd === 'git' && capturedArgs[0] === 'diff-index') { + mockChild.emit!('close', 1); + return; + } + + mockChild.stdout!.emit('data', Buffer.from(output)); + mockChild.emit!('close', 0); + }); + + return mockChild; + }); + + await ghPages.publish(basePath, options); + + const cloneCalls = spawnCalls.filter(call => + call.cmd === 'git' && call.args[0] === 'clone' + ); + + // CRITICAL: Must have exactly 2 clone attempts + expect(cloneCalls.length).toBe(2); + + // First attempt: with --branch and --depth + expect(cloneCalls[0].args).toContain('--branch'); + expect(cloneCalls[0].args).toContain(branch); + expect(cloneCalls[0].args).toContain('--depth'); + + // Second attempt: WITHOUT --branch and --depth (fallback) + expect(cloneCalls[1].args).not.toContain('--branch'); + expect(cloneCalls[1].args).not.toContain('--depth'); + expect(cloneCalls[1].args).toContain(repo); + }); + + it('should NOT commit when no changes exist (diff-index returns 0)', async () => { + const repo = 'https://github.com/test/no-changes-test.git'; + currentTestContext.repo = repo; + const options = { repo, message: 'Deploy' }; + + // Reset mock to implement no-changes behavior + mockSpawn.mockClear(); + spawnCalls = []; + + mockSpawn.mockImplementation((cmd: string, args: string[] | undefined, opts: unknown) => { + const capturedArgs = args || []; + spawnCalls.push({ cmd, args: capturedArgs, options: opts }); + + if (cmd === 'git' && capturedArgs[0]) { + if (!EXPECTED_GIT_COMMANDS.includes(capturedArgs[0])) { + throw new Error(`Unexpected git command: ${capturedArgs[0]}`); + } + } + + const mockChild = createMockChildProcess(); + + setImmediate(() => { + let output = ''; + + if (cmd === 'git' && capturedArgs[0] === 'config' && capturedArgs[1] === '--get') { + output = repo; + } else if (cmd === 'git' && capturedArgs[0] === 'ls-remote') { + output = 'refs/heads/gh-pages'; + } else if (cmd === 'git' && capturedArgs[0] === 'diff-index') { + // Return 0 = no changes (opposite of our normal mock) + mockChild.emit!('close', 0); + return; + } + + mockChild.stdout!.emit('data', Buffer.from(output)); + mockChild.emit!('close', 0); + }); + + return mockChild; + }); + + await ghPages.publish(basePath, options); + + const commitCall = spawnCalls.find(call => + call.cmd === 'git' && call.args[0] === 'commit' + ); + + // CRITICAL: Commit should NOT be called when no changes + // gh-pages uses: git diff-index --quiet HEAD || git commit -m "message" + expect(commitCall).toBeUndefined(); + + // But push should still be called (even with no new commits) + const pushCall = spawnCalls.find(call => + call.cmd === 'git' && call.args[0] === 'push' + ); + expect(pushCall).toBeDefined(); + }); + + it('should handle deployment without user credentials (uses getUser)', async () => { + const repo = 'https://github.com/test/no-user-test.git'; + currentTestContext.repo = repo; + const options = { repo, message: 'Deploy' }; + // DON'T pass user option - gh-pages will call getUser() + + // Reset mock to normal behavior (previous test modified it) + mockSpawn.mockClear(); + spawnCalls = []; + + mockSpawn.mockImplementation((cmd: string, args: string[] | undefined, opts: unknown) => { + const capturedArgs = args || []; + spawnCalls.push({ cmd, args: capturedArgs, options: opts }); + + if (cmd === 'git' && capturedArgs[0]) { + if (!EXPECTED_GIT_COMMANDS.includes(capturedArgs[0])) { + throw new Error(`Unexpected git command: ${capturedArgs[0]}`); + } + } + + const mockChild = createMockChildProcess(); + + setImmediate(() => { + let output = ''; + + if (cmd === 'git' && capturedArgs[0] === 'config' && capturedArgs[1] === '--get' && + capturedArgs[2] && capturedArgs[2].startsWith('remote.')) { + output = currentTestContext.repo || ''; + } else if (cmd === 'git' && capturedArgs[0] === 'ls-remote') { + output = 'refs/heads/gh-pages'; + } else if (cmd === 'git' && capturedArgs[0] === 'diff-index') { + mockChild.emit!('close', 1); + return; + } + + mockChild.stdout!.emit('data', Buffer.from(output)); + mockChild.emit!('close', 0); + }); + + return mockChild; + }); + + await ghPages.publish(basePath, options); + + // Verify getUser was called (our mock returns null) + expect(mockGetUser).toHaveBeenCalled(); + + // Verify NO git config commands for user.name/user.email + const configUserCalls = spawnCalls.filter(call => + call.cmd === 'git' && + call.args[0] === 'config' && + (call.args[1] === 'user.name' || call.args[1] === 'user.email') + ); + + // When getUser returns null, gh-pages skips git config + // (relies on global/local git config) + expect(configUserCalls.length).toBe(0); + }); + }); + + /** + * gh-pages v6+ CNAME and .nojekyll file creation + * + * IMPORTANT: File creation tests are in engine.gh-pages-filecreation.spec.ts + * Those tests run WITHOUT mocks to verify actual file creation behavior. + * + * This mocked test file cannot test actual file creation because: + * - We mock child_process.spawn (git commands) + * - gh-pages creates files in git.cwd which is set during clone + * - Since clone is mocked, the directory structure isn't created + * + * See engine.gh-pages-filecreation.spec.ts for real file creation tests. + */ + + /** + * gh-pages.clean() behavior is tested in engine.gh-pages-clean.spec.ts + * That test uses real filesystem operations without mocks. + */ + + /** + * Regression for issue #205: a failing git command must reject engine.run(), + * never end in a "Successfully published" message. + */ + describe('auth-failure regression (issue #205)', () => { + it('engine.run() should reject when git clone fails with fatal: Authentication failed', async () => { + mockSpawn.mockImplementation((cmd: string, args: string[] | undefined) => { + const capturedArgs = args || []; + spawnCalls.push({ cmd, args: capturedArgs, options: undefined }); + const child = createMockChildProcess(); + setImmediate(() => { + child.stderr!.emit( + 'data', + Buffer.from("fatal: Authentication failed for 'https://github.com/owner/repo.git'") + ); + child.emit!('close', 128); + }); + return child; + }); + + const options = { + repo: 'https://x-access-token:bad-token@github.com/owner/repo.git', + branch: 'gh-pages', + dotfiles: true, + notfound: true, + nojekyll: true + }; + + await expect( + engine.run(basePath, options, new logging.NullLogger()) + ).rejects.toThrow('fatal: Authentication failed'); + + const cloneCall = spawnCalls.find(c => c.cmd === 'git' && c.args[0] === 'clone'); + expect(cloneCall).toBeDefined(); + }); + }); + + /** + * Regression for issue #204: leftover files of the gh-pages branch (dotfiles, + * dot-directories, submodule gitlinks) must not survive a deploy. publish() + * removes every tracked file before copying dist, unless `add` is set. + */ + describe('submodule / dotfile cleanup regression (issue #204)', () => { + it('engine.run() should remove all tracked files after checkout and before adding dist', async () => { + const repo = 'https://github.com/owner/leftover-test.git'; + currentTestContext.repo = repo; + + const options = { + repo, + branch: 'gh-pages', + dotfiles: true, + notfound: false, + nojekyll: false + }; + + await engine.run(basePath, options, new logging.NullLogger()); + + const gitCalls = spawnCalls.filter(c => c.cmd === 'git').map(c => c.args.join(' ')); + const resetIndex = gitCalls.indexOf('reset --hard origin/gh-pages'); + const rmIndex = gitCalls.indexOf('rm --ignore-unmatch -r -f -- .'); + const addIndex = gitCalls.indexOf('add .'); + + expect(resetIndex).toBeGreaterThan(-1); + expect(rmIndex).toBeGreaterThan(resetIndex); + expect(addIndex).toBeGreaterThan(rmIndex); + expect(mockCopy).toHaveBeenCalledTimes(1); + }); + + it('engine.run() should keep existing files with add: true', async () => { + const repo = 'https://github.com/owner/leftover-add-test.git'; + currentTestContext.repo = repo; + + await engine.run(basePath, { repo, add: true, notfound: false, nojekyll: false }, new logging.NullLogger()); + + expect(spawnCalls.some(c => c.cmd === 'git' && c.args[0] === 'rm')).toBe(false); + }); + }); +}); diff --git a/src/engine/engine.gh-pages-clean.spec.ts b/src/engine/engine.gh-pages-clean.spec.ts new file mode 100644 index 0000000..c5c71af --- /dev/null +++ b/src/engine/engine.gh-pages-clean.spec.ts @@ -0,0 +1,44 @@ +/** + * gh-pages-fork clean() behavior verification + * + * What we're testing: + * - clean() actually removes cache directories + * + * Why test files run serially (see vitest.config.mts): + * The cache at node_modules/.cache/gh-pages/ is shared by all repositories. + * clean() wipes the ENTIRE cache directory, so a parallel test file could + * lose a clone it is actively using. + */ + +import * as path from 'path'; +import * as fs from 'fs/promises'; + +import { pathExists } from '../utils'; + +import * as ghPages from '../gh-pages-fork/lib'; + +describe('gh-pages-fork clean() behavior', () => { + + it('should remove repo-specific cache directories', async () => { + // Create a fake repo-specific cache directory + const fakeRepoUrl = 'https://github.com/test/clean-test.git'; + const repoCacheDir = ghPages.getCacheDir(fakeRepoUrl); + + // Setup: create the fake cache directory with a marker file + await fs.mkdir(repoCacheDir, { recursive: true }); + await fs.writeFile(path.join(repoCacheDir, 'marker.txt'), 'should be deleted'); + expect(await pathExists(repoCacheDir)).toBe(true); + + // Execute clean - this removes ALL repo cache directories + await ghPages.clean(); + + // Verify the directory was removed + expect(await pathExists(repoCacheDir)).toBe(false); + }); + + it('should not throw when cache directory does not exist', async () => { + // clean() should be safe to call even if nothing to clean + await ghPages.clean(); + await expect(ghPages.clean()).resolves.toBeUndefined(); + }); +}); diff --git a/src/engine/engine.gh-pages-cleanup.spec.ts b/src/engine/engine.gh-pages-cleanup.spec.ts new file mode 100644 index 0000000..f1768ef --- /dev/null +++ b/src/engine/engine.gh-pages-cleanup.spec.ts @@ -0,0 +1,199 @@ +/** + * End-to-end integration test for issue #204 β€” REAL git, REAL gh-pages-fork, REAL filesystem. + * + * No mocks. This test proves that a deploy produces a clean gh-pages commit + * when the branch previously contained dotfiles and submodule gitlinks. + * + * Flow: + * 1. Create a local bare git repo (serves as "remote"). + * 2. Seed a gh-pages branch containing: + * - .github/workflows/deploy.yml (dot-directory, nested) + * - .gitignore (dotfile) + * - .gitmodules (dotfile) + * - a submodule gitlink `build` (mode 160000) + * - stale.html (regular stale file) + * 3. Run `engine.run()` against the bare repo with a dist containing only index.html. + * 4. `git ls-tree -r gh-pages` on the bare repo β†’ assert ONLY `index.html` landed. + */ + +import * as path from 'path'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import { execSync } from 'child_process'; + +import { logging } from '@angular-devkit/core'; + +import * as engine from './engine'; +import * as ghPages from '../gh-pages-fork/lib'; + +interface GitOptions { + readonly cwd: string; +} + +function git(args: string, options: GitOptions): string { + return execSync(`git -C "${options.cwd}" ${args}`, { stdio: ['pipe', 'pipe', 'pipe'] }) + .toString() + .trim(); +} + +async function seedGhPagesBranch(workDir: string, bareRepoPath: string): Promise { + await fs.mkdir(workDir, { recursive: true }); + execSync(`git init "${workDir}"`, { stdio: 'pipe' }); + git('config user.email "seed@test.com"', { cwd: workDir }); + git('config user.name "Seed"', { cwd: workDir }); + git('checkout -b gh-pages', { cwd: workDir }); + + // Dotfiles and dot-directory + await fs.mkdir(path.join(workDir, '.github', 'workflows'), { recursive: true }); + await fs.writeFile(path.join(workDir, '.github', 'workflows', 'deploy.yml'), 'name: deploy\n'); + await fs.writeFile(path.join(workDir, '.gitignore'), 'node_modules\n'); + await fs.writeFile( + path.join(workDir, '.gitmodules'), + '[submodule "build"]\n\tpath = build\n\turl = https://example.invalid/build.git\n' + ); + // A regular non-dot file + await fs.writeFile(path.join(workDir, 'stale.html'), 'stale\n'); + + git('add .github .gitignore .gitmodules stale.html', { cwd: workDir }); + git('commit -m "seed dotfiles and stale content"', { cwd: workDir }); + + // Add a submodule gitlink without needing a real subrepo. + // update-index --cacheinfo creates the 160000 entry directly in the index. + const seedSha = git('rev-parse HEAD', { cwd: workDir }); + git(`update-index --add --cacheinfo 160000,${seedSha},build`, { cwd: workDir }); + git('commit -m "add submodule gitlink"', { cwd: workDir }); + + git(`remote add origin "${bareRepoPath}"`, { cwd: workDir }); + git('push origin gh-pages', { cwd: workDir }); +} + +describe('end-to-end cleanup regression (issue #204, real git)', () => { + let tempDir: string; + let distDir: string; + let bareRepoPath: string; + let originalEnv: NodeJS.ProcessEnv; + + // Unique suffix so parallel-ish reruns never collide in /tmp. + const testRunId = `${Date.now()}-${Math.random().toString(36).substring(7)}`; + + beforeEach(async () => { + // Isolate from ambient CI envs and tokens so engine.prepareOptions doesn't + // rewrite the repo URL or append CI metadata during tests. + originalEnv = { ...process.env }; + delete process.env.TRAVIS; + delete process.env.CIRCLECI; + delete process.env.GITHUB_ACTIONS; + delete process.env.GH_TOKEN; + delete process.env.PERSONAL_TOKEN; + delete process.env.GITHUB_TOKEN; + + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), `ghp-204-${testRunId}-`)); + + distDir = path.join(tempDir, 'dist'); + await fs.mkdir(distDir, { recursive: true }); + await fs.writeFile(path.join(distDir, 'index.html'), 'fresh dist\n'); + + bareRepoPath = path.join(tempDir, 'bare.git'); + execSync(`git init --bare "${bareRepoPath}"`, { stdio: 'pipe' }); + + await seedGhPagesBranch(path.join(tempDir, 'seed'), bareRepoPath); + + // Each test starts from a clean clone. + await ghPages.clean(); + }); + + afterEach(async () => { + await ghPages.clean(); + process.env = originalEnv; + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('engine.run() produces a gh-pages commit containing ONLY dist files (no dotfiles, no submodule)', async () => { + await engine.run( + distDir, + { + repo: bareRepoPath, + branch: 'gh-pages', + dotfiles: true, + // Keep assertions simple: no 404.html / .nojekyll in the expected set. + notfound: false, + nojekyll: false, + name: 'Test', + email: 'test@test.com', + message: 'test deploy' + }, + new logging.NullLogger() + ); + + // Inspect what actually landed on gh-pages in the bare repo. + const tree = git('ls-tree -r gh-pages --name-only', { cwd: bareRepoPath }) + .split('\n') + .filter(Boolean) + .sort(); + + expect(tree).toEqual(['index.html']); + + // Explicit negative checks β€” makes regressions very readable on failure. + expect(tree).not.toContain('.gitignore'); + expect(tree).not.toContain('.gitmodules'); + expect(tree).not.toContain('.github/workflows/deploy.yml'); + expect(tree).not.toContain('build'); + expect(tree).not.toContain('stale.html'); + }, 30_000); + + it('keeps .nojekyll and CNAME on a redeploy', async () => { + const deploy = () => engine.run( + distDir, + { + repo: bareRepoPath, + branch: 'gh-pages', + dotfiles: true, + notfound: false, + nojekyll: true, + cname: 'example.org', + name: 'Test', + email: 'test@test.com', + message: 'test deploy' + }, + new logging.NullLogger() + ); + + await deploy(); + await fs.writeFile(path.join(distDir, 'index.html'), 'second deploy\n'); + await deploy(); + + const tree = git('ls-tree -r gh-pages --name-only', { cwd: bareRepoPath }) + .split('\n') + .filter(Boolean) + .sort(); + + expect(tree).toEqual(['.nojekyll', 'CNAME', 'index.html']); + expect(git('show gh-pages:CNAME', { cwd: bareRepoPath })).toBe('example.org'); + }, 30_000); + + it('removes a leftover .nojekyll when nojekyll is disabled', async () => { + const deploy = (nojekyll: boolean) => engine.run( + distDir, + { + repo: bareRepoPath, + branch: 'gh-pages', + dotfiles: true, + notfound: false, + nojekyll, + name: 'Test', + email: 'test@test.com', + message: 'test deploy' + }, + new logging.NullLogger() + ); + + await deploy(true); + await deploy(false); + + const tree = git('ls-tree -r gh-pages --name-only', { cwd: bareRepoPath }) + .split('\n') + .filter(Boolean); + + expect(tree).toEqual(['index.html']); + }, 30_000); +}); diff --git a/src/engine/engine.gh-pages-filecreation.spec.ts b/src/engine/engine.gh-pages-filecreation.spec.ts new file mode 100644 index 0000000..b789aa7 --- /dev/null +++ b/src/engine/engine.gh-pages-filecreation.spec.ts @@ -0,0 +1,182 @@ +/** + * gh-pages-fork file creation tests (REAL filesystem with local git repo) + * + * These tests verify that publish() ACTUALLY creates CNAME and .nojekyll files + * on the REAL filesystem when the options are passed. + * + * IMPORTANT: These tests do NOT mock child_process or fs. + * + * Approach: + * - Create a local bare git repository for each test + * - publish() clones from and pushes to the local repo (no network required) + * - Verify files in the clone after publish completes + * + * 404.html is created by the engine in dist; CNAME and .nojekyll are written + * by publish() into its clone. + */ + +import * as path from 'path'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import { execSync } from 'child_process'; + +import { pathExists } from '../utils'; + +import * as ghPages from '../gh-pages-fork/lib'; + +describe('gh-pages-fork CNAME/.nojekyll file creation (REAL filesystem)', () => { + let tempDir: string; + let basePath: string; + let bareRepoPath: string; + + // Unique ID to prevent conflicts with parallel test runs + const testRunId = `${Date.now()}-${Math.random().toString(36).substring(7)}`; + + beforeEach(async () => { + // Create a unique temp directory for this test + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), `gh-pages-file-test-${testRunId}-`)); + + // Create source directory with minimal files + basePath = path.join(tempDir, 'dist'); + await fs.mkdir(basePath, { recursive: true }); + await fs.writeFile(path.join(basePath, 'index.html'), 'test'); + + // Create a local bare git repository that gh-pages can clone from + bareRepoPath = path.join(tempDir, 'bare-repo.git'); + execSync(`git init --bare "${bareRepoPath}"`, { stdio: 'pipe' }); + + // Initialize the gh-pages branch in the bare repo with an initial commit + const initWorkDir = path.join(tempDir, 'init-work'); + await fs.mkdir(initWorkDir, { recursive: true }); + execSync(`git init "${initWorkDir}"`, { stdio: 'pipe' }); + execSync(`git -C "${initWorkDir}" config user.email "test@test.com"`, { stdio: 'pipe' }); + execSync(`git -C "${initWorkDir}" config user.name "Test"`, { stdio: 'pipe' }); + await fs.writeFile(path.join(initWorkDir, 'README.md'), 'init'); + execSync(`git -C "${initWorkDir}" add .`, { stdio: 'pipe' }); + execSync(`git -C "${initWorkDir}" commit -m "init"`, { stdio: 'pipe' }); + execSync(`git -C "${initWorkDir}" branch gh-pages`, { stdio: 'pipe' }); + execSync(`git -C "${initWorkDir}" remote add origin "${bareRepoPath}"`, { stdio: 'pipe' }); + execSync(`git -C "${initWorkDir}" push -u origin gh-pages`, { stdio: 'pipe' }); + + // Every test starts from a fresh clone + await ghPages.clean(); + }); + + afterEach(async () => { + await ghPages.clean(); + // Cleanup temp directory + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + function getCacheDir(repoPath: string): string { + return ghPages.getCacheDir(repoPath); + } + + describe('CNAME file creation', () => { + it('should create CNAME file with exact domain content when cname option is set', async () => { + const testDomain = 'test-cname.example.com'; + const cacheDir = getCacheDir(bareRepoPath); + + const options = { + repo: bareRepoPath, + branch: 'gh-pages', + cname: testDomain, + message: 'Test CNAME creation', + user: { name: 'Test', email: 'test@test.com' } + }; + + await ghPages.publish(basePath, options); + + // Verify CNAME file was created by publish() + const cnamePath = path.join(cacheDir, 'CNAME'); + const exists = await pathExists(cnamePath); + expect(exists).toBe(true); + + const content = await fs.readFile(cnamePath, 'utf-8'); + expect(content).toBe(testDomain); + }); + + it('should NOT create CNAME file when cname option is not provided', async () => { + const cacheDir = getCacheDir(bareRepoPath); + + const options = { + repo: bareRepoPath, + branch: 'gh-pages', + // cname NOT provided + message: 'Test no CNAME', + user: { name: 'Test', email: 'test@test.com' } + }; + + await ghPages.publish(basePath, options); + + const cnamePath = path.join(cacheDir, 'CNAME'); + const exists = await pathExists(cnamePath); + expect(exists).toBe(false); + }); + }); + + describe('.nojekyll file creation', () => { + it('should create .nojekyll file when nojekyll option is true', async () => { + const cacheDir = getCacheDir(bareRepoPath); + + const options = { + repo: bareRepoPath, + branch: 'gh-pages', + nojekyll: true, + message: 'Test nojekyll creation', + user: { name: 'Test', email: 'test@test.com' } + }; + + await ghPages.publish(basePath, options); + + const nojekyllPath = path.join(cacheDir, '.nojekyll'); + const exists = await pathExists(nojekyllPath); + expect(exists).toBe(true); + }); + + it('should NOT create .nojekyll file when nojekyll option is false', async () => { + const cacheDir = getCacheDir(bareRepoPath); + + const options = { + repo: bareRepoPath, + branch: 'gh-pages', + nojekyll: false, + message: 'Test no nojekyll', + user: { name: 'Test', email: 'test@test.com' } + }; + + await ghPages.publish(basePath, options); + + const nojekyllPath = path.join(cacheDir, '.nojekyll'); + const exists = await pathExists(nojekyllPath); + expect(exists).toBe(false); + }); + }); + + describe('Both CNAME and .nojekyll together', () => { + it('should create both files when both options are set', async () => { + const testDomain = 'both-files.example.com'; + const cacheDir = getCacheDir(bareRepoPath); + + const options = { + repo: bareRepoPath, + branch: 'gh-pages', + cname: testDomain, + nojekyll: true, + message: 'Test both files', + user: { name: 'Test', email: 'test@test.com' } + }; + + await ghPages.publish(basePath, options); + + // Verify CNAME + const cnamePath = path.join(cacheDir, 'CNAME'); + expect(await pathExists(cnamePath)).toBe(true); + expect(await fs.readFile(cnamePath, 'utf-8')).toBe(testDomain); + + // Verify .nojekyll + const nojekyllPath = path.join(cacheDir, '.nojekyll'); + expect(await pathExists(nojekyllPath)).toBe(true); + }); + }); +}); diff --git a/src/engine/engine.gh-pages-integration.spec.ts b/src/engine/engine.gh-pages-integration.spec.ts new file mode 100644 index 0000000..dcabc36 --- /dev/null +++ b/src/engine/engine.gh-pages-integration.spec.ts @@ -0,0 +1,589 @@ +/** + * Integration tests for the interaction with gh-pages-fork + * + * These tests verify how engine.ts interacts with gh-pages-fork: + * - When clean() is called + * - What arguments are passed to publish() + * - Which options are passed through vs. filtered out + * - Dry-run behavior isolation + * - Options transformation before passing to publish() + */ + +import { logging } from '@angular-devkit/core'; + +import * as engine from './engine'; +import * as ghpages from '../gh-pages-fork/lib'; + +// Mock utils.pathExists at module level +vi.mock('../utils', async () => ({ + ...(await vi.importActual('../utils')), + pathExists: vi.fn().mockResolvedValue(true) +})); + +// Mock the Git class to avoid spawning actual git processes for the remote URL lookup +vi.mock('../gh-pages-fork/lib/git', async () => { + class MockGit { + getRemoteUrl = vi.fn().mockResolvedValue( + 'https://github.com/test/repo.git' + ); + } + + return { + ...(await vi.importActual('../gh-pages-fork/lib/git')), + Git: MockGit + }; +}); + +vi.mock('../gh-pages-fork/lib', async () => ({ + ...(await vi.importActual('../gh-pages-fork/lib')), + clean: vi.fn(), + publish: vi.fn() +})); + +describe('engine - gh-pages-fork integration', () => { + const logger = new logging.NullLogger(); + const originalEnv = process.env; + + const ghpagesCleanSpy = vi.mocked(ghpages.clean); + const ghpagesPublishSpy = vi.mocked(ghpages.publish); + + beforeEach(() => { + ghpagesCleanSpy.mockReset(); + ghpagesPublishSpy.mockReset(); + ghpagesCleanSpy.mockResolvedValue(undefined); + ghpagesPublishSpy.mockResolvedValue(undefined); + + // Create fresh copy of environment for each test + // This preserves PATH, HOME, etc. needed by git + process.env = { ...originalEnv }; + // Clear only CI-specific vars we're testing + delete process.env.TRAVIS; + delete process.env.CIRCLECI; + delete process.env.GITHUB_ACTIONS; + delete process.env.GH_TOKEN; + delete process.env.PERSONAL_TOKEN; + delete process.env.GITHUB_TOKEN; + }); + + afterAll(() => { + // Restore original environment for other test files + process.env = originalEnv; + }); + + describe('clean() behavior', () => { + it('should call clean() before publishing in normal mode', async () => { + const testDir = '/test/dist'; + const options = { dotfiles: true, notfound: true, nojekyll: true }; + + await engine.run(testDir, options, logger); + + expect(ghpagesCleanSpy).toHaveBeenCalledTimes(1); + expect(ghpagesCleanSpy).toHaveBeenCalledWith(); + }); + + it('should NOT call clean() during dry-run', async () => { + const testDir = '/test/dist'; + const options = { + dotfiles: true, + notfound: true, + nojekyll: true, + dryRun: true + }; + + await engine.run(testDir, options, logger); + + expect(ghpagesCleanSpy).not.toHaveBeenCalled(); + }); + + it('should call clean() before publish() to avoid remote url mismatch', async () => { + const testDir = '/test/dist'; + const options = { dotfiles: true, notfound: true, nojekyll: true }; + + await engine.run(testDir, options, logger); + + // Verify clean() was called before publish() + const cleanCallOrder = ghpagesCleanSpy.mock.invocationCallOrder[0]; + const publishCallOrder = ghpagesPublishSpy.mock.invocationCallOrder[0]; + + expect(cleanCallOrder).toBeLessThan(publishCallOrder); + }); + }); + + describe('publish() - directory parameter', () => { + it('should pass the correct directory path to publish()', async () => { + const testDir = '/test/dist/my-app'; + const options = { dotfiles: true, notfound: true, nojekyll: true }; + + await engine.run(testDir, options, logger); + + expect(ghpagesPublishSpy).toHaveBeenCalledTimes(1); + // third argument: log function that forwards to the logger + expect(ghpagesPublishSpy).toHaveBeenCalledWith( + testDir, + expect.any(Object), + expect.any(Function) + ); + }); + + it('should pass different directory paths correctly', async () => { + const firstDir = '/first/path'; + const options = { dotfiles: true, notfound: true, nojekyll: true }; + + await engine.run(firstDir, options, logger); + + const actualDir = ghpagesPublishSpy.mock.calls[0][0]; + expect(actualDir).toBe(firstDir); + }); + }); + + describe('publish() - options parameter', () => { + it('should pass core options to publish()', async () => { + const testDir = '/test/dist'; + const repo = 'https://github.com/test/repo.git'; + const branch = 'main'; + const message = 'Deploy to GitHub Pages'; + const remote = 'upstream'; + + const options = { + repo, + branch, + message, + remote, + dotfiles: true, + notfound: true, + nojekyll: true + }; + + await engine.run(testDir, options, logger); + + const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; + expect(actualOptions.repo).toBe(repo); + expect(actualOptions.branch).toBe(branch); + expect(actualOptions.message).toBe(message); + expect(actualOptions.remote).toBe(remote); + }); + + it('should pass transformed dotfiles boolean to publish()', async () => { + const testDir = '/test/dist'; + const options = { + dotfiles: true, + notfound: true, + nojekyll: true + }; + + await engine.run(testDir, options, logger); + + const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; + expect(actualOptions.dotfiles).toBe(true); + }); + + it('should pass dotfiles: false when transformed from noDotfiles: true', async () => { + const testDir = '/test/dist'; + const options = { + noDotfiles: true, + dotfiles: true, // will be overwritten by noDotfiles transformation + notfound: true, + nojekyll: true + }; + + await engine.run(testDir, options, logger); + + const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; + expect(actualOptions.dotfiles).toBe(false); + }); + + it('should pass user credentials object when both name and email provided', async () => { + const testDir = '/test/dist'; + const userName = 'CI Bot'; + const userEmail = 'ci@example.com'; + + const options = { + name: userName, + email: userEmail, + dotfiles: true, + notfound: true, + nojekyll: true + }; + + await engine.run(testDir, options, logger); + + const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; + expect(actualOptions.user).toEqual({ + name: userName, + email: userEmail + }); + }); + + it('should NOT have user object when only name is provided', async () => { + const testDir = '/test/dist'; + const options = { + name: 'CI Bot', + dotfiles: true, + notfound: true, + nojekyll: true + }; + + await engine.run(testDir, options, logger); + + const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; + expect(actualOptions.user).toBeUndefined(); + }); + + it('should pass add option for incremental deployment', async () => { + const testDir = '/test/dist'; + const options = { + add: true, + dotfiles: true, + notfound: true, + nojekyll: true + }; + + await engine.run(testDir, options, logger); + + const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; + expect(actualOptions.add).toBe(true); + }); + + it('should pass git option to publish()', async () => { + const testDir = '/test/dist'; + const gitPath = '/custom/path/to/git'; + + const options = { + git: gitPath, + dotfiles: true, + notfound: true, + nojekyll: true + }; + + await engine.run(testDir, options, logger); + + const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; + expect(actualOptions.git).toBe(gitPath); + }); + + it('should NOT pass internal dryRun option to publish()', async () => { + const testDir = '/test/dist'; + const options = { + dryRun: false, // Internal option, should be filtered out + dotfiles: true, + notfound: true, + nojekyll: true + }; + + await engine.run(testDir, options, logger); + + const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; + expect(actualOptions.dryRun).toBeUndefined(); + }); + + it('should NOT pass internal noDotfiles option to publish()', async () => { + const testDir = '/test/dist'; + const options = { + noDotfiles: false, + dotfiles: true, + notfound: true, + nojekyll: true + }; + + await engine.run(testDir, options, logger); + + const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; + expect(actualOptions.noDotfiles).toBeUndefined(); + }); + + it('should NOT pass internal noNotfound option to publish()', async () => { + const testDir = '/test/dist'; + const options = { + noNotfound: false, + dotfiles: true, + notfound: true, + nojekyll: true + }; + + await engine.run(testDir, options, logger); + + const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; + expect(actualOptions.noNotfound).toBeUndefined(); + }); + + it('should NOT pass internal noNojekyll option to publish()', async () => { + const testDir = '/test/dist'; + const options = { + noNojekyll: false, + dotfiles: true, + notfound: true, + nojekyll: true + }; + + await engine.run(testDir, options, logger); + + const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; + expect(actualOptions.noNojekyll).toBeUndefined(); + }); + + it('should NOT pass internal notfound option to publish()', async () => { + const testDir = '/test/dist'; + const options = { + dotfiles: true, + notfound: true, // Internal only, used for 404.html creation + nojekyll: true + }; + + await engine.run(testDir, options, logger); + + const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; + // notfound stays internal - the engine creates 404.html in dist + expect(actualOptions.notfound).toBeUndefined(); + }); + + it('should pass nojekyll option to publish()', async () => { + const testDir = '/test/dist'; + const options = { + dotfiles: true, + notfound: true, + nojekyll: true // publish() writes .nojekyll + }; + + await engine.run(testDir, options, logger); + + const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; + // nojekyll IS passed to publish(), which writes .nojekyll + expect(actualOptions.nojekyll).toBe(true); + }); + + it('should pass cname option to publish()', async () => { + const testDir = '/test/dist'; + const options = { + cname: 'example.com', // publish() writes CNAME + dotfiles: true, + notfound: true, + nojekyll: true + }; + + await engine.run(testDir, options, logger); + + const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; + // cname IS passed to publish(), which writes CNAME + expect(actualOptions.cname).toBe('example.com'); + }); + }); + + describe('publish() - dry-run mode isolation', () => { + it('should NOT call publish() during dry-run', async () => { + const testDir = '/test/dist'; + const options = { + dryRun: true, + dotfiles: true, + notfound: true, + nojekyll: true + }; + + await engine.run(testDir, options, logger); + + expect(ghpagesPublishSpy).not.toHaveBeenCalled(); + }); + + it('should NOT call clean() during dry-run', async () => { + const testDir = '/test/dist'; + const options = { + dryRun: true, + dotfiles: true, + notfound: true, + nojekyll: true + }; + + await engine.run(testDir, options, logger); + + expect(ghpagesCleanSpy).not.toHaveBeenCalled(); + }); + + it('should log what WOULD be published during dry-run', async () => { + const testLogger = new logging.Logger('test'); + const infoSpy = vi.spyOn(testLogger, 'info'); + + const testDir = '/test/dist'; + const repo = 'https://github.com/test/repo.git'; + const branch = 'gh-pages'; + + const options = { + dryRun: true, + repo, + branch, + dotfiles: true, + notfound: true, + nojekyll: true + }; + + await engine.run(testDir, options, testLogger); + + // Should log the dry-run preview + expect(infoSpy).toHaveBeenCalledWith( + expect.stringContaining("Dry-run / SKIPPED: publishing folder") + ); + expect(infoSpy).toHaveBeenCalledWith( + expect.stringContaining(testDir) + ); + }); + }); + + describe('options transformation verification', () => { + it('should transform noDotfiles: true to dotfiles: false before passing to publish()', async () => { + const testDir = '/test/dist'; + const options = { + noDotfiles: true, + dotfiles: true, // will be overwritten + notfound: true, + nojekyll: true + }; + + await engine.run(testDir, options, logger); + + const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; + expect(actualOptions.dotfiles).toBe(false); + expect(actualOptions.noDotfiles).toBeUndefined(); + }); + + it('should transform noDotfiles: false to dotfiles: true before passing to publish()', async () => { + const testDir = '/test/dist'; + const options = { + noDotfiles: false, + dotfiles: false, // will be overwritten + notfound: true, + nojekyll: true + }; + + await engine.run(testDir, options, logger); + + const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; + expect(actualOptions.dotfiles).toBe(true); + expect(actualOptions.noDotfiles).toBeUndefined(); + }); + + it('should use default dotfiles: true when no noDotfiles is provided', async () => { + const testDir = '/test/dist'; + const options = { + dotfiles: true, + notfound: true, + nojekyll: true + }; + + await engine.run(testDir, options, logger); + + const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; + expect(actualOptions.dotfiles).toBe(true); + }); + + it('should NOT pass transformed noNotfound/notfound to publish()', async () => { + const testDir = '/test/dist'; + const options = { + noNotfound: true, + dotfiles: true, + notfound: true, // will be overwritten to false + nojekyll: true + }; + + await engine.run(testDir, options, logger); + + const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; + // notfound stays internal - the engine creates 404.html in dist + expect(actualOptions.notfound).toBeUndefined(); + expect(actualOptions.noNotfound).toBeUndefined(); + }); + + it('should pass transformed noNojekyll to nojekyll: false to publish()', async () => { + const testDir = '/test/dist'; + const options = { + noNojekyll: true, + dotfiles: true, + notfound: true, + nojekyll: true // will be overwritten to false + }; + + await engine.run(testDir, options, logger); + + const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; + // nojekyll IS passed to publish(), which writes .nojekyll + expect(actualOptions.nojekyll).toBe(false); + expect(actualOptions.noNojekyll).toBeUndefined(); + }); + }); + + describe('Promise handling integration', () => { + it('should invoke publish() with a log function', async () => { + const testDir = '/test/dist'; + const options = { dotfiles: true, notfound: true, nojekyll: true }; + + await engine.run(testDir, options, logger); + + expect(ghpagesPublishSpy).toHaveBeenCalledWith( + expect.any(String), + expect.any(Object), + expect.any(Function) + ); + }); + + it('should resolve when publish() resolves', async () => { + ghpagesPublishSpy.mockResolvedValue(undefined); + + const testDir = '/test/dist'; + const options = { dotfiles: true, notfound: true, nojekyll: true }; + + await expect( + engine.run(testDir, options, logger) + ).resolves.toBeUndefined(); + }); + + it('should reject when publish() rejects', async () => { + const publishError = new Error('Git push failed'); + ghpagesPublishSpy.mockRejectedValue(publishError); + + const testDir = '/test/dist'; + const options = { dotfiles: true, notfound: true, nojekyll: true }; + + await expect( + engine.run(testDir, options, logger) + ).rejects.toThrow('Git push failed'); + }); + }); + + describe('silent-swallow regression (issue #205)', () => { + // Git failures (e.g. auth errors during clone) must surface as rejections, + // never as a successful deploy. + + it('should reject when publish() fails with an authentication error', async () => { + const authError = new Error( + "fatal: Authentication failed for 'https://github.com/owner/repo.git'" + ); + + ghpagesPublishSpy.mockRejectedValue(authError); + + const testDir = '/test/dist'; + const options = { dotfiles: true, notfound: true, nojekyll: true }; + + await expect( + engine.run(testDir, options, logger) + ).rejects.toThrow(/Authentication failed/); + }); + + it('should NOT log the success banner when publish() fails', async () => { + const authError = new Error('fatal: Authentication failed'); + + ghpagesPublishSpy.mockRejectedValue(authError); + + const testLogger = new logging.Logger('test'); + const infoSpy = vi.spyOn(testLogger, 'info'); + + const testDir = '/test/dist'; + const options = { dotfiles: true, notfound: true, nojekyll: true }; + + await expect( + engine.run(testDir, options, testLogger) + ).rejects.toThrow(); + + const bannerCall = infoSpy.mock.calls.find( + ([msg]) => typeof msg === 'string' && msg.includes('Successfully published') + ); + expect(bannerCall).toBeUndefined(); + }); + }); +}); diff --git a/src/engine/engine.gh-pages-no-package-json.spec.ts b/src/engine/engine.gh-pages-no-package-json.spec.ts new file mode 100644 index 0000000..e70f224 --- /dev/null +++ b/src/engine/engine.gh-pages-no-package-json.spec.ts @@ -0,0 +1,113 @@ +/** + * End-to-end integration test for issue #203 β€” REAL git, REAL gh-pages-fork, REAL filesystem. + * + * `angular-cli-ghpages` must work in a directory with no `package.json` in any + * parent β€” typical for `npx angular-cli-ghpages` on a repo that is just a + * `dist/` folder. The cache directory then falls back to + * `/angular-cli-ghpages-cache/gh-pages` (see gh-pages-fork getCacheDir()). + * + * Flow: + * 1. Make a local bare repo and seed a gh-pages branch with one file. + * 2. Create a cwd that has NO package.json in any ancestor (os.mkdtemp). + * 3. `process.chdir()` there so engine.run() sees that cwd. + * 4. Run engine.run(). + * 5. Assert no throw AND the new dist file lands in the remote. + */ + +import * as path from 'path'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import { execSync } from 'child_process'; + +import { logging } from '@angular-devkit/core'; + +import * as engine from './engine'; +import * as ghPages from '../gh-pages-fork/lib'; + +function git(args: string, cwd: string): string { + return execSync(`git -C "${cwd}" ${args}`, { stdio: ['pipe', 'pipe', 'pipe'] }) + .toString() + .trim(); +} + +describe('engine.run() in a directory with no package.json (issue #203)', () => { + let originalCwd: string; + let originalEnv: NodeJS.ProcessEnv; + let workDir: string; // Outside any package.json tree; becomes cwd. + let bareRepoPath: string; + let distDir: string; + + beforeEach(async () => { + originalCwd = process.cwd(); + originalEnv = { ...process.env }; + delete process.env.CACHE_DIR; + delete process.env.TRAVIS; + delete process.env.CIRCLECI; + delete process.env.GITHUB_ACTIONS; + delete process.env.GH_TOKEN; + delete process.env.PERSONAL_TOKEN; + delete process.env.GITHUB_TOKEN; + + workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ghp-203-e2e-')); + + // Dist with just index.html. Note: no package.json is ever created in workDir. + distDir = path.join(workDir, 'dist'); + await fs.mkdir(distDir, { recursive: true }); + await fs.writeFile(path.join(distDir, 'index.html'), 'fresh\n'); + + // Bare repo + seed a gh-pages branch, so clone + checkout run the existing-branch path. + bareRepoPath = path.join(workDir, 'bare.git'); + execSync(`git init --bare "${bareRepoPath}"`, { stdio: 'pipe' }); + + const seedDir = path.join(workDir, 'seed'); + await fs.mkdir(seedDir, { recursive: true }); + execSync(`git init "${seedDir}"`, { stdio: 'pipe' }); + git('config user.email "seed@test.com"', seedDir); + git('config user.name "Seed"', seedDir); + git('checkout -b gh-pages', seedDir); + await fs.writeFile(path.join(seedDir, 'old.html'), 'old'); + git('add .', seedDir); + git('commit -m "seed"', seedDir); + git(`remote add origin "${bareRepoPath}"`, seedDir); + git('push origin gh-pages', seedDir); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await ghPages.clean(); + process.env = originalEnv; + await fs.rm(workDir, { recursive: true, force: true }); + }); + + it('engine.run() succeeds and actually publishes when no package.json is reachable from cwd', async () => { + // Only a real run proves the full chain (prepareOptions β†’ clean() β†’ + // publish() β†’ real git clone/push) in a cwd without package.json. + + process.chdir(workDir); + + await engine.run( + distDir, + { + repo: bareRepoPath, + branch: 'gh-pages', + dotfiles: true, + notfound: false, + nojekyll: false, + name: 'Test', + email: 'test@test.com', + message: 'test deploy (no package.json)' + }, + new logging.NullLogger() + ); + + // Positive proof that the deploy actually landed: + const tree = git('ls-tree -r gh-pages --name-only', bareRepoPath) + .split('\n') + .filter(Boolean) + .sort(); + expect(tree).toEqual(['index.html']); + + // The clone went to the tmpdir fallback + expect(ghPages.getCacheDir()).toBe(path.join(os.tmpdir(), 'angular-cli-ghpages-cache', 'gh-pages')); + }, 30_000); +}); diff --git a/src/engine/engine.spec.ts b/src/engine/engine.spec.ts index 9563543..c749d88 100644 --- a/src/engine/engine.spec.ts +++ b/src/engine/engine.spec.ts @@ -1,7 +1,5 @@ import { logging } from '@angular-devkit/core'; -import { Mock } from 'vitest'; - import * as engine from './engine'; // Mock utils.pathExists at module level From c449f3cf73ab8fe72585ff786ba0f634f4cdc170 Mon Sep 17 00:00:00 2001 From: Johannes Hoppe Date: Mon, 12 Oct 2026 12:02:56 +0200 Subject: [PATCH 06/17] fix: harden gh-pages-fork publish() - every publish() clones into a fresh temporary directory below the cache directory and removes it afterwards: concurrent deploys no longer delete or share each other's clone, no token stays in .git/config, and the engine no longer wipes the shared cache - GIT_CEILING_DIRECTORIES keeps git inside the clone, so a broken clone can never fall through to the user's project repository - credential helpers are disabled for repository URLs that carry credentials - the remote url mismatch check is gone (every clone is fresh), so relative repository paths work - git rm removes .gitmodules before all other files, so a submodule gitlink sorting before it (e.g. .github/actions/x) no longer breaks every deploy - a new branch starts as an empty orphan branch, so the first deploy with --add no longer publishes the default branch's source tree - spawn() rejects unless the exit code is 0 (a git process killed by a signal no longer counts as success) and returns stdout only - getRemoteUrl() rethrows errors other than a failed git config, so a missing git binary is reported as such - listFiles() skips broken symlinks and symlink cycles - copy() writes files without the executable bit (like gh-pages v6.3.0), creates every directory once and copies with limited concurrency - getUser() uses the configured git executable - the fork's MIT license ships in the package --- ....spec.ts => engine.gh-pages-clone.spec.ts} | 0 src/engine/engine.prepare-options-helpers.ts | 5 +- src/engine/engine.ts | 7 - src/gh-pages-fork/lib/git.ts | 117 +++++++++------- src/gh-pages-fork/lib/index.ts | 127 +++++++++--------- src/gh-pages-fork/lib/util.ts | 59 ++++++-- src/scripts/postbuild.js | 3 +- 7 files changed, 178 insertions(+), 140 deletions(-) rename src/engine/{engine.gh-pages-clean.spec.ts => engine.gh-pages-clone.spec.ts} (100%) diff --git a/src/engine/engine.gh-pages-clean.spec.ts b/src/engine/engine.gh-pages-clone.spec.ts similarity index 100% rename from src/engine/engine.gh-pages-clean.spec.ts rename to src/engine/engine.gh-pages-clone.spec.ts diff --git a/src/engine/engine.prepare-options-helpers.ts b/src/engine/engine.prepare-options-helpers.ts index 057196c..5e22e9a 100644 --- a/src/engine/engine.prepare-options-helpers.ts +++ b/src/engine/engine.prepare-options-helpers.ts @@ -8,7 +8,7 @@ import { logging } from '@angular-devkit/core'; import { Schema } from '../deploy/schema'; -import { Git } from '../gh-pages-fork/lib/git'; +import { getRepo } from '../gh-pages-fork/lib'; /** * Type for options with the three boolean flags that prepareOptions adds, @@ -178,10 +178,9 @@ export async function injectTokenIntoRepoUrl(options: PreparedOptions): Promise< * Get the URL of `options.remote` from the git repository in the current working directory. * * process.cwd() is the directory from which ng deploy was invoked, i.e. the project root. - * Expects options.remote to be set (our defaults provide 'origin'). * * Exported for testing - internal use only */ export async function getRemoteUrl(options: Schema & { git?: string; remote?: string }): Promise { - return new Git(process.cwd(), options.git).getRemoteUrl(options.remote ?? 'origin'); + return getRepo({ git: options.git, remote: options.remote }); } diff --git a/src/engine/engine.ts b/src/engine/engine.ts index c0c1a66..e76c7f1 100644 --- a/src/engine/engine.ts +++ b/src/engine/engine.ts @@ -22,13 +22,6 @@ export async function run( ) { const prepared = await prepareOptions(options, logger); - // Every deploy starts from a fresh clone of the target branch - if (prepared.dryRun) { - logger.info('Dry-run / SKIPPED: cleaning of the cache directory'); - } else { - await ghpages.clean(); - } - await checkIfDistFolderExists(dir); await createNotFoundFile(dir, prepared, logger); await publishViaGhPages(dir, prepared, logger); diff --git a/src/gh-pages-fork/lib/git.ts b/src/gh-pages-fork/lib/git.ts index 191d306..1cfa154 100644 --- a/src/gh-pages-fork/lib/git.ts +++ b/src/gh-pages-fork/lib/git.ts @@ -6,16 +6,15 @@ */ import * as cp from 'child_process'; -import * as fs from 'fs/promises'; import * as path from 'path'; /** - * Error for a git process that exited with a non-zero code. - * `message` holds the combined stdout/stderr output of the process. + * Error for a git process that didn't exit with code 0. + * `message` holds the combined stderr/stdout output of the process. */ export class ProcessError extends Error { constructor( - public readonly code: number, + public readonly code: number | null, message: string ) { super(message); @@ -23,37 +22,41 @@ export class ProcessError extends Error { } } +export interface SpawnOptions { + cwd?: string; + env?: NodeJS.ProcessEnv; +} + /** - * Run a process and resolve with its combined stdout/stderr output. - * Rejects with a ProcessError on a non-zero exit code. + * Run a process and resolve with its stdout. + * Rejects with a ProcessError unless the process exits with code 0, + * which includes a process killed by a signal. */ -export function spawn(exe: string, args: string[], cwd?: string): Promise { +export function spawn(exe: string, args: string[], options: SpawnOptions = {}): Promise { return new Promise((resolve, reject) => { - const child = cp.spawn(exe, args, { cwd: cwd || process.cwd() }); - const buffer: string[] = []; - child.stderr.on('data', (chunk: Buffer) => buffer.push(chunk.toString())); - child.stdout.on('data', (chunk: Buffer) => buffer.push(chunk.toString())); + const child = cp.spawn(exe, args, { + cwd: options.cwd || process.cwd(), + env: options.env ? { ...process.env, ...options.env } : process.env + }); + const stdout: string[] = []; + const output: string[] = []; + child.stdout.on('data', (chunk: Buffer) => { + stdout.push(chunk.toString()); + output.push(chunk.toString()); + }); + child.stderr.on('data', (chunk: Buffer) => output.push(chunk.toString())); child.on('error', reject); - child.on('close', (code: number | null) => { - const output = buffer.join(''); - if (code) { - reject(new ProcessError(code, output || 'Process failed: ' + code)); + child.on('close', (code: number | null, signal: NodeJS.Signals | null) => { + if (code === 0) { + resolve(stdout.join('')); } else { - resolve(output); + const reason = signal ? 'Process killed by ' + signal : 'Process failed: ' + code; + reject(new ProcessError(code, output.join('') || reason)); } }); }); } -async function pathExists(filePath: string): Promise { - try { - await fs.access(filePath); - return true; - } catch { - return false; - } -} - export interface CloneOptions { git: string; remote: string; @@ -62,46 +65,53 @@ export interface CloneOptions { /** * Executes git commands in one repository directory. - * `output` holds the output of the last executed command. + * `output` holds the stdout of the last executed command. */ export class Git { output = ''; + /** + * @param cwd Repository directory + * @param cmd Git executable + * @param args Arguments placed before every git subcommand (e.g. `-c key=value`) + * @param env Additional environment variables for every git process + */ constructor( public readonly cwd: string, - public readonly cmd = 'git' + public readonly cmd = 'git', + private readonly args: string[] = [], + private readonly env?: NodeJS.ProcessEnv ) {} /** - * Clone a repo into the given dir if it doesn't already exist. + * Clone a branch of a repo into the given (empty or missing) dir. * Falls back to a full clone of the default branch when the branch doesn't exist yet. + * + * The returned instance never operates on a repository above `dir` + * (GIT_CEILING_DIRECTORIES), even if `dir` loses its .git directory. */ - static async clone(repo: string, dir: string, branch: string, options: CloneOptions): Promise { - if (await pathExists(dir)) { - return new Git(dir, options.git); - } - - await fs.mkdir(path.dirname(path.resolve(dir)), { recursive: true }); - + static async clone(repo: string, dir: string, branch: string, options: CloneOptions, args: string[] = []): Promise { + const env = { GIT_CEILING_DIRECTORIES: path.dirname(path.resolve(dir)) }; try { await spawn(options.git, [ + ...args, 'clone', repo, dir, '--branch', branch, '--single-branch', '--origin', options.remote, '--depth', String(options.depth) - ]); + ], { env }); } catch { - await spawn(options.git, ['clone', repo, dir, '--origin', options.remote]); + await spawn(options.git, [...args, 'clone', repo, dir, '--origin', options.remote], { env }); } - return new Git(dir, options.git); + return new Git(dir, options.git, args, env); } /** * Execute an arbitrary git command. */ async exec(...args: string[]): Promise { - this.output = await spawn(this.cmd, args, this.cwd); + this.output = await spawn(this.cmd, [...this.args, ...args], { cwd: this.cwd, env: this.env }); return this; } @@ -120,14 +130,8 @@ export class Git { } /** - * Fetch from a remote. - */ - fetch(remote: string): Promise { - return this.exec('fetch', remote); - } - - /** - * Checkout a branch (create an orphan if it doesn't exist on the remote). + * Checkout a branch. A branch that doesn't exist on the remote is created as + * an empty orphan branch (the files of the cloned default branch are removed). */ async checkout(remote: string, branch: string): Promise { const treeish = remote + '/' + branch; @@ -135,7 +139,8 @@ export class Git { await this.exec('ls-remote', '--exit-code', '.', treeish); } catch (error) { if (error instanceof ProcessError && error.code === 2) { - return this.exec('checkout', '--orphan', branch); + await this.exec('checkout', '--orphan', branch); + return this.removeAll(); } throw error; } @@ -152,6 +157,16 @@ export class Git { return this.exec('rm', '--ignore-unmatch', '-r', '-f', '--', ...files); } + /** + * Remove all tracked files, including dotfiles and submodule gitlinks. + * `.gitmodules` goes first: `git rm` reads it to update submodule entries and + * fails when it is already gone while a gitlink sorting before it remains. + */ + async removeAll(): Promise { + await this.rm(['.gitmodules']); + return this.rm(['.']); + } + /** * Add files. */ @@ -185,8 +200,10 @@ export class Git { try { await this.exec('config', '--get', 'remote.' + remote + '.url'); repo = this.output.split(/[\n\r]/).shift(); - } catch { - repo = undefined; + } catch (error) { + if (!(error instanceof ProcessError)) { + throw error; + } } if (!repo) { throw new Error( diff --git a/src/gh-pages-fork/lib/index.ts b/src/gh-pages-fork/lib/index.ts index eca4a06..ab15168 100644 --- a/src/gh-pages-fork/lib/index.ts +++ b/src/gh-pages-fork/lib/index.ts @@ -5,7 +5,6 @@ * Copyright (c) 2014 Tim Schaub */ -import { createHash } from 'crypto'; import { existsSync } from 'fs'; import * as fs from 'fs/promises'; import * as os from 'os'; @@ -30,6 +29,10 @@ export interface PublishOptions { export type Log = (message: string) => void; +/** + * Defaults of upstream gh-pages. angular-cli-ghpages passes every option + * explicitly (see engine/defaults.ts), so these only apply to direct calls. + */ export const defaults = { add: false, git: 'git', @@ -42,25 +45,14 @@ export const defaults = { }; /** - * Get the cache directory. + * Get the cache directory that holds the temporary clones. * * Resolution order: * 1. `$CACHE_DIR/gh-pages` (boolean-like values such as `true` or `0` are ignored) * 2. `node_modules/.cache/gh-pages` next to the closest package.json above cwd * 3. `/angular-cli-ghpages-cache/gh-pages` - * - * With `optPath`, returns a subdirectory named after a hash of it, so repository - * URLs (which may contain tokens) never end up in a path on disk. */ -export function getCacheDir(optPath?: string): string { - const dir = findCacheDir(process.cwd()); - if (!optPath) { - return dir; - } - return path.join(dir, createHash('sha256').update(optPath).digest('hex').slice(0, 16)); -} - -function findCacheDir(cwd: string): string { +export function getCacheDir(cwd = process.cwd()): string { const cacheDirEnv = process.env.CACHE_DIR; if (cacheDirEnv && !['true', 'false', '1', '0'].includes(cacheDirEnv)) { return path.join(cacheDirEnv, 'gh-pages'); @@ -80,17 +72,21 @@ function findCacheDir(cwd: string): string { } /** - * Clean the cache directory. + * The repository to publish to: `options.repo`, or the URL of `options.remote` + * of the git repository in the current working directory. */ -export async function clean(): Promise { - await fs.rm(getCacheDir(), { recursive: true, force: true }); -} - -async function getRepo(options: { repo?: string; git: string; remote: string }): Promise { +export async function getRepo(options: { repo?: string; git?: string; remote?: string }): Promise { if (options.repo) { return options.repo; } - return new Git(process.cwd(), options.git).getRemoteUrl(options.remote); + return new Git(process.cwd(), options.git ?? defaults.git).getRemoteUrl(options.remote ?? defaults.remote); +} + +/** + * Whether a repository URL carries credentials (e.g. an injected token). + */ +function hasCredentials(repo: string): boolean { + return /\/\/[^/@]+@/.test(repo); } /** @@ -103,8 +99,10 @@ function redact(repo: string): string { /** * Publish the files in `basePath` to a branch of a git repository. * - * Clones the branch into the cache directory, replaces its content with the - * files from `basePath` (unless `add` is set), commits and pushes. + * Clones the branch into a fresh temporary directory, replaces its content + * with the files from `basePath` (unless `add` is set), commits and pushes. + * The clone is removed afterwards, so concurrent deploys never share a clone + * and an injected token doesn't stay on disk. */ export async function publish(basePath: string, config: PublishOptions, log: Log = () => undefined): Promise { const options = { ...defaults, ...config }; @@ -118,60 +116,57 @@ export async function publish(basePath: string, config: PublishOptions, log: Log throw new Error(`The directory "${basePath}" doesn't contain any files to publish.`); } - const user = options.user ?? await getUser(); + const user = options.user ?? await getUser(process.cwd(), options.git); const repoUrl = await getRepo(options); - const clone = getCacheDir(repoUrl); - - log(`Cloning ${redact(repoUrl)} into ${clone}`); - const git = await Git.clone(repoUrl, clone, options.branch, options); - const url = await git.getRemoteUrl(options.remote); - if (url !== repoUrl) { - throw new Error( - `Remote url mismatch. Got "${redact(url)}" but expected "${redact(repoUrl)}" in ${git.cwd}. Remove the cache directory and try again.` - ); - } - // only required if someone mucks with the checkout between builds - log('Cleaning'); - await git.clean(); + const cacheDir = getCacheDir(); + await fs.mkdir(cacheDir, { recursive: true }); + const clone = await fs.mkdtemp(path.join(cacheDir, 'clone-')); - log(`Fetching ${options.remote}`); - await git.fetch(options.remote); + try { + // Credentials in the URL must not end up in a credential helper's store + const gitArgs = hasCredentials(repoUrl) ? ['-c', 'credential.helper='] : []; - log(`Checking out ${options.remote}/${options.branch}`); - await git.checkout(options.remote, options.branch); + log(`Cloning ${redact(repoUrl)} into ${clone}`); + const git = await Git.clone(repoUrl, clone, options.branch, options, gitArgs); - if (!options.add) { - log('Removing files'); - await git.rm(['.']); - } + log(`Checking out ${options.remote}/${options.branch}`); + await git.checkout(options.remote, options.branch); - if (options.nojekyll) { - log('Creating .nojekyll'); - await fs.writeFile(path.join(git.cwd, '.nojekyll'), ''); - } + if (!options.add) { + log('Removing files'); + await git.removeAll(); + } - if (options.cname) { - log(`Creating CNAME for ${options.cname}`); - await fs.writeFile(path.join(git.cwd, 'CNAME'), options.cname); - } + if (options.nojekyll) { + log('Creating .nojekyll'); + await fs.writeFile(path.join(git.cwd, '.nojekyll'), ''); + } + + if (options.cname) { + log(`Creating CNAME for ${options.cname}`); + await fs.writeFile(path.join(git.cwd, 'CNAME'), options.cname); + } - log('Copying files'); - await copy(files, basePath, git.cwd); + log('Copying files'); + await copy(files, basePath, git.cwd); - log('Adding all'); - await git.add(['.']); + log('Adding all'); + await git.add(['.']); - if (user) { - await git.exec('config', 'user.email', user.email); - if (user.name) { - await git.exec('config', 'user.name', user.name); + if (user) { + await git.exec('config', 'user.email', user.email); + if (user.name) { + await git.exec('config', 'user.name', user.name); + } } - } - log('Committing'); - await git.commit(options.message); + log('Committing'); + await git.commit(options.message); - log('Pushing'); - await git.push(options.remote, options.branch); + log('Pushing'); + await git.push(options.remote, options.branch); + } finally { + await fs.rm(clone, { recursive: true, force: true, maxRetries: 3 }); + } } diff --git a/src/gh-pages-fork/lib/util.ts b/src/gh-pages-fork/lib/util.ts index b47fb87..39cfb6b 100644 --- a/src/gh-pages-fork/lib/util.ts +++ b/src/gh-pages-fork/lib/util.ts @@ -5,8 +5,10 @@ * Copyright (c) 2014 Tim Schaub */ +import { createReadStream, createWriteStream } from 'fs'; import * as fs from 'fs/promises'; import * as path from 'path'; +import { pipeline } from 'stream/promises'; import { Git } from './git'; @@ -17,53 +19,84 @@ export interface User { /** * List all files below `base` as relative paths with POSIX separators. - * Symbolic links are followed (their targets get copied). + * Symbolic links are followed (their targets get copied); broken links and + * links to a directory of the current path (cycles) are skipped. * Entries starting with `.` are skipped unless `dotfiles` is set. */ export async function listFiles(base: string, dotfiles: boolean): Promise { const files: string[] = []; - async function walk(relDir: string): Promise { + async function walk(relDir: string, ancestors: string[]): Promise { + const realDir = await fs.realpath(path.join(base, relDir)); + if (ancestors.includes(realDir)) { + return; + } + ancestors = [...ancestors, realDir]; + const entries = await fs.readdir(path.join(base, relDir), { withFileTypes: true }); for (const entry of entries) { if (!dotfiles && entry.name.startsWith('.')) { continue; } const relPath = relDir ? `${relDir}/${entry.name}` : entry.name; - const stat = entry.isSymbolicLink() ? await fs.stat(path.join(base, relPath)) : entry; + let stat: { isDirectory(): boolean; isFile(): boolean } = entry; + if (entry.isSymbolicLink()) { + try { + stat = await fs.stat(path.join(base, relPath)); + } catch { + continue; + } + } if (stat.isDirectory()) { - await walk(relPath); + await walk(relPath, ancestors); } else if (stat.isFile()) { files.push(relPath); } } } - await walk(''); + await walk('', []); return files; } /** * Copy a list of files. + * + * Files are written as new files (mode 0666 minus umask, no executable bit), + * like a plain checkout of the branch would create them. Directories are + * created once; files are copied with limited concurrency. */ export async function copy(files: string[], base: string, dest: string): Promise { - for (const file of files) { + const pairs = files.map((file) => { const src = path.resolve(base, file); - const target = path.join(dest, path.relative(base, src)); - await fs.mkdir(path.dirname(target), { recursive: true }); - await fs.copyFile(src, target); + return { src, target: path.join(dest, path.relative(base, src)) }; + }); + + const dirs = [...new Set(pairs.map(({ target }) => path.dirname(target)))].sort(); + for (const dir of dirs) { + await fs.mkdir(dir, { recursive: true }); + } + + const concurrency = 16; + let next = 0; + async function worker(): Promise { + while (next < pairs.length) { + const { src, target } = pairs[next++]; + await pipeline(createReadStream(src), createWriteStream(target)); + } } + await Promise.all(Array.from({ length: Math.min(concurrency, pairs.length) }, worker)); } /** - * Read the git user of the current working directory. + * Read the git user of the given working directory (default: process.cwd()). * Resolves with null when name or email is not configured. */ -export async function getUser(cwd?: string): Promise { +export async function getUser(cwd = process.cwd(), cmd = 'git'): Promise { try { const [name, email] = await Promise.all([ - new Git(cwd ?? process.cwd()).exec('config', 'user.name'), - new Git(cwd ?? process.cwd()).exec('config', 'user.email') + new Git(cwd, cmd).exec('config', 'user.name'), + new Git(cwd, cmd).exec('config', 'user.email') ]); return { name: name.output.trim(), email: email.output.trim() }; } catch { diff --git a/src/scripts/postbuild.js b/src/scripts/postbuild.js index 24c4289..fcf57c2 100644 --- a/src/scripts/postbuild.js +++ b/src/scripts/postbuild.js @@ -10,7 +10,8 @@ const files = [ 'package.json', 'angular-cli-ghpages', 'deploy/schema.json', - 'commander-fork/index.js' + 'commander-fork/index.js', + 'gh-pages-fork/LICENSE' ]; for (const file of files) { From 789ec9c6ccbca073fbaa91abc6736ab86d471609 Mon Sep 17 00:00:00 2001 From: Johannes Hoppe Date: Mon, 12 Oct 2026 12:02:56 +0200 Subject: [PATCH 07/17] test: cover the publish() fixes and type-check specs - real-git regression tests for every fix in gh-pages-fork publish() - the clean spec checks that the temporary clone is removed after successful and failed deploys - file creation tests check the pushed branch instead of the clone - the dry-run 404.html test is restored (it was merged with a cname test) - behavior and integration specs clear token variables and describe the fork instead of gh-pages internals - npm test type-checks all sources including specs; spec type errors are fixed - test files run in parallel again (no shared cache between suites) --- CLAUDE.md | 4 +- .../test.command.allowUnknownOption.spec.ts | 2 +- .../test/test.fork-fixes.spec.ts | 12 +- .../test/test.options.version.spec.ts | 4 +- src/engine/engine-filesystem.spec.ts | 29 ++- src/engine/engine.gh-pages-behavior.spec.ts | 211 +++++++----------- src/engine/engine.gh-pages-cleanup.spec.ts | 5 +- src/engine/engine.gh-pages-clone.spec.ts | 81 ++++--- .../engine.gh-pages-filecreation.spec.ts | 58 ++--- .../engine.gh-pages-integration.spec.ts | 53 +---- .../engine.gh-pages-no-package-json.spec.ts | 15 +- src/engine/engine.spec.ts | 16 +- src/gh-pages-fork/README.md | 26 ++- src/gh-pages-fork/test/cache-dir.spec.ts | 25 +-- src/gh-pages-fork/test/git-commands.spec.ts | 60 +++-- src/gh-pages-fork/test/publish.spec.ts | 117 +++++++++- src/package.json | 2 +- .../builder-integration.spec.ts | 9 +- src/vitest.config.mts | 5 +- 19 files changed, 399 insertions(+), 335 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4dcf348..cf408d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,10 +125,10 @@ actions.ts (deploy function) β”‚ Uses BuilderContext.scheduleTarget() └─→ engine.run() β”œβ”€β†’ Prepare options (tokens, CI env vars) - β”œβ”€β†’ Clean the cache (every deploy starts from a fresh clone) β”œβ”€β†’ Create 404.html in dist (copy of index.html for SPAs) └─→ gh-pages-fork publish() - β”œβ”€β†’ Clone the target branch (or create it as an orphan branch) + β”œβ”€β†’ Clone the target branch into a fresh temporary directory (removed afterwards) + β”‚ or create it as an empty orphan branch β”œβ”€β†’ Remove the branch content (skipped with --add) β”œβ”€β†’ Write .nojekyll (bypasses Jekyll on GitHub) and CNAME (if custom domain) β”œβ”€β†’ Copy dist, git add, commit (only if something changed) diff --git a/src/commander-fork/test/test.command.allowUnknownOption.spec.ts b/src/commander-fork/test/test.command.allowUnknownOption.spec.ts index cd8b37b..c16c053 100644 --- a/src/commander-fork/test/test.command.allowUnknownOption.spec.ts +++ b/src/commander-fork/test/test.command.allowUnknownOption.spec.ts @@ -19,7 +19,7 @@ function resetStubStatus() { beforeEach(() => { stubError = vi.spyOn(console, 'error').mockImplementation(() => {}); - stubExit = vi.spyOn(process, 'exit').mockImplementation((code?: number) => { + stubExit = vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => { return undefined as never; }); }); diff --git a/src/commander-fork/test/test.fork-fixes.spec.ts b/src/commander-fork/test/test.fork-fixes.spec.ts index 1b6f530..bef6bea 100644 --- a/src/commander-fork/test/test.fork-fixes.spec.ts +++ b/src/commander-fork/test/test.fork-fixes.spec.ts @@ -25,8 +25,8 @@ describe('Fork Fix 2: Version short-only and custom flags', () => { beforeEach(() => { captured = {out: '', code: null}; - exitSpy = vi.spyOn(process, 'exit').mockImplementation((c?: number) => { - captured.code = c ?? 0; + exitSpy = vi.spyOn(process, 'exit').mockImplementation((c?: string | number | null) => { + captured.code = Number(c ?? 0); return undefined as never; }); writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((s: string | Uint8Array) => { @@ -74,8 +74,8 @@ describe('Fork Fix 2: Version short-only and custom flags', () => { beforeEach(() => { exitCode = null; - exitSpy = vi.spyOn(process, 'exit').mockImplementation((c?: number) => { - exitCode = c ?? 0; + exitSpy = vi.spyOn(process, 'exit').mockImplementation((c?: string | number | null) => { + exitCode = Number(c ?? 0); throw new Error(`exit(${c})`); }); writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); @@ -110,7 +110,7 @@ describe('Fork Fix 2: Version short-only and custom flags', () => { let writeSpy: MockInstance; beforeEach(() => { - exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: number) => { + exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => { return undefined as never; }); writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); @@ -179,7 +179,7 @@ describe('Fork Fix 2: Version short-only and custom flags', () => { beforeEach(() => { errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: number) => { + exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => { return undefined as never; }); }); diff --git a/src/commander-fork/test/test.options.version.spec.ts b/src/commander-fork/test/test.options.version.spec.ts index 009e901..3f2338d 100644 --- a/src/commander-fork/test/test.options.version.spec.ts +++ b/src/commander-fork/test/test.options.version.spec.ts @@ -19,8 +19,8 @@ let writeSpy: MockInstance; capturedExitCode = -1; capturedOutput = ''; - exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: number) => { - capturedExitCode = code ?? 0; + exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => { + capturedExitCode = Number(code ?? 0); return undefined as never; }); writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((output: string | Uint8Array) => { diff --git a/src/engine/engine-filesystem.spec.ts b/src/engine/engine-filesystem.spec.ts index bc736ad..bee9fa0 100644 --- a/src/engine/engine-filesystem.spec.ts +++ b/src/engine/engine-filesystem.spec.ts @@ -24,7 +24,6 @@ vi.mock('../gh-pages-fork/lib', async () => { const actual = await vi.importActual('../gh-pages-fork/lib'); return { ...actual, - clean: vi.fn().mockResolvedValue(undefined), publish: vi.fn().mockResolvedValue(undefined) }; }); @@ -43,7 +42,6 @@ describe('engine - real filesystem tests', () => { // Spy on logger to capture warnings loggerInfoSpy = vi.spyOn(logger, 'info'); - vi.mocked(ghpages.clean).mockClear(); vi.mocked(ghpages.publish).mockClear(); }); @@ -126,6 +124,33 @@ describe('engine - real filesystem tests', () => { const indexPath = path.join(testDir, 'index.html'); await fs.writeFile(indexPath, 'Test'); + const options = { + notfound: true, + nojekyll: false, + dotfiles: true, + dryRun: true + }; + + await engine.run(testDir, options, logger); + + const notFoundPath = path.join(testDir, '404.html'); + const exists = await pathExists(notFoundPath); + expect(exists).toBe(false); + }); + }); + + /** + * publish() writes CNAME and .nojekyll into its clone; the engine only + * creates 404.html in dist. + * + * What we're testing: + * - Verify we DO pass cname option to publish() when provided + * - Verify we DO pass nojekyll option to publish() when enabled + */ + describe('publish options - cname and nojekyll', () => { + it('should pass cname option to publish() when provided', async () => { + const indexPath = path.join(testDir, 'index.html'); + await fs.writeFile(indexPath, 'test'); const testDomain = 'example.com'; const options = { diff --git a/src/engine/engine.gh-pages-behavior.spec.ts b/src/engine/engine.gh-pages-behavior.spec.ts index b01e20f..a9a55fd 100644 --- a/src/engine/engine.gh-pages-behavior.spec.ts +++ b/src/engine/engine.gh-pages-behavior.spec.ts @@ -2,8 +2,8 @@ * Behavioral snapshot tests for gh-pages-fork publish() * * These tests pin the git commands that publish() executes, in order and with - * their arguments, to the behavior of gh-pages v6.3.0 that angular-cli-ghpages - * has always relied on. Any change to the fork that alters this behavior fails here. + * their arguments, to the behavior of gh-pages v6.3.0, the base of the fork. + * Any change to the fork that alters this behavior fails here. * * Approach: Mock child_process and gh-pages-fork/lib/util (copy, getUser), use real filesystem */ @@ -63,6 +63,15 @@ const EXPECTED_GIT_COMMANDS = [ 'rm', 'add', 'config', 'diff-index', 'commit', 'push' ]; +// Git subcommand of an argument list, skipping leading `-c key=value` pairs +function gitSubcommand(args: string[]): string | undefined { + let i = 0; + while (args[i] === '-c') { + i += 2; + } + return args[i]; +} + const { mockSpawn, mockCopy, mockGetUser } = vi.hoisted(() => { const mockCopy = vi.fn(() => Promise.resolve()); const mockGetUser = vi.fn(() => Promise.resolve(null)); @@ -86,10 +95,9 @@ function defaultSpawn(cmd: string, args: string[] | undefined, opts: unknown) { spawnCalls.push({ cmd, args: capturedArgs, options: opts }); // Validate git commands are expected - if (cmd === 'git' && capturedArgs[0]) { - if (!EXPECTED_GIT_COMMANDS.includes(capturedArgs[0])) { - throw new Error(`Unexpected git command: ${capturedArgs[0]}. Add to whitelist if intentional.`); - } + const subcommand = gitSubcommand(capturedArgs); + if (cmd === 'git' && subcommand && !EXPECTED_GIT_COMMANDS.includes(subcommand)) { + throw new Error(`Unexpected git command: ${subcommand}. Add to whitelist if intentional.`); } const mockChild = createMockChildProcess(); @@ -136,6 +144,7 @@ async function publishAndHandle( describe('gh-pages-fork publish() - behavioral snapshot (gh-pages v6.3.0 behavior)', () => { let tempDir: string; let basePath: string; + let originalEnv: NodeJS.ProcessEnv; beforeAll(async () => { // Create a real temp directory with test files @@ -156,6 +165,10 @@ describe('gh-pages-fork publish() - behavioral snapshot (gh-pages v6.3.0 behavio await fs.rm(tempDir, { recursive: true, force: true }); }); + afterEach(() => { + process.env = originalEnv; + }); + beforeEach(() => { // Note: We DON'T call ghPages.clean() here because it interferes with test isolation // Each test uses a unique repo URL which naturally isolates cache directories @@ -165,6 +178,15 @@ describe('gh-pages-fork publish() - behavioral snapshot (gh-pages v6.3.0 behavio currentTestContext = {}; mockSpawn.mockReset(); mockSpawn.mockImplementation(defaultSpawn); + + // An injected token would make the repo URL differ from currentTestContext.repo + originalEnv = { ...process.env }; + delete process.env.GH_TOKEN; + delete process.env.PERSONAL_TOKEN; + delete process.env.GITHUB_TOKEN; + delete process.env.TRAVIS; + delete process.env.CIRCLECI; + delete process.env.GITHUB_ACTIONS; mockCopy.mockClear(); mockGetUser.mockClear(); }); @@ -248,7 +270,7 @@ describe('gh-pages-fork publish() - behavioral snapshot (gh-pages v6.3.0 behavio * * What is depth? * - `--depth N` limits git clone history to N commits - * - gh-pages defaults to depth=1 (only latest commit, optimal for deployments) + * - publish() defaults to depth=1 (only latest commit, optimal for deployments) * - Smaller depth = faster clones, less disk space, faster CI/CD * * Why angular-cli-ghpages does NOT expose this option: @@ -258,7 +280,7 @@ describe('gh-pages-fork publish() - behavioral snapshot (gh-pages v6.3.0 behavio * - depth=1 is perfect - no benefit to cloning more history * * What we're testing: - * - That gh-pages uses its default depth=1 + * - That publish() uses its default depth=1 * - Exact argument position and value * - This ensures optimal performance for all deployments */ @@ -267,7 +289,7 @@ describe('gh-pages-fork publish() - behavioral snapshot (gh-pages v6.3.0 behavio const branch = 'gh-pages'; currentTestContext.repo = repo; - // Don't specify depth - rely on gh-pages default + // Don't specify depth - rely on the default const options = { repo, branch, message: 'Deploy' }; await publishAndHandle(basePath, options, () => { @@ -322,15 +344,10 @@ describe('gh-pages-fork publish() - behavioral snapshot (gh-pages v6.3.0 behavio /** * Git push command * - * What does angular-cli-ghpages do? - * - We DO NOT pass the 'history' option to gh-pages - * - gh-pages defaults to history: true (normal push, no --force) - * - We rely on this default behavior - * * What we're testing: * - Push command includes correct remote and branch - * - Push ALWAYS includes --tags flag (gh-pages behavior) - * - NO --force flag (because we don't pass history: false) + * - Push ALWAYS includes the --tags flag + * - NO --force flag: every deploy adds a commit to the branch history */ describe('Git push command', () => { it('should push to correct remote and branch with --tags flag', async () => { @@ -348,12 +365,11 @@ describe('gh-pages-fork publish() - behavioral snapshot (gh-pages v6.3.0 behavio expect(pushCall).toBeDefined(); expect(pushCall?.args).toContain(remote); expect(pushCall?.args).toContain(branch); - // gh-pages ALWAYS includes --tags in push (lib/git.js line 189) expect(pushCall?.args).toContain('--tags'); }); }); - it('should NOT use force push (we rely on gh-pages default history: true)', async () => { + it('should NOT use force push', async () => { const repo = 'https://github.com/test/no-force-test.git'; currentTestContext.repo = repo; const options = { repo, message: 'Deploy' }; @@ -364,7 +380,6 @@ describe('gh-pages-fork publish() - behavioral snapshot (gh-pages v6.3.0 behavio ); expect(pushCall).toBeDefined(); - // We don't pass history: false, so gh-pages defaults to history: true (no --force) expect(pushCall?.args).not.toContain('--force'); }); }); @@ -395,7 +410,7 @@ describe('gh-pages-fork publish() - behavioral snapshot (gh-pages v6.3.0 behavio // Verify source is our basePath expect(source).toBe(basePath); - // Verify destination is in gh-pages cache + // Verify destination is the temporary clone in the cache directory expect(destination).toContain('gh-pages'); }); }); @@ -412,18 +427,12 @@ describe('gh-pages-fork publish() - behavioral snapshot (gh-pages v6.3.0 behavio * - add: false ensures no stale files remain (recommended for most cases) * - add: true useful for multi-source deployments or preserving manually added files * - * Critical behavior from gh-pages source (lib/index.js lines 156-170): - * if (options.add) { - * return git; // SKIP removal logic entirely - * } - * // Otherwise: execute globby to find files, then git rm if files exist + * publish() removes all tracked files after the checkout (`git rm` of + * `.gitmodules`, then of `.`), unless `add` is set. * - * Testing strategy: - * - We can't directly test git rm is called because it depends on existing files in cache - * - Instead, we test command ORDERING which differs between add: true and add: false - * - With add: false, checkout happens BEFORE add (removal step in between, even if no files) - * - With add: true, checkout happens BEFORE add (no removal step at all) - * - We also verify that add: true NEVER calls git rm (critical) + * What we're testing: + * - With add: false, the removal runs between checkout and add + * - With add: true, git rm is NEVER called on an existing branch */ describe('Git rm command behavior', () => { it('should maintain standard command order with add: false (default)', async () => { @@ -436,17 +445,16 @@ describe('gh-pages-fork publish() - behavioral snapshot (gh-pages v6.3.0 behavio .filter(call => call.cmd === 'git') .map(call => call.args[0]); - // Verify standard flow: checkout β†’ (removal logic) β†’ add β†’ commit + // Verify standard flow: checkout β†’ removal β†’ add β†’ commit const checkoutIndex = gitCommands.indexOf('checkout'); + const rmIndex = gitCommands.indexOf('rm'); const addIndex = gitCommands.indexOf('add'); const commitIndex = gitCommands.indexOf('commit'); expect(checkoutIndex).toBeGreaterThan(-1); - expect(addIndex).toBeGreaterThan(checkoutIndex); + expect(rmIndex).toBeGreaterThan(checkoutIndex); + expect(addIndex).toBeGreaterThan(rmIndex); expect(commitIndex).toBeGreaterThan(addIndex); - - // Note: git rm may or may not be called depending on existing files - // The important thing is the removal logic is ATTEMPTED (not skipped) }); }); @@ -471,19 +479,14 @@ describe('gh-pages-fork publish() - behavioral snapshot (gh-pages v6.3.0 behavio * Dotfiles option * * What does angular-cli-ghpages do? - * - We PASS dotfiles option to gh-pages (engine.ts line 242) + * - The engine passes the dotfiles option to publish() * - Default in angular-cli-ghpages: dotfiles: true (include dotfiles) * - User can disable with --no-dotfiles flag * - * How gh-pages uses it: - * - Controls globby pattern matching for files to copy + * How publish() uses it (listFiles): * - dotfiles: true β†’ includes files starting with '.' (like .htaccess) * - dotfiles: false β†’ ignores files starting with '.' * - * Why we test this: - * - CRITICAL: We expose this option and pass it to gh-pages - * - Must verify it affects file selection behavior - * * Test fixture has these files: * - index.html (normal file) * - main.js (normal file) @@ -497,7 +500,6 @@ describe('gh-pages-fork publish() - behavioral snapshot (gh-pages v6.3.0 behavio const options = { repo, message: 'Deploy', dotfiles: true }; await publishAndHandle(basePath, options, () => { - // gh-pages uses globby with { dot: options.dotfiles } (lib/index.js line 90) expect(mockCopy).toHaveBeenCalledTimes(1); const callArgs = mockCopy.mock.calls[0]; @@ -551,23 +553,17 @@ describe('gh-pages-fork publish() - behavioral snapshot (gh-pages v6.3.0 behavio * Git executable option * * What does angular-cli-ghpages do? - * - We PASS git option to gh-pages (engine.ts line 240) - * - Default: 'git' (relies on PATH) - * - gh-pages defaults.git = 'git' (lib/index.js line 29) - * - * Why we test this: - * - We expose this option through defaults.ts - * - Must verify the option flows through to gh-pages + * - The engine passes the git option to publish() + * - Default: 'git' (relies on PATH), see engine/defaults.ts * * Testing approach: * - We pass git: 'git' (our default) - * - Verify git commands are executed - * - This confirms the option is accepted by gh-pages + * - Verify git commands are executed with it */ describe('Git executable option', () => { it('should accept git executable option (we pass our default "git")', async () => { const repo = 'https://github.com/test/git-exe-test.git'; - const gitExecutable = 'git'; // Our default from defaults.ts line 15 + const gitExecutable = 'git'; // Our default from engine/defaults.ts currentTestContext.repo = repo; const options = { repo, message: 'Deploy', git: gitExecutable }; @@ -576,7 +572,6 @@ describe('gh-pages-fork publish() - behavioral snapshot (gh-pages v6.3.0 behavio const gitCalls = spawnCalls.filter(call => call.cmd === 'git'); // CRITICAL: Verify git commands were executed - // This confirms gh-pages accepted our git option expect(gitCalls.length).toBeGreaterThan(0); // Verify critical commands were executed with the git executable @@ -630,14 +625,7 @@ describe('gh-pages-fork publish() - behavioral snapshot (gh-pages v6.3.0 behavio /** * Git tag command - Verify we DON'T use tagging * - * What does angular-cli-ghpages do? - * - We DO NOT pass the 'tag' option to gh-pages - * - We DO NOT expose tag option in schema.json - * - Therefore, no git tags should ever be created - * - * Why test this? - * - Ensures our deployments don't accidentally create tags - * - If gh-pages changes defaults, we'll catch it + * publish() has no tag option and must never create tags. */ describe('Git tag command', () => { it('should NEVER create git tags (we do not use tag option)', async () => { @@ -657,66 +645,48 @@ describe('gh-pages-fork publish() - behavioral snapshot (gh-pages v6.3.0 behavio }); /** - * gh-pages default options we DON'T pass - * - * These tests verify gh-pages default behavior for options we don't pass. - * This ensures we understand what gh-pages is doing on our behalf. - * - * Options we DON'T pass (gh-pages uses defaults): - * - push: true (default) - Always pushes to remote - * - dest: '.' (default) - Deploys to root of branch - * - src: '**\/*' (default) - Includes all files - * - remove: '.' (default) - Removes existing files (unless add: true) - * - silent: false (default) - gh-pages logs output (we used to have noSilent but it's DEPRECATED) - * - * Note on 'silent' option: - * - We have 'noSilent' in schema.json but it's DEPRECATED and IGNORED - * - We do NOT pass 'silent' to gh-pages - * - gh-pages defaults to silent: false (logging enabled) - * - This is what we want: verbose logging for deployments + * Fixed behavior of publish() (upstream gh-pages has options for these): + * - always pushes to the remote + * - deploys to the root of the branch + * - includes all files of basePath + * - removes all existing files (unless add: true) */ - describe('gh-pages default options (we do not pass these)', () => { - it('should push to remote (gh-pages default push: true)', async () => { + describe('fixed publish behavior', () => { + it('should push to remote', async () => { const repo = 'https://github.com/test/default-push-test.git'; currentTestContext.repo = repo; const options = { repo, message: 'Deploy' }; - // We DON'T pass push option, gh-pages defaults to push: true await publishAndHandle(basePath, options, () => { const pushCall = spawnCalls.find(call => call.cmd === 'git' && call.args[0] === 'push' ); - // CRITICAL: Push MUST be called (gh-pages default: push: true) + // CRITICAL: Push MUST be called expect(pushCall).toBeDefined(); }); }); - it('should use root destination (gh-pages default dest: ".")', async () => { + it('should copy to the root of the branch', async () => { const repo = 'https://github.com/test/default-dest-test.git'; currentTestContext.repo = repo; const options = { repo, message: 'Deploy' }; - // We DON'T pass dest option, gh-pages defaults to dest: '.' await publishAndHandle(basePath, options, () => { - // Copy destination should be at root of cache (not in subdirectory) + // Copy destination is the root of the clone (not a subdirectory) expect(mockCopy).toHaveBeenCalledTimes(1); const callArgs = mockCopy.mock.calls[0]; const [files, source, destination] = callArgs as unknown as [string[], string, string]; - // Destination should be in gh-pages cache, not in a subdirectory - expect(destination).toContain('gh-pages'); - // Should NOT contain additional path segments beyond cache dir - // (dest: '.' means deploy to root of branch) + expect(path.basename(destination)).toMatch(/^clone-/); }); }); - it('should include all files (gh-pages default src: "**/*")', async () => { + it('should include all files', async () => { const repo = 'https://github.com/test/default-src-test.git'; currentTestContext.repo = repo; const options = { repo, message: 'Deploy' }; - // We DON'T pass src option, gh-pages defaults to src: '**/*' await publishAndHandle(basePath, options, () => { expect(mockCopy).toHaveBeenCalledTimes(1); @@ -724,7 +694,7 @@ describe('gh-pages-fork publish() - behavioral snapshot (gh-pages v6.3.0 behavio const callArgs = mockCopy.mock.calls[0]; const [files] = callArgs as unknown as [string[], string, string]; - // All our test files should be included (src: '**/*' means all files) + // All our test files should be included expect(files.length).toBe(3); expect(files).toContain('index.html'); expect(files).toContain('main.js'); @@ -732,23 +702,21 @@ describe('gh-pages-fork publish() - behavioral snapshot (gh-pages v6.3.0 behavio }); }); - it('should use default remove pattern (gh-pages default remove: ".")', async () => { + it('should remove .gitmodules first, then all tracked files', async () => { const repo = 'https://github.com/test/default-remove-test.git'; currentTestContext.repo = repo; const options = { repo, message: 'Deploy', add: false }; - // We DON'T pass remove option, gh-pages defaults to remove: '.' - // With add: false, gh-pages will attempt to remove files matching '.' await publishAndHandle(basePath, options, () => { - // Since it's a fresh clone, there may be no files to remove - // But we verify the removal logic is executed (not skipped like with add: true) - const gitCommands = spawnCalls - .filter(call => call.cmd === 'git') - .map(call => call.args[0]); - - // Standard flow includes removal logic (even if no files to remove) - expect(gitCommands).toContain('checkout'); - expect(gitCommands).toContain('add'); + const rmCalls = spawnCalls + .filter(call => call.cmd === 'git' && call.args[0] === 'rm') + .map(call => call.args.join(' ')); + + // .gitmodules first: git rm fails when it is gone while a gitlink sorting before it remains + expect(rmCalls).toEqual([ + 'rm --ignore-unmatch -r -f -- .gitmodules', + 'rm --ignore-unmatch -r -f -- .' + ]); }); }); }); @@ -756,7 +724,7 @@ describe('gh-pages-fork publish() - behavioral snapshot (gh-pages v6.3.0 behavio /** * Error scenarios * - * gh-pages has robust error handling. We test critical failure paths: + * Critical failure paths: * 1. Git clone failure and retry (branch doesn't exist) * 2. No changes to commit (diff-index returns 0) * 3. Git commit when no user configured (uses getUser) @@ -893,7 +861,7 @@ describe('gh-pages-fork publish() - behavioral snapshot (gh-pages v6.3.0 behavio ); // CRITICAL: Commit should NOT be called when no changes - // gh-pages uses: git diff-index --quiet HEAD || git commit -m "message" + // publish() uses: git diff-index --quiet HEAD || git commit -m "message" expect(commitCall).toBeUndefined(); // But push should still be called (even with no new commits) @@ -907,7 +875,7 @@ describe('gh-pages-fork publish() - behavioral snapshot (gh-pages v6.3.0 behavio const repo = 'https://github.com/test/no-user-test.git'; currentTestContext.repo = repo; const options = { repo, message: 'Deploy' }; - // DON'T pass user option - gh-pages will call getUser() + // DON'T pass user option - publish() will call getUser() // Reset mock to normal behavior (previous test modified it) mockSpawn.mockClear(); @@ -957,29 +925,16 @@ describe('gh-pages-fork publish() - behavioral snapshot (gh-pages v6.3.0 behavio (call.args[1] === 'user.name' || call.args[1] === 'user.email') ); - // When getUser returns null, gh-pages skips git config + // When getUser returns null, publish() skips git config // (relies on global/local git config) expect(configUserCalls.length).toBe(0); }); }); /** - * gh-pages v6+ CNAME and .nojekyll file creation - * - * IMPORTANT: File creation tests are in engine.gh-pages-filecreation.spec.ts - * Those tests run WITHOUT mocks to verify actual file creation behavior. - * - * This mocked test file cannot test actual file creation because: - * - We mock child_process.spawn (git commands) - * - gh-pages creates files in git.cwd which is set during clone - * - Since clone is mocked, the directory structure isn't created - * - * See engine.gh-pages-filecreation.spec.ts for real file creation tests. - */ - - /** - * gh-pages.clean() behavior is tested in engine.gh-pages-clean.spec.ts - * That test uses real filesystem operations without mocks. + * CNAME/.nojekyll file creation and the removal of the temporary clone run + * against real git in engine.gh-pages-filecreation.spec.ts and + * engine.gh-pages-clean.spec.ts. */ /** @@ -1014,8 +969,10 @@ describe('gh-pages-fork publish() - behavioral snapshot (gh-pages v6.3.0 behavio engine.run(basePath, options, new logging.NullLogger()) ).rejects.toThrow('fatal: Authentication failed'); - const cloneCall = spawnCalls.find(c => c.cmd === 'git' && c.args[0] === 'clone'); + const cloneCall = spawnCalls.find(c => c.cmd === 'git' && gitSubcommand(c.args) === 'clone'); expect(cloneCall).toBeDefined(); + // credentials in the URL: no credential helper may store them + expect(cloneCall!.args.slice(0, 2)).toEqual(['-c', 'credential.helper=']); }); }); diff --git a/src/engine/engine.gh-pages-cleanup.spec.ts b/src/engine/engine.gh-pages-cleanup.spec.ts index f1768ef..41a99a8 100644 --- a/src/engine/engine.gh-pages-cleanup.spec.ts +++ b/src/engine/engine.gh-pages-cleanup.spec.ts @@ -24,7 +24,6 @@ import { execSync } from 'child_process'; import { logging } from '@angular-devkit/core'; import * as engine from './engine'; -import * as ghPages from '../gh-pages-fork/lib'; interface GitOptions { readonly cwd: string; @@ -98,12 +97,10 @@ describe('end-to-end cleanup regression (issue #204, real git)', () => { await seedGhPagesBranch(path.join(tempDir, 'seed'), bareRepoPath); - // Each test starts from a clean clone. - await ghPages.clean(); + process.env.CACHE_DIR = path.join(tempDir, 'cache'); }); afterEach(async () => { - await ghPages.clean(); process.env = originalEnv; await fs.rm(tempDir, { recursive: true, force: true }); }); diff --git a/src/engine/engine.gh-pages-clone.spec.ts b/src/engine/engine.gh-pages-clone.spec.ts index c5c71af..11ac5e5 100644 --- a/src/engine/engine.gh-pages-clone.spec.ts +++ b/src/engine/engine.gh-pages-clone.spec.ts @@ -1,44 +1,71 @@ /** - * gh-pages-fork clean() behavior verification + * Removal of the temporary clone (REAL git, REAL filesystem) * * What we're testing: - * - clean() actually removes cache directories - * - * Why test files run serially (see vitest.config.mts): - * The cache at node_modules/.cache/gh-pages/ is shared by all repositories. - * clean() wipes the ENTIRE cache directory, so a parallel test file could - * lose a clone it is actively using. + * - publish() clones into a fresh directory below the cache directory + * - that directory is removed after a successful AND after a failed deploy, + * so no clone (with a token in .git/config) stays on disk and concurrent + * deploys never share a clone */ -import * as path from 'path'; +import { execSync } from 'child_process'; import * as fs from 'fs/promises'; - -import { pathExists } from '../utils'; +import * as os from 'os'; +import * as path from 'path'; import * as ghPages from '../gh-pages-fork/lib'; -describe('gh-pages-fork clean() behavior', () => { +describe('gh-pages-fork temporary clone', () => { + let tempDir: string; + let cacheDir: string; + let basePath: string; + let bareRepoPath: string; + let originalEnv: NodeJS.ProcessEnv; + const user = { name: 'Test', email: 'test@test.com' }; + + beforeEach(async () => { + originalEnv = { ...process.env }; + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gh-pages-clone-test-')); + process.env.CACHE_DIR = path.join(tempDir, 'cache'); + cacheDir = ghPages.getCacheDir(); + + basePath = path.join(tempDir, 'dist'); + await fs.mkdir(basePath); + await fs.writeFile(path.join(basePath, 'index.html'), 'test'); + + bareRepoPath = path.join(tempDir, 'bare-repo.git'); + execSync(`git init --bare "${bareRepoPath}"`, { stdio: 'pipe' }); + }); - it('should remove repo-specific cache directories', async () => { - // Create a fake repo-specific cache directory - const fakeRepoUrl = 'https://github.com/test/clean-test.git'; - const repoCacheDir = ghPages.getCacheDir(fakeRepoUrl); + afterEach(async () => { + process.env = originalEnv; + await fs.rm(tempDir, { recursive: true, force: true }); + }); - // Setup: create the fake cache directory with a marker file - await fs.mkdir(repoCacheDir, { recursive: true }); - await fs.writeFile(path.join(repoCacheDir, 'marker.txt'), 'should be deleted'); - expect(await pathExists(repoCacheDir)).toBe(true); + it('should clone below the cache directory', async () => { + const messages: string[] = []; - // Execute clean - this removes ALL repo cache directories - await ghPages.clean(); + await ghPages.publish(basePath, { repo: bareRepoPath, user }, (message) => messages.push(message)); - // Verify the directory was removed - expect(await pathExists(repoCacheDir)).toBe(false); + const cloning = messages.find((message) => message.startsWith('Cloning ')); + const target = cloning!.split(' into ')[1]; + expect(path.dirname(target)).toBe(cacheDir); + expect(path.basename(target)).toMatch(/^clone-/); }); - it('should not throw when cache directory does not exist', async () => { - // clean() should be safe to call even if nothing to clean - await ghPages.clean(); - await expect(ghPages.clean()).resolves.toBeUndefined(); + it('should remove the clone after a successful deploy', async () => { + await ghPages.publish(basePath, { repo: bareRepoPath, user }); + + expect(await fs.readdir(cacheDir)).toEqual([]); + }); + + it('should remove the clone after a failed deploy', async () => { + const hook = path.join(bareRepoPath, 'hooks', 'pre-receive'); + await fs.writeFile(hook, '#!/bin/sh\nexit 1\n'); + await fs.chmod(hook, 0o755); + + await expect(ghPages.publish(basePath, { repo: bareRepoPath, user })).rejects.toThrow(); + + expect(await fs.readdir(cacheDir)).toEqual([]); }); }); diff --git a/src/engine/engine.gh-pages-filecreation.spec.ts b/src/engine/engine.gh-pages-filecreation.spec.ts index b789aa7..7159413 100644 --- a/src/engine/engine.gh-pages-filecreation.spec.ts +++ b/src/engine/engine.gh-pages-filecreation.spec.ts @@ -9,7 +9,7 @@ * Approach: * - Create a local bare git repository for each test * - publish() clones from and pushes to the local repo (no network required) - * - Verify files in the clone after publish completes + * - Verify the files on the pushed gh-pages branch * * 404.html is created by the engine in dist; CNAME and .nojekyll are written * by publish() into its clone. @@ -20,7 +20,6 @@ import * as fs from 'fs/promises'; import * as os from 'os'; import { execSync } from 'child_process'; -import { pathExists } from '../utils'; import * as ghPages from '../gh-pages-fork/lib'; @@ -28,13 +27,16 @@ describe('gh-pages-fork CNAME/.nojekyll file creation (REAL filesystem)', () => let tempDir: string; let basePath: string; let bareRepoPath: string; + let originalEnv: NodeJS.ProcessEnv; // Unique ID to prevent conflicts with parallel test runs const testRunId = `${Date.now()}-${Math.random().toString(36).substring(7)}`; beforeEach(async () => { - // Create a unique temp directory for this test + // Create a unique temp directory for this test, including the clone cache + originalEnv = { ...process.env }; tempDir = await fs.mkdtemp(path.join(os.tmpdir(), `gh-pages-file-test-${testRunId}-`)); + process.env.CACHE_DIR = path.join(tempDir, 'cache'); // Create source directory with minimal files basePath = path.join(tempDir, 'dist'); @@ -57,25 +59,28 @@ describe('gh-pages-fork CNAME/.nojekyll file creation (REAL filesystem)', () => execSync(`git -C "${initWorkDir}" branch gh-pages`, { stdio: 'pipe' }); execSync(`git -C "${initWorkDir}" remote add origin "${bareRepoPath}"`, { stdio: 'pipe' }); execSync(`git -C "${initWorkDir}" push -u origin gh-pages`, { stdio: 'pipe' }); - - // Every test starts from a fresh clone - await ghPages.clean(); }); afterEach(async () => { - await ghPages.clean(); + process.env = originalEnv; // Cleanup temp directory await fs.rm(tempDir, { recursive: true, force: true }); }); - function getCacheDir(repoPath: string): string { - return ghPages.getCacheDir(repoPath); + /** + * Content of a file on the pushed gh-pages branch, or null if it doesn't exist + */ + function readPublished(file: string): string | null { + try { + return execSync(`git --git-dir="${bareRepoPath}" show gh-pages:${file}`, { stdio: 'pipe' }).toString(); + } catch { + return null; + } } describe('CNAME file creation', () => { it('should create CNAME file with exact domain content when cname option is set', async () => { const testDomain = 'test-cname.example.com'; - const cacheDir = getCacheDir(bareRepoPath); const options = { repo: bareRepoPath, @@ -88,16 +93,10 @@ describe('gh-pages-fork CNAME/.nojekyll file creation (REAL filesystem)', () => await ghPages.publish(basePath, options); // Verify CNAME file was created by publish() - const cnamePath = path.join(cacheDir, 'CNAME'); - const exists = await pathExists(cnamePath); - expect(exists).toBe(true); - - const content = await fs.readFile(cnamePath, 'utf-8'); - expect(content).toBe(testDomain); + expect(readPublished('CNAME')).toBe(testDomain); }); it('should NOT create CNAME file when cname option is not provided', async () => { - const cacheDir = getCacheDir(bareRepoPath); const options = { repo: bareRepoPath, @@ -109,15 +108,12 @@ describe('gh-pages-fork CNAME/.nojekyll file creation (REAL filesystem)', () => await ghPages.publish(basePath, options); - const cnamePath = path.join(cacheDir, 'CNAME'); - const exists = await pathExists(cnamePath); - expect(exists).toBe(false); + expect(readPublished('CNAME')).toBeNull(); }); }); describe('.nojekyll file creation', () => { it('should create .nojekyll file when nojekyll option is true', async () => { - const cacheDir = getCacheDir(bareRepoPath); const options = { repo: bareRepoPath, @@ -129,13 +125,10 @@ describe('gh-pages-fork CNAME/.nojekyll file creation (REAL filesystem)', () => await ghPages.publish(basePath, options); - const nojekyllPath = path.join(cacheDir, '.nojekyll'); - const exists = await pathExists(nojekyllPath); - expect(exists).toBe(true); + expect(readPublished('.nojekyll')).toBe(''); }); it('should NOT create .nojekyll file when nojekyll option is false', async () => { - const cacheDir = getCacheDir(bareRepoPath); const options = { repo: bareRepoPath, @@ -147,16 +140,13 @@ describe('gh-pages-fork CNAME/.nojekyll file creation (REAL filesystem)', () => await ghPages.publish(basePath, options); - const nojekyllPath = path.join(cacheDir, '.nojekyll'); - const exists = await pathExists(nojekyllPath); - expect(exists).toBe(false); + expect(readPublished('.nojekyll')).toBeNull(); }); }); describe('Both CNAME and .nojekyll together', () => { it('should create both files when both options are set', async () => { const testDomain = 'both-files.example.com'; - const cacheDir = getCacheDir(bareRepoPath); const options = { repo: bareRepoPath, @@ -169,14 +159,8 @@ describe('gh-pages-fork CNAME/.nojekyll file creation (REAL filesystem)', () => await ghPages.publish(basePath, options); - // Verify CNAME - const cnamePath = path.join(cacheDir, 'CNAME'); - expect(await pathExists(cnamePath)).toBe(true); - expect(await fs.readFile(cnamePath, 'utf-8')).toBe(testDomain); - - // Verify .nojekyll - const nojekyllPath = path.join(cacheDir, '.nojekyll'); - expect(await pathExists(nojekyllPath)).toBe(true); + expect(readPublished('CNAME')).toBe(testDomain); + expect(readPublished('.nojekyll')).toBe(''); }); }); }); diff --git a/src/engine/engine.gh-pages-integration.spec.ts b/src/engine/engine.gh-pages-integration.spec.ts index dcabc36..5e4f30d 100644 --- a/src/engine/engine.gh-pages-integration.spec.ts +++ b/src/engine/engine.gh-pages-integration.spec.ts @@ -2,7 +2,6 @@ * Integration tests for the interaction with gh-pages-fork * * These tests verify how engine.ts interacts with gh-pages-fork: - * - When clean() is called * - What arguments are passed to publish() * - Which options are passed through vs. filtered out * - Dry-run behavior isolation @@ -36,7 +35,6 @@ vi.mock('../gh-pages-fork/lib/git', async () => { vi.mock('../gh-pages-fork/lib', async () => ({ ...(await vi.importActual('../gh-pages-fork/lib')), - clean: vi.fn(), publish: vi.fn() })); @@ -44,13 +42,10 @@ describe('engine - gh-pages-fork integration', () => { const logger = new logging.NullLogger(); const originalEnv = process.env; - const ghpagesCleanSpy = vi.mocked(ghpages.clean); const ghpagesPublishSpy = vi.mocked(ghpages.publish); beforeEach(() => { - ghpagesCleanSpy.mockReset(); ghpagesPublishSpy.mockReset(); - ghpagesCleanSpy.mockResolvedValue(undefined); ghpagesPublishSpy.mockResolvedValue(undefined); // Create fresh copy of environment for each test @@ -70,42 +65,14 @@ describe('engine - gh-pages-fork integration', () => { process.env = originalEnv; }); - describe('clean() behavior', () => { - it('should call clean() before publishing in normal mode', async () => { + describe('publish() calls', () => { + it('should call publish() exactly once in normal mode', async () => { const testDir = '/test/dist'; const options = { dotfiles: true, notfound: true, nojekyll: true }; await engine.run(testDir, options, logger); - expect(ghpagesCleanSpy).toHaveBeenCalledTimes(1); - expect(ghpagesCleanSpy).toHaveBeenCalledWith(); - }); - - it('should NOT call clean() during dry-run', async () => { - const testDir = '/test/dist'; - const options = { - dotfiles: true, - notfound: true, - nojekyll: true, - dryRun: true - }; - - await engine.run(testDir, options, logger); - - expect(ghpagesCleanSpy).not.toHaveBeenCalled(); - }); - - it('should call clean() before publish() to avoid remote url mismatch', async () => { - const testDir = '/test/dist'; - const options = { dotfiles: true, notfound: true, nojekyll: true }; - - await engine.run(testDir, options, logger); - - // Verify clean() was called before publish() - const cleanCallOrder = ghpagesCleanSpy.mock.invocationCallOrder[0]; - const publishCallOrder = ghpagesPublishSpy.mock.invocationCallOrder[0]; - - expect(cleanCallOrder).toBeLessThan(publishCallOrder); + expect(ghpagesPublishSpy).toHaveBeenCalledTimes(1); }); }); @@ -383,20 +350,6 @@ describe('engine - gh-pages-fork integration', () => { expect(ghpagesPublishSpy).not.toHaveBeenCalled(); }); - it('should NOT call clean() during dry-run', async () => { - const testDir = '/test/dist'; - const options = { - dryRun: true, - dotfiles: true, - notfound: true, - nojekyll: true - }; - - await engine.run(testDir, options, logger); - - expect(ghpagesCleanSpy).not.toHaveBeenCalled(); - }); - it('should log what WOULD be published during dry-run', async () => { const testLogger = new logging.Logger('test'); const infoSpy = vi.spyOn(testLogger, 'info'); diff --git a/src/engine/engine.gh-pages-no-package-json.spec.ts b/src/engine/engine.gh-pages-no-package-json.spec.ts index e70f224..3762870 100644 --- a/src/engine/engine.gh-pages-no-package-json.spec.ts +++ b/src/engine/engine.gh-pages-no-package-json.spec.ts @@ -22,7 +22,6 @@ import { execSync } from 'child_process'; import { logging } from '@angular-devkit/core'; import * as engine from './engine'; -import * as ghPages from '../gh-pages-fork/lib'; function git(args: string, cwd: string): string { return execSync(`git -C "${cwd}" ${args}`, { stdio: ['pipe', 'pipe', 'pipe'] }) @@ -74,16 +73,18 @@ describe('engine.run() in a directory with no package.json (issue #203)', () => afterEach(async () => { process.chdir(originalCwd); - await ghPages.clean(); process.env = originalEnv; await fs.rm(workDir, { recursive: true, force: true }); }); it('engine.run() succeeds and actually publishes when no package.json is reachable from cwd', async () => { - // Only a real run proves the full chain (prepareOptions β†’ clean() β†’ - // publish() β†’ real git clone/push) in a cwd without package.json. + // Only a real run proves the full chain (prepareOptions β†’ publish() β†’ + // real git clone/push) in a cwd without package.json. process.chdir(workDir); + const logger = new logging.Logger('test'); + const messages: string[] = []; + logger.subscribe((entry) => messages.push(entry.message)); await engine.run( distDir, @@ -97,7 +98,7 @@ describe('engine.run() in a directory with no package.json (issue #203)', () => email: 'test@test.com', message: 'test deploy (no package.json)' }, - new logging.NullLogger() + logger ); // Positive proof that the deploy actually landed: @@ -108,6 +109,8 @@ describe('engine.run() in a directory with no package.json (issue #203)', () => expect(tree).toEqual(['index.html']); // The clone went to the tmpdir fallback - expect(ghPages.getCacheDir()).toBe(path.join(os.tmpdir(), 'angular-cli-ghpages-cache', 'gh-pages')); + const cloning = messages.find((message) => message.startsWith('Cloning ')); + const cloneDir = cloning!.split(' into ')[1]; + expect(path.dirname(cloneDir)).toBe(path.join(os.tmpdir(), 'angular-cli-ghpages-cache', 'gh-pages')); }, 30_000); }); diff --git a/src/engine/engine.spec.ts b/src/engine/engine.spec.ts index c749d88..b0edd63 100644 --- a/src/engine/engine.spec.ts +++ b/src/engine/engine.spec.ts @@ -17,7 +17,6 @@ vi.mock('../gh-pages-fork/lib', async () => { const actual = await vi.importActual('../gh-pages-fork/lib'); return { ...actual, - clean: vi.fn(), publish: vi.fn() }; }); @@ -272,12 +271,10 @@ describe('engine', () => { beforeEach(() => { vi.mocked(pathExists).mockResolvedValue(true); - vi.mocked(ghpages.clean).mockResolvedValue(undefined); }); afterEach(() => { vi.mocked(pathExists).mockReset(); - vi.mocked(ghpages.clean).mockReset(); vi.mocked(ghpages.publish).mockReset(); }); @@ -294,20 +291,9 @@ describe('engine', () => { await expect(engine.run(testDir, options, logger)).resolves.toBeUndefined(); }); - it('should clean the cache before publishing', async () => { - const calls: string[] = []; - vi.mocked(ghpages.clean).mockImplementation(async () => { calls.push('clean'); }); - vi.mocked(ghpages.publish).mockImplementation(async () => { calls.push('publish'); }); - - await engine.run(testDir, options, logger); - - expect(calls).toEqual(['clean', 'publish']); - }); - - it('should neither clean nor publish during dry-run', async () => { + it('should not publish during dry-run', async () => { await engine.run(testDir, { ...options, dryRun: true }, logger); - expect(ghpages.clean).not.toHaveBeenCalled(); expect(ghpages.publish).not.toHaveBeenCalled(); }); diff --git a/src/gh-pages-fork/README.md b/src/gh-pages-fork/README.md index 66bf8c4..9fbc9ce 100644 --- a/src/gh-pages-fork/README.md +++ b/src/gh-pages-fork/README.md @@ -26,7 +26,7 @@ The file structure and function names follow upstream, so upstream changes can b | Upstream | Fork | Contents | | -------------- | -------------- | ---------------------------------------------------------------------- | -| `lib/index.js` | `lib/index.ts` | `publish()`, `clean()`, `getCacheDir()`, `getRepo()`, `defaults` | +| `lib/index.js` | `lib/index.ts` | `publish()`, `getCacheDir()`, `getRepo()`, `defaults` | | `lib/git.js` | `lib/git.ts` | `Git` class, `ProcessError`, `spawn()` | | `lib/util.js` | `lib/util.ts` | `copy()`, `getUser()`, plus `listFiles()` instead of a glob library | @@ -35,28 +35,34 @@ The file structure and function names follow upstream, so upstream changes can b ### Features Kept βœ… - `publish(basePath, options, log?)` β€” async, rejects on any error -- `clean()` and `getCacheDir()` +- `getCacheDir()` and `getRepo()` - Options: `repo`, `branch`, `remote`, `message`, `git`, `depth`, `add`, `dotfiles`, `nojekyll`, `cname`, `user` -- `Git`: `clone`, `exec`, `clean`, `reset`, `fetch`, `checkout`, `rm`, `add`, `commit`, `push`, `getRemoteUrl` +- `Git`: `clone`, `exec`, `clean`, `reset`, `checkout`, `rm`, `removeAll`, `add`, `commit`, `push`, `getRemoteUrl` ### Features Removed ❌ -- The `gh-pages` / `gh-pages-clean` CLI +- The `gh-pages` / `gh-pages-clean` CLI and `clean()` - The callback API of `publish()` - Options `src`, `remove`, `only`, `dest`, `tag`, `history`, `push`, `silent`, `beforeAdd` -- `Git`: `init`, `tag`, `deleteRef` +- `Git`: `init`, `fetch`, `tag`, `deleteRef` - All dependencies: `async`, `commander`, `email-addresses`, `filenamify`, `find-cache-dir`, `fs-extra`, `globby`/`tinyglobby` ## Deviations from Upstream | Topic | Upstream | Fork | | ----------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| Logging | `util.debuglog('gh-pages')` | `log` callback parameter of `publish()` | -| Removing files | glob `remove` pattern, then `git rm` in batches | `git rm -r -f --ignore-unmatch -- .` (all tracked files, including dotfiles and submodules) | -| File list | glob `src` pattern via tinyglobby | `listFiles()`: all files below `basePath`, follows symlinks, honors `dotfiles` | -| Cache directory | `find-cache-dir` + `filenamify(repo)` | Same lookup (`$CACHE_DIR`, closest `package.json`) plus an os temp dir fallback; clone directory named after a hash of the repo URL, so tokens never end up in a path | +| Clone | one cached clone per repository URL, reused across runs, `gh-pages-clean` to reset it | a fresh temporary clone per `publish()` below the cache directory, removed afterwards; concurrent deploys never share a clone, and no token stays in `.git/config` | +| Cache directory | `find-cache-dir` | same lookup (`$CACHE_DIR`, closest `package.json`) plus an os temp dir fallback | +| Repository check | "Remote url mismatch" check against the cached clone | none (every clone is fresh), so relative repository paths work | +| Git environment | inherited | `GIT_CEILING_DIRECTORIES` for all commands in the clone, so git never falls through to a repository above it; `-c credential.helper=` when the URL carries credentials, so no credential helper stores them | +| Process results | resolves on a falsy exit code (also for a process killed by a signal); stdout and stderr in one buffer | rejects unless the exit code is 0 (a killed process rejects); `output` is stdout only, stderr only goes into error messages | +| Logging | `util.debuglog('gh-pages')` | `log` callback parameter of `publish()`; credentials in the repository URL are replaced with `***` | +| Removing files | glob `remove` pattern, then `git rm` in batches | `removeAll()`: `git rm` of `.gitmodules`, then of `.` (all tracked files, including dotfiles and submodule gitlinks) | +| New branch | `checkout --orphan` keeps the files of the cloned default branch (they get published with `add`) | the orphan branch starts empty | | `checkout()` | `main` only runs `ls-remote` for an existing branch | `checkout`, `clean -f -d`, `reset --hard remote/branch` (the behavior of v6.3.0) | -| Log messages | full repository URL | credentials in the repository URL are replaced with `***` | +| File list | glob `src` pattern via tinyglobby | `listFiles()`: all files below `basePath`, follows symlinks, skips broken links and cycles, honors `dotfiles` | +| Copying | `fs-extra` copy (keeps the file mode) | streams, files created with mode 0666 minus umask (no executable bit, like gh-pages v6.3.0); limited concurrency | +| `getUser()` | always runs `git` | uses the configured `git` executable | | Empty `basePath` | `The pattern in the "src" property didn't match any files.` | `The directory "" doesn't contain any files to publish.` | ## Maintenance diff --git a/src/gh-pages-fork/test/cache-dir.spec.ts b/src/gh-pages-fork/test/cache-dir.spec.ts index c771e81..a9d84a1 100644 --- a/src/gh-pages-fork/test/cache-dir.spec.ts +++ b/src/gh-pages-fork/test/cache-dir.spec.ts @@ -2,9 +2,9 @@ import * as fs from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; -import { clean, getCacheDir } from '../lib'; +import { getCacheDir } from '../lib'; -describe('gh-pages-fork getCacheDir() / clean()', () => { +describe('gh-pages-fork getCacheDir()', () => { let tempDir: string; let originalEnv: NodeJS.ProcessEnv; let originalCwd: string; @@ -53,24 +53,9 @@ describe('gh-pages-fork getCacheDir() / clean()', () => { expect(getCacheDir()).toBe(path.join(os.tmpdir(), 'angular-cli-ghpages-cache', 'gh-pages')); }); - it('names the clone directory after a hash, so tokens never end up on disk', () => { - process.env.CACHE_DIR = tempDir; - const repo = 'https://x-access-token:secret123@github.com/user/repo.git'; - - const cloneDir = getCacheDir(repo); - - expect(path.dirname(cloneDir)).toBe(path.join(tempDir, 'gh-pages')); - expect(cloneDir).not.toContain('secret123'); - expect(getCacheDir(repo)).toBe(cloneDir); - }); - - it('clean() removes the cache directory', async () => { - process.env.CACHE_DIR = tempDir; - const cloneDir = getCacheDir('https://github.com/user/repo.git'); - await fs.mkdir(cloneDir, { recursive: true }); - - await clean(); + it('resolves relative to the given directory', async () => { + await fs.writeFile(path.join(tempDir, 'package.json'), '{}'); - await expect(fs.access(path.join(tempDir, 'gh-pages'))).rejects.toThrow(); + expect(getCacheDir(tempDir)).toBe(path.join(tempDir, 'node_modules', '.cache', 'gh-pages')); }); }); diff --git a/src/gh-pages-fork/test/git-commands.spec.ts b/src/gh-pages-fork/test/git-commands.spec.ts index c502bb2..f9f5539 100644 --- a/src/gh-pages-fork/test/git-commands.spec.ts +++ b/src/gh-pages-fork/test/git-commands.spec.ts @@ -22,6 +22,7 @@ interface FakeResult { describe('gh-pages-fork git commands', () => { const repo = 'https://github.com/user/repo.git'; let calls: string[]; + let envs: (NodeJS.ProcessEnv | undefined)[]; let results: Record; let tempDir: string; let dist: string; @@ -36,17 +37,15 @@ describe('gh-pages-fork git commands', () => { await fs.writeFile(path.join(dist, 'index.html'), '

test

'); calls = []; + envs = []; results = { 'config --get remote.origin.url': { output: repo }, 'diff-index --quiet HEAD': { code: 1 } }; - mockSpawn.mockImplementation((cmd: string, args: string[], options: { cwd: string }) => { + mockSpawn.mockImplementation((cmd: string, args: string[], options: { cwd: string; env: NodeJS.ProcessEnv }) => { const command = args.join(' '); calls.push(`${cmd} ${command}`); - if (args[0] === 'clone') { - // git clone creates the target directory - void fs.mkdir(args[2], { recursive: true }); - } + envs.push(options.env); const result = Object.entries(results).find(([key]) => command.startsWith(key))?.[1] ?? {}; const child = Object.assign(new EventEmitter(), { stdout: new EventEmitter(), @@ -85,13 +84,11 @@ describe('gh-pages-fork git commands', () => { const cloneDir = cloneDirOf(calls[0]); expect(calls).toEqual([ `git clone ${repo} ${cloneDir} --branch gh-pages --single-branch --origin origin --depth 1`, - 'git config --get remote.origin.url', - 'git clean -f -d', - 'git fetch origin', 'git ls-remote --exit-code . origin/gh-pages', 'git checkout gh-pages', 'git clean -f -d', 'git reset --hard origin/gh-pages', + 'git rm --ignore-unmatch -r -f -- .gitmodules', 'git rm --ignore-unmatch -r -f -- .', 'git add .', 'git config user.email bot@example.org', @@ -106,7 +103,7 @@ describe('gh-pages-fork git commands', () => { results['clone ' + repo] = { code: 128 }; results['ls-remote'] = { code: 2 }; const defaultImplementation = mockSpawn.getMockImplementation()!; - mockSpawn.mockImplementation((cmd: string, args: string[], options: { cwd: string }) => { + mockSpawn.mockImplementation((cmd: string, args: string[], options: { cwd: string; env: NodeJS.ProcessEnv }) => { // only the clone with --branch fails, the fallback clone succeeds if (args[0] === 'clone' && !args.includes('--branch')) { delete results['clone ' + repo]; @@ -120,14 +117,51 @@ describe('gh-pages-fork git commands', () => { expect(calls.slice(0, 7)).toEqual([ `git clone ${repo} ${cloneDir} --branch gh-pages --single-branch --origin origin --depth 1`, `git clone ${repo} ${cloneDir} --origin origin`, - 'git config --get remote.origin.url', - 'git clean -f -d', - 'git fetch origin', 'git ls-remote --exit-code . origin/gh-pages', - 'git checkout --orphan gh-pages' + 'git checkout --orphan gh-pages', + // the orphan branch starts empty (the cloned default branch's files are removed) + 'git rm --ignore-unmatch -r -f -- .gitmodules', + 'git rm --ignore-unmatch -r -f -- .', + 'git rm --ignore-unmatch -r -f -- .gitmodules' ]); }); + it('starts an orphan branch empty even with add', async () => { + results['clone ' + repo + ' '] = { code: 128 }; + results['ls-remote'] = { code: 2 }; + const defaultImplementation = mockSpawn.getMockImplementation()!; + mockSpawn.mockImplementation((cmd: string, args: string[], options: { cwd: string; env: NodeJS.ProcessEnv }) => { + if (args[0] === 'clone' && !args.includes('--branch')) { + delete results['clone ' + repo + ' ']; + } + return defaultImplementation(cmd, args, options); + }); + + await publish(dist, { repo, add: true, user: { name: 'Bot', email: 'bot@example.org' } }); + + expect(calls.filter((call) => call.startsWith('git rm'))).toEqual([ + 'git rm --ignore-unmatch -r -f -- .gitmodules', + 'git rm --ignore-unmatch -r -f -- .' + ]); + }); + + it('never lets git operate on a repository above the clone', async () => { + await publish(dist, { repo, user: { name: 'Bot', email: 'bot@example.org' } }); + + const cloneDir = cloneDirOf(calls[0]); + for (const env of envs) { + expect(env?.GIT_CEILING_DIRECTORIES).toBe(path.dirname(cloneDir)); + } + }); + + it('disables credential helpers for a repository URL with credentials', async () => { + const repoWithToken = 'https://x-access-token:secret@github.com/user/repo.git'; + + await publish(dist, { repo: repoWithToken, user: { name: 'Bot', email: 'bot@example.org' } }); + + expect(calls.every((call) => call.startsWith('git -c credential.helper= '))).toBe(true); + }); + it('skips removing files with add', async () => { await publish(dist, { repo, add: true, user: { name: 'Bot', email: 'bot@example.org' } }); diff --git a/src/gh-pages-fork/test/publish.spec.ts b/src/gh-pages-fork/test/publish.spec.ts index 21c24bd..f057970 100644 --- a/src/gh-pages-fork/test/publish.spec.ts +++ b/src/gh-pages-fork/test/publish.spec.ts @@ -7,7 +7,7 @@ import * as fs from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; -import { clean, publish, PublishOptions } from '../lib'; +import { getCacheDir, publish, PublishOptions } from '../lib'; const user = { name: 'Deploy Bot', email: 'bot@example.org' }; @@ -49,7 +49,6 @@ describe('gh-pages-fork publish()', () => { }); afterEach(async () => { - await clean(); process.env = originalEnv; await fs.rm(tempDir, { recursive: true, force: true }); }); @@ -182,7 +181,119 @@ describe('gh-pages-fork publish()', () => { publish(dist, { repo: repoWithToken, user }, (message) => messages.push(message)) ).rejects.toThrow(); - expect(messages[0]).toContain('https://***@example.invalid/user/repo.git'); + expect(messages[0].split(' into ')[0]).toBe('Cloning https://***@example.invalid/user/repo.git'); expect(messages.join('\n')).not.toContain('secret123'); }); + + it('removes a submodule gitlink that sorts before .gitmodules', async () => { + const seed = path.join(tempDir, 'seed-gitmodules'); + git(tempDir, 'init', '-b', 'gh-pages', seed); + git(seed, 'config', 'user.name', 'Seed'); + git(seed, 'config', 'user.email', 'seed@example.org'); + await fs.writeFile(path.join(seed, '.gitmodules'), '[submodule "shared"]\n\tpath = .github/actions/shared\n\turl = https://example.invalid/shared.git\n'); + git(seed, 'add', '.'); + git(seed, 'commit', '-m', 'seed'); + git(seed, 'update-index', '--add', '--cacheinfo', `160000,${git(seed, 'rev-parse', 'HEAD')},.github/actions/shared`); + git(seed, 'commit', '-m', 'gitlink'); + git(seed, 'push', remote, 'gh-pages'); + + await deploy(); + + expect(tree(remote)).toEqual(['assets/app.js', 'index.html']); + }); + + it('never publishes the default branch on the first deploy with add', async () => { + const source = path.join(tempDir, 'source'); + git(tempDir, 'init', '-b', 'main', source); + git(source, 'config', 'user.name', 'Dev'); + git(source, 'config', 'user.email', 'dev@example.org'); + await fs.writeFile(path.join(source, 'environment.ts'), 'apiKey=secret'); + git(source, 'add', '.'); + git(source, 'commit', '-m', 'source'); + git(source, 'push', remote, 'main'); + + await deploy({ add: true }); + + expect(tree(remote)).toEqual(['assets/app.js', 'index.html']); + }); + + it('skips broken symbolic links and symlink cycles in dist', async () => { + await fs.symlink('/does/not/exist', path.join(dist, 'broken.svg')); + await fs.symlink('..', path.join(dist, 'assets', 'loop')); + + await deploy(); + + expect(tree(remote)).toEqual(['assets/app.js', 'index.html']); + }); + + it('writes files without the executable bit', async () => { + await fs.chmod(path.join(dist, 'assets', 'app.js'), 0o755); + + await deploy(); + + expect(git(remote, 'ls-tree', 'gh-pages', 'assets/app.js').split(/\s/)[0]).toBe('100644'); + }); + + it('accepts a relative repository path', async () => { + const cwd = process.cwd(); + process.chdir(tempDir); + + try { + await publish(dist, { repo: './remote.git', user, message: 'deploy' }); + } finally { + process.chdir(cwd); + } + + expect(tree(remote)).toEqual(['assets/app.js', 'index.html']); + }); + + it('runs concurrent deploys to different branches without interference', async () => { + const other = path.join(tempDir, 'other-dist'); + await fs.mkdir(other); + await fs.writeFile(path.join(other, 'other.html'), 'other'); + + await Promise.all([ + deploy({ branch: 'pages-a' }), + publish(other, { repo: remote, user, branch: 'pages-b', message: 'deploy' }) + ]); + + expect(tree(remote, 'pages-a')).toEqual(['assets/app.js', 'index.html']); + expect(tree(remote, 'pages-b')).toEqual(['other.html']); + expect(await fs.readdir(getCacheDir())).toEqual([]); + }); + + it('rejects when git is killed by a signal', async () => { + const fakeGit = path.join(tempDir, 'fake-git'); + await fs.writeFile(fakeGit, `#!/bin/sh\nif [ "$1" = "push" ]; then kill -9 $$; fi\nexec git "$@"\n`); + await fs.chmod(fakeGit, 0o755); + + await expect(deploy({ git: fakeGit })).rejects.toThrow('Process killed by SIGKILL'); + }); + + it('reports a missing git executable as such', async () => { + const missing = path.join(tempDir, 'no-such-git'); + + await expect(publish(dist, { git: missing, user, message: 'deploy' })).rejects.toThrow('ENOENT'); + }); + + it('ignores stderr output of git when reading the remote URL and the user', async () => { + const app = path.join(tempDir, 'app'); + git(tempDir, 'init', app); + git(app, 'remote', 'add', 'origin', remote); + git(app, 'config', 'user.name', 'Local User'); + git(app, 'config', 'user.email', 'local@example.org'); + const noisyGit = path.join(tempDir, 'noisy-git'); + await fs.writeFile(noisyGit, '#!/bin/sh\necho "trace: noise" >&2\nexec git "$@"\n'); + await fs.chmod(noisyGit, 0o755); + const cwd = process.cwd(); + process.chdir(app); + + try { + await publish(dist, { git: noisyGit, message: 'deploy' }); + } finally { + process.chdir(cwd); + } + + expect(git(remote, 'log', '-1', '--format=%an <%ae>', 'gh-pages')).toBe('Local User '); + }); }); diff --git a/src/package.json b/src/package.json index 177e59c..134b970 100644 --- a/src/package.json +++ b/src/package.json @@ -16,7 +16,7 @@ "prebuild": "node scripts/prebuild.js", "build": "tsc -p tsconfig.build.json", "postbuild": "node scripts/postbuild.js", - "test": "vitest run" + "test": "tsc -p tsconfig.json --noEmit && vitest run" }, "schematics": "./collection.json", "builders": "./builders.json", diff --git a/src/parameter-tests/builder-integration.spec.ts b/src/parameter-tests/builder-integration.spec.ts index 8b1ff8e..5a95cde 100644 --- a/src/parameter-tests/builder-integration.spec.ts +++ b/src/parameter-tests/builder-integration.spec.ts @@ -21,19 +21,19 @@ import { Mock } from 'vitest'; * * WHAT'S REAL vs MOCKED: * βœ… REAL: deploy/actions.ts, engine/engine.ts, prepareOptions() - * ❌ MOCKED: gh-pages-fork publish()/clean() (to capture final options), utils.pathExists + * ❌ MOCKED: gh-pages-fork publish() (to capture final options), utils.pathExists * This IS a true integration test - we test the full internal code path with external dependencies mocked. */ // Captured options from publish() const { capturedOptions } = vi.hoisted(() => { - const capturedOptions = { value: null as PublishOptions | null }; + // Record: tests also assert that internal options are absent + const capturedOptions = { value: null as (PublishOptions & Record) | null }; return { capturedOptions }; }); vi.mock('../gh-pages-fork/lib', async () => ({ ...(await vi.importActual('../gh-pages-fork/lib')), - clean: vi.fn(), publish: vi.fn() })); @@ -67,9 +67,8 @@ describe('Angular Builder Integration Tests', () => { capturedOptions.value = null; context = createMockContext(); - vi.mocked(ghpages.clean).mockResolvedValue(undefined); vi.mocked(ghpages.publish).mockImplementation(async (_dir, options) => { - capturedOptions.value = options; + capturedOptions.value = { ...options }; }); }); diff --git a/src/vitest.config.mts b/src/vitest.config.mts index 97fb8e1..414d975 100644 --- a/src/vitest.config.mts +++ b/src/vitest.config.mts @@ -3,9 +3,6 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { globals: true, - environment: 'node', - // Run test files serially: several suites run real git commands and the - // standalone CLI against temporary repositories - fileParallelism: false + environment: 'node' } }); From 394a8ba7c090210658d1593e295af3511903bfe2 Mon Sep 17 00:00:00 2001 From: Johannes Hoppe Date: Mon, 12 Oct 2026 13:15:05 +0200 Subject: [PATCH 08/17] refactor: fall back to the os temp dir and clone new branches shallow - a non-writable cache directory (e.g. a read-only node_modules) falls back to a clone directory in the os temp dir - when the target branch doesn't exist yet, the default branch is cloned shallow and without a checkout (only the remote is needed), with a full clone as the last resort for servers without shallow clone support --- src/gh-pages-fork/lib/git.ts | 24 ++++++++++++++---------- src/gh-pages-fork/lib/index.ts | 19 ++++++++++++++++--- 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/src/gh-pages-fork/lib/git.ts b/src/gh-pages-fork/lib/git.ts index 1cfa154..33ac71c 100644 --- a/src/gh-pages-fork/lib/git.ts +++ b/src/gh-pages-fork/lib/git.ts @@ -85,24 +85,28 @@ export class Git { /** * Clone a branch of a repo into the given (empty or missing) dir. - * Falls back to a full clone of the default branch when the branch doesn't exist yet. + * + * When the branch doesn't exist yet, the default branch is cloned without a + * checkout instead (only the remote is needed to create the new branch): + * shallow first, then in full for servers without shallow clone support. * * The returned instance never operates on a repository above `dir` * (GIT_CEILING_DIRECTORIES), even if `dir` loses its .git directory. */ static async clone(repo: string, dir: string, branch: string, options: CloneOptions, args: string[] = []): Promise { const env = { GIT_CEILING_DIRECTORIES: path.dirname(path.resolve(dir)) }; + const clone = (...cloneArgs: string[]) => + spawn(options.git, [...args, 'clone', repo, dir, '--origin', options.remote, ...cloneArgs], { env }); + const depth = String(options.depth); + try { - await spawn(options.git, [ - ...args, - 'clone', repo, dir, - '--branch', branch, - '--single-branch', - '--origin', options.remote, - '--depth', String(options.depth) - ], { env }); + await clone('--branch', branch, '--single-branch', '--depth', depth); } catch { - await spawn(options.git, [...args, 'clone', repo, dir, '--origin', options.remote], { env }); + try { + await clone('--no-checkout', '--depth', depth); + } catch { + await clone('--no-checkout'); + } } return new Git(dir, options.git, args, env); } diff --git a/src/gh-pages-fork/lib/index.ts b/src/gh-pages-fork/lib/index.ts index ab15168..52877bd 100644 --- a/src/gh-pages-fork/lib/index.ts +++ b/src/gh-pages-fork/lib/index.ts @@ -71,6 +71,21 @@ export function getCacheDir(cwd = process.cwd()): string { } } +/** + * Create a fresh directory for a temporary clone below getCacheDir(). + * Falls back to the os temp directory when the cache directory isn't writable + * (e.g. a read-only node_modules). + */ +async function createCloneDir(): Promise { + try { + const cacheDir = getCacheDir(); + await fs.mkdir(cacheDir, { recursive: true }); + return await fs.mkdtemp(path.join(cacheDir, 'clone-')); + } catch { + return fs.mkdtemp(path.join(os.tmpdir(), 'angular-cli-ghpages-clone-')); + } +} + /** * The repository to publish to: `options.repo`, or the URL of `options.remote` * of the git repository in the current working directory. @@ -119,9 +134,7 @@ export async function publish(basePath: string, config: PublishOptions, log: Log const user = options.user ?? await getUser(process.cwd(), options.git); const repoUrl = await getRepo(options); - const cacheDir = getCacheDir(); - await fs.mkdir(cacheDir, { recursive: true }); - const clone = await fs.mkdtemp(path.join(cacheDir, 'clone-')); + const clone = await createCloneDir(); try { // Credentials in the URL must not end up in a credential helper's store From a8c764bc63cce69a9a8b46202167f21d7e2648ee Mon Sep 17 00:00:00 2001 From: Johannes Hoppe Date: Mon, 12 Oct 2026 13:15:06 +0200 Subject: [PATCH 09/17] test: share git helpers between specs and remove duplicate tests - src/test-helpers/git.ts: git(), tree(), commitCount(), createBareRepo(), seedBranch(), createMockChildProcess() - real-git specs use the shared helpers instead of their own git wrappers and seed code - the behavior spec configures responses through one spawnOverride hook instead of three copies of the spawn mock - engine.spec no longer repeats the publish error handling of the integration spec - tests for the temp dir and full clone fallbacks --- CLAUDE.md | 3 +- src/engine/engine.gh-pages-behavior.spec.ts | 221 ++++-------------- src/engine/engine.gh-pages-cleanup.spec.ts | 85 ++----- src/engine/engine.gh-pages-clone.spec.ts | 5 +- .../engine.gh-pages-filecreation.spec.ts | 23 +- .../engine.gh-pages-integration.spec.ts | 13 ++ .../engine.gh-pages-no-package-json.spec.ts | 30 +-- src/engine/engine.spec.ts | 56 ----- src/gh-pages-fork/test/git-commands.spec.ts | 38 ++- src/gh-pages-fork/test/publish.spec.ts | 81 +++---- src/test-helpers/git.ts | 75 ++++++ src/tsconfig.build.json | 2 +- 12 files changed, 234 insertions(+), 398 deletions(-) create mode 100644 src/test-helpers/git.ts diff --git a/CLAUDE.md b/CLAUDE.md index cf408d7..799483f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -202,7 +202,8 @@ The engine appends CI metadata to commit messages when running on: ## Testing -- Uses Vitest (`npm test`), tests in `*.spec.ts` files +- Uses Vitest (`npm test`, also type-checks all sources including specs), tests in `*.spec.ts` files +- Shared helpers for specs that run real git or mock child processes: `src/test-helpers/git.ts` (not part of the package) - Requires git clone with `origin` remote (see `test-prerequisites.spec.ts`) - All tests preserve/restore `process.env` using `originalEnv` pattern - **No test counts in documentation** - they become stale quickly and are bragging diff --git a/src/engine/engine.gh-pages-behavior.spec.ts b/src/engine/engine.gh-pages-behavior.spec.ts index a9a55fd..7e78b3c 100644 --- a/src/engine/engine.gh-pages-behavior.spec.ts +++ b/src/engine/engine.gh-pages-behavior.spec.ts @@ -8,15 +8,13 @@ * Approach: Mock child_process and gh-pages-fork/lib/util (copy, getUser), use real filesystem */ -import { ChildProcess } from 'child_process'; - +import { createMockChildProcess } from '../test-helpers/git'; import * as engine from './engine'; import { logging } from '@angular-devkit/core'; const path = require('path'); const fs = require('fs/promises'); const os = require('os'); -const { EventEmitter } = require('events'); // Mock utils.pathExists so engine.run() passes the dist folder check vi.mock('../utils', async () => { @@ -44,14 +42,6 @@ let spawnCalls: SpawnCall[] = []; // Track current test context for deterministic mock behavior let currentTestContext: TestContext = {}; -// Mock child process with what gh-pages-fork lib/git.ts uses: stdout/stderr 'data', 'close' -function createMockChildProcess(): Partial { - const child: Partial = new EventEmitter(); - child.stdout = new EventEmitter() as unknown as ChildProcess['stdout']; - child.stderr = new EventEmitter() as unknown as ChildProcess['stderr']; - return child; -} - /** * Whitelist of git subcommands that publish() may run. * @@ -89,7 +79,31 @@ vi.mock('../gh-pages-fork/lib/util', async () => ({ getUser: mockGetUser })); -// Default implementation; tests that need other responses override it, beforeEach restores it +interface SpawnResult { + code?: number; + output?: string; + stderr?: string; +} + +// Per-test override: return a result to replace the default response for a git call +let spawnOverride: ((args: string[]) => SpawnResult | undefined) | undefined; + +function defaultResult(args: string[]): SpawnResult { + // git config --get remote.X.url returns the repo URL of the current test + if (args[0] === 'config' && args[1] === '--get' && args[2]?.startsWith('remote.')) { + return { output: currentTestContext.repo || '' }; + } + // the branch exists on the remote + if (args[0] === 'ls-remote') { + return { output: 'refs/heads/gh-pages' }; + } + // exit code 1: there are changes to commit + if (args[0] === 'diff-index') { + return { code: 1 }; + } + return {}; +} + function defaultSpawn(cmd: string, args: string[] | undefined, opts: unknown) { const capturedArgs = args || []; spawnCalls.push({ cmd, args: capturedArgs, options: opts }); @@ -100,34 +114,18 @@ function defaultSpawn(cmd: string, args: string[] | undefined, opts: unknown) { throw new Error(`Unexpected git command: ${subcommand}. Add to whitelist if intentional.`); } - const mockChild = createMockChildProcess(); - - // Simulate appropriate response based on git command + const result = spawnOverride?.(capturedArgs) ?? defaultResult(capturedArgs); + const child = createMockChildProcess(); setImmediate(() => { - let output = ''; - - // git config --get remote.X.url should return the repo URL - if (cmd === 'git' && capturedArgs[0] === 'config' && capturedArgs[1] === '--get' && - capturedArgs[2] && capturedArgs[2].startsWith('remote.')) { - // Use tracked repo URL for deterministic behavior - output = currentTestContext.repo || ''; + if (result.stderr) { + child.stderr!.emit('data', Buffer.from(result.stderr)); } - // ls-remote should return something for branch existence check - else if (cmd === 'git' && capturedArgs[0] === 'ls-remote') { - output = 'refs/heads/gh-pages'; + if (result.output) { + child.stdout!.emit('data', Buffer.from(result.output)); } - // diff-index for checking if commit needed (exit 1 means changes exist) - else if (cmd === 'git' && capturedArgs[0] === 'diff-index') { - // Return exit code 1 to indicate changes exist - mockChild.emit!('close', 1); - return; - } - - mockChild.stdout!.emit('data', Buffer.from(output)); - mockChild.emit!('close', 0); + child.emit('close', result.code ?? 0); }); - - return mockChild; + return child; } import * as ghPages from '../gh-pages-fork/lib'; @@ -178,6 +176,7 @@ describe('gh-pages-fork publish() - behavioral snapshot (gh-pages v6.3.0 behavio currentTestContext = {}; mockSpawn.mockReset(); mockSpawn.mockImplementation(defaultSpawn); + spawnOverride = undefined; // An injected token would make the repo URL differ from currentTestContext.repo originalEnv = { ...process.env }; @@ -735,63 +734,17 @@ describe('gh-pages-fork publish() - behavioral snapshot (gh-pages v6.3.0 behavio * - Documents expected error recovery behavior */ describe('Error scenarios', () => { - it('should retry git clone without branch/depth options on failure', async () => { + it('should retry git clone without the branch on failure', async () => { const repo = 'https://github.com/test/clone-failure-test.git'; currentTestContext.repo = repo; const branch = 'new-branch'; const options = { repo, branch, message: 'Deploy' }; - // Reset mock to implement failure behavior - mockSpawn.mockClear(); - - let cloneAttempts = 0; - mockSpawn.mockImplementation((cmd: string, args: string[] | undefined, opts: unknown) => { - const capturedArgs = args || []; - spawnCalls.push({ cmd, args: capturedArgs, options: opts }); - - // Validate git commands are expected - if (cmd === 'git' && capturedArgs[0]) { - if (!EXPECTED_GIT_COMMANDS.includes(capturedArgs[0])) { - throw new Error(`Unexpected git command: ${capturedArgs[0]}`); - } - } - - const mockChild = createMockChildProcess(); - - setImmediate(() => { - // First clone attempt with --branch and --depth should FAIL - if (cmd === 'git' && capturedArgs[0] === 'clone' && cloneAttempts === 0) { - cloneAttempts++; - mockChild.stderr!.emit('data', Buffer.from('fatal: Remote branch new-branch not found')); - mockChild.emit!('close', 128); // Git error code - return; - } - - // Second clone attempt without --branch/--depth should SUCCEED - if (cmd === 'git' && capturedArgs[0] === 'clone' && cloneAttempts === 1) { - cloneAttempts++; - mockChild.stdout!.emit('data', Buffer.from('')); - mockChild.emit!('close', 0); - return; - } - - // All other commands succeed normally - let output = ''; - if (cmd === 'git' && capturedArgs[0] === 'config' && capturedArgs[1] === '--get') { - output = repo; - } else if (cmd === 'git' && capturedArgs[0] === 'ls-remote') { - output = 'refs/heads/gh-pages'; - } else if (cmd === 'git' && capturedArgs[0] === 'diff-index') { - mockChild.emit!('close', 1); - return; - } - - mockChild.stdout!.emit('data', Buffer.from(output)); - mockChild.emit!('close', 0); - }); - - return mockChild; - }); + // The clone of the branch fails, the fallback clone of the default branch succeeds + spawnOverride = (args) => + args[0] === 'clone' && args.includes('--branch') + ? { code: 128, stderr: 'fatal: Remote branch new-branch not found' } + : undefined; await ghPages.publish(basePath, options); @@ -807,9 +760,9 @@ describe('gh-pages-fork publish() - behavioral snapshot (gh-pages v6.3.0 behavio expect(cloneCalls[0].args).toContain(branch); expect(cloneCalls[0].args).toContain('--depth'); - // Second attempt: WITHOUT --branch and --depth (fallback) + // Second attempt: the default branch, without a checkout (the new branch starts empty) expect(cloneCalls[1].args).not.toContain('--branch'); - expect(cloneCalls[1].args).not.toContain('--depth'); + expect(cloneCalls[1].args).toContain('--no-checkout'); expect(cloneCalls[1].args).toContain(repo); }); @@ -818,41 +771,8 @@ describe('gh-pages-fork publish() - behavioral snapshot (gh-pages v6.3.0 behavio currentTestContext.repo = repo; const options = { repo, message: 'Deploy' }; - // Reset mock to implement no-changes behavior - mockSpawn.mockClear(); - spawnCalls = []; - - mockSpawn.mockImplementation((cmd: string, args: string[] | undefined, opts: unknown) => { - const capturedArgs = args || []; - spawnCalls.push({ cmd, args: capturedArgs, options: opts }); - - if (cmd === 'git' && capturedArgs[0]) { - if (!EXPECTED_GIT_COMMANDS.includes(capturedArgs[0])) { - throw new Error(`Unexpected git command: ${capturedArgs[0]}`); - } - } - - const mockChild = createMockChildProcess(); - - setImmediate(() => { - let output = ''; - - if (cmd === 'git' && capturedArgs[0] === 'config' && capturedArgs[1] === '--get') { - output = repo; - } else if (cmd === 'git' && capturedArgs[0] === 'ls-remote') { - output = 'refs/heads/gh-pages'; - } else if (cmd === 'git' && capturedArgs[0] === 'diff-index') { - // Return 0 = no changes (opposite of our normal mock) - mockChild.emit!('close', 0); - return; - } - - mockChild.stdout!.emit('data', Buffer.from(output)); - mockChild.emit!('close', 0); - }); - - return mockChild; - }); + // exit code 0: no changes (opposite of the default) + spawnOverride = (args) => args[0] === 'diff-index' ? { code: 0 } : undefined; await ghPages.publish(basePath, options); @@ -877,42 +797,6 @@ describe('gh-pages-fork publish() - behavioral snapshot (gh-pages v6.3.0 behavio const options = { repo, message: 'Deploy' }; // DON'T pass user option - publish() will call getUser() - // Reset mock to normal behavior (previous test modified it) - mockSpawn.mockClear(); - spawnCalls = []; - - mockSpawn.mockImplementation((cmd: string, args: string[] | undefined, opts: unknown) => { - const capturedArgs = args || []; - spawnCalls.push({ cmd, args: capturedArgs, options: opts }); - - if (cmd === 'git' && capturedArgs[0]) { - if (!EXPECTED_GIT_COMMANDS.includes(capturedArgs[0])) { - throw new Error(`Unexpected git command: ${capturedArgs[0]}`); - } - } - - const mockChild = createMockChildProcess(); - - setImmediate(() => { - let output = ''; - - if (cmd === 'git' && capturedArgs[0] === 'config' && capturedArgs[1] === '--get' && - capturedArgs[2] && capturedArgs[2].startsWith('remote.')) { - output = currentTestContext.repo || ''; - } else if (cmd === 'git' && capturedArgs[0] === 'ls-remote') { - output = 'refs/heads/gh-pages'; - } else if (cmd === 'git' && capturedArgs[0] === 'diff-index') { - mockChild.emit!('close', 1); - return; - } - - mockChild.stdout!.emit('data', Buffer.from(output)); - mockChild.emit!('close', 0); - }); - - return mockChild; - }); - await ghPages.publish(basePath, options); // Verify getUser was called (our mock returns null) @@ -943,18 +827,9 @@ describe('gh-pages-fork publish() - behavioral snapshot (gh-pages v6.3.0 behavio */ describe('auth-failure regression (issue #205)', () => { it('engine.run() should reject when git clone fails with fatal: Authentication failed', async () => { - mockSpawn.mockImplementation((cmd: string, args: string[] | undefined) => { - const capturedArgs = args || []; - spawnCalls.push({ cmd, args: capturedArgs, options: undefined }); - const child = createMockChildProcess(); - setImmediate(() => { - child.stderr!.emit( - 'data', - Buffer.from("fatal: Authentication failed for 'https://github.com/owner/repo.git'") - ); - child.emit!('close', 128); - }); - return child; + spawnOverride = () => ({ + code: 128, + stderr: "fatal: Authentication failed for 'https://github.com/owner/repo.git'" }); const options = { diff --git a/src/engine/engine.gh-pages-cleanup.spec.ts b/src/engine/engine.gh-pages-cleanup.spec.ts index 41a99a8..60c8eb4 100644 --- a/src/engine/engine.gh-pages-cleanup.spec.ts +++ b/src/engine/engine.gh-pages-cleanup.spec.ts @@ -19,51 +19,21 @@ import * as path from 'path'; import * as fs from 'fs/promises'; import * as os from 'os'; -import { execSync } from 'child_process'; import { logging } from '@angular-devkit/core'; +import { createBareRepo, git, seedBranch, tree } from '../test-helpers/git'; import * as engine from './engine'; -interface GitOptions { - readonly cwd: string; -} - -function git(args: string, options: GitOptions): string { - return execSync(`git -C "${options.cwd}" ${args}`, { stdio: ['pipe', 'pipe', 'pipe'] }) - .toString() - .trim(); -} - -async function seedGhPagesBranch(workDir: string, bareRepoPath: string): Promise { - await fs.mkdir(workDir, { recursive: true }); - execSync(`git init "${workDir}"`, { stdio: 'pipe' }); - git('config user.email "seed@test.com"', { cwd: workDir }); - git('config user.name "Seed"', { cwd: workDir }); - git('checkout -b gh-pages', { cwd: workDir }); - - // Dotfiles and dot-directory - await fs.mkdir(path.join(workDir, '.github', 'workflows'), { recursive: true }); - await fs.writeFile(path.join(workDir, '.github', 'workflows', 'deploy.yml'), 'name: deploy\n'); - await fs.writeFile(path.join(workDir, '.gitignore'), 'node_modules\n'); - await fs.writeFile( - path.join(workDir, '.gitmodules'), - '[submodule "build"]\n\tpath = build\n\turl = https://example.invalid/build.git\n' - ); - // A regular non-dot file - await fs.writeFile(path.join(workDir, 'stale.html'), 'stale\n'); - - git('add .github .gitignore .gitmodules stale.html', { cwd: workDir }); - git('commit -m "seed dotfiles and stale content"', { cwd: workDir }); - - // Add a submodule gitlink without needing a real subrepo. - // update-index --cacheinfo creates the 160000 entry directly in the index. - const seedSha = git('rev-parse HEAD', { cwd: workDir }); - git(`update-index --add --cacheinfo 160000,${seedSha},build`, { cwd: workDir }); - git('commit -m "add submodule gitlink"', { cwd: workDir }); - - git(`remote add origin "${bareRepoPath}"`, { cwd: workDir }); - git('push origin gh-pages', { cwd: workDir }); +function seedGhPagesBranch(workDir: string, bareRepoPath: string): Promise { + return seedBranch(bareRepoPath, workDir, 'gh-pages', { + // Dotfiles and dot-directory + '.github/workflows/deploy.yml': 'name: deploy\n', + '.gitignore': 'node_modules\n', + '.gitmodules': '[submodule "build"]\n\tpath = build\n\turl = https://example.invalid/build.git\n', + // A regular non-dot file + 'stale.html': 'stale\n' + }, ['build']); } describe('end-to-end cleanup regression (issue #204, real git)', () => { @@ -92,8 +62,7 @@ describe('end-to-end cleanup regression (issue #204, real git)', () => { await fs.mkdir(distDir, { recursive: true }); await fs.writeFile(path.join(distDir, 'index.html'), 'fresh dist\n'); - bareRepoPath = path.join(tempDir, 'bare.git'); - execSync(`git init --bare "${bareRepoPath}"`, { stdio: 'pipe' }); + bareRepoPath = createBareRepo(path.join(tempDir, 'bare.git')); await seedGhPagesBranch(path.join(tempDir, 'seed'), bareRepoPath); @@ -123,19 +92,16 @@ describe('end-to-end cleanup regression (issue #204, real git)', () => { ); // Inspect what actually landed on gh-pages in the bare repo. - const tree = git('ls-tree -r gh-pages --name-only', { cwd: bareRepoPath }) - .split('\n') - .filter(Boolean) - .sort(); + const files = tree(bareRepoPath); - expect(tree).toEqual(['index.html']); + expect(files).toEqual(['index.html']); // Explicit negative checks β€” makes regressions very readable on failure. - expect(tree).not.toContain('.gitignore'); - expect(tree).not.toContain('.gitmodules'); - expect(tree).not.toContain('.github/workflows/deploy.yml'); - expect(tree).not.toContain('build'); - expect(tree).not.toContain('stale.html'); + expect(files).not.toContain('.gitignore'); + expect(files).not.toContain('.gitmodules'); + expect(files).not.toContain('.github/workflows/deploy.yml'); + expect(files).not.toContain('build'); + expect(files).not.toContain('stale.html'); }, 30_000); it('keeps .nojekyll and CNAME on a redeploy', async () => { @@ -159,13 +125,10 @@ describe('end-to-end cleanup regression (issue #204, real git)', () => { await fs.writeFile(path.join(distDir, 'index.html'), 'second deploy\n'); await deploy(); - const tree = git('ls-tree -r gh-pages --name-only', { cwd: bareRepoPath }) - .split('\n') - .filter(Boolean) - .sort(); + const files = tree(bareRepoPath); - expect(tree).toEqual(['.nojekyll', 'CNAME', 'index.html']); - expect(git('show gh-pages:CNAME', { cwd: bareRepoPath })).toBe('example.org'); + expect(files).toEqual(['.nojekyll', 'CNAME', 'index.html']); + expect(git(bareRepoPath, 'show', 'gh-pages:CNAME')).toBe('example.org'); }, 30_000); it('removes a leftover .nojekyll when nojekyll is disabled', async () => { @@ -187,10 +150,8 @@ describe('end-to-end cleanup regression (issue #204, real git)', () => { await deploy(true); await deploy(false); - const tree = git('ls-tree -r gh-pages --name-only', { cwd: bareRepoPath }) - .split('\n') - .filter(Boolean); + const files = tree(bareRepoPath); - expect(tree).toEqual(['index.html']); + expect(files).toEqual(['index.html']); }, 30_000); }); diff --git a/src/engine/engine.gh-pages-clone.spec.ts b/src/engine/engine.gh-pages-clone.spec.ts index 11ac5e5..4bab5e5 100644 --- a/src/engine/engine.gh-pages-clone.spec.ts +++ b/src/engine/engine.gh-pages-clone.spec.ts @@ -8,12 +8,12 @@ * deploys never share a clone */ -import { execSync } from 'child_process'; import * as fs from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; import * as ghPages from '../gh-pages-fork/lib'; +import { createBareRepo } from '../test-helpers/git'; describe('gh-pages-fork temporary clone', () => { let tempDir: string; @@ -33,8 +33,7 @@ describe('gh-pages-fork temporary clone', () => { await fs.mkdir(basePath); await fs.writeFile(path.join(basePath, 'index.html'), 'test'); - bareRepoPath = path.join(tempDir, 'bare-repo.git'); - execSync(`git init --bare "${bareRepoPath}"`, { stdio: 'pipe' }); + bareRepoPath = createBareRepo(path.join(tempDir, 'bare-repo.git')); }); afterEach(async () => { diff --git a/src/engine/engine.gh-pages-filecreation.spec.ts b/src/engine/engine.gh-pages-filecreation.spec.ts index 7159413..8315fa7 100644 --- a/src/engine/engine.gh-pages-filecreation.spec.ts +++ b/src/engine/engine.gh-pages-filecreation.spec.ts @@ -18,10 +18,10 @@ import * as path from 'path'; import * as fs from 'fs/promises'; import * as os from 'os'; -import { execSync } from 'child_process'; import * as ghPages from '../gh-pages-fork/lib'; +import { createBareRepo, git, seedBranch } from '../test-helpers/git'; describe('gh-pages-fork CNAME/.nojekyll file creation (REAL filesystem)', () => { let tempDir: string; @@ -43,22 +43,9 @@ describe('gh-pages-fork CNAME/.nojekyll file creation (REAL filesystem)', () => await fs.mkdir(basePath, { recursive: true }); await fs.writeFile(path.join(basePath, 'index.html'), 'test'); - // Create a local bare git repository that gh-pages can clone from - bareRepoPath = path.join(tempDir, 'bare-repo.git'); - execSync(`git init --bare "${bareRepoPath}"`, { stdio: 'pipe' }); - - // Initialize the gh-pages branch in the bare repo with an initial commit - const initWorkDir = path.join(tempDir, 'init-work'); - await fs.mkdir(initWorkDir, { recursive: true }); - execSync(`git init "${initWorkDir}"`, { stdio: 'pipe' }); - execSync(`git -C "${initWorkDir}" config user.email "test@test.com"`, { stdio: 'pipe' }); - execSync(`git -C "${initWorkDir}" config user.name "Test"`, { stdio: 'pipe' }); - await fs.writeFile(path.join(initWorkDir, 'README.md'), 'init'); - execSync(`git -C "${initWorkDir}" add .`, { stdio: 'pipe' }); - execSync(`git -C "${initWorkDir}" commit -m "init"`, { stdio: 'pipe' }); - execSync(`git -C "${initWorkDir}" branch gh-pages`, { stdio: 'pipe' }); - execSync(`git -C "${initWorkDir}" remote add origin "${bareRepoPath}"`, { stdio: 'pipe' }); - execSync(`git -C "${initWorkDir}" push -u origin gh-pages`, { stdio: 'pipe' }); + // Local bare repository with an existing gh-pages branch + bareRepoPath = createBareRepo(path.join(tempDir, 'bare-repo.git')); + await seedBranch(bareRepoPath, path.join(tempDir, 'init-work'), 'gh-pages', { 'README.md': 'init' }); }); afterEach(async () => { @@ -72,7 +59,7 @@ describe('gh-pages-fork CNAME/.nojekyll file creation (REAL filesystem)', () => */ function readPublished(file: string): string | null { try { - return execSync(`git --git-dir="${bareRepoPath}" show gh-pages:${file}`, { stdio: 'pipe' }).toString(); + return git(bareRepoPath, 'show', `gh-pages:${file}`); } catch { return null; } diff --git a/src/engine/engine.gh-pages-integration.spec.ts b/src/engine/engine.gh-pages-integration.spec.ts index 5e4f30d..d6a7a2f 100644 --- a/src/engine/engine.gh-pages-integration.spec.ts +++ b/src/engine/engine.gh-pages-integration.spec.ts @@ -475,6 +475,19 @@ describe('engine - gh-pages-fork integration', () => { ); }); + it('should forward publish() log messages to the logger', async () => { + const testLogger = new logging.Logger('test'); + const infoSpy = vi.spyOn(testLogger, 'info'); + const message = 'Cloning https://github.com/test/repo.git into /cache'; + ghpagesPublishSpy.mockImplementation(async (_dir, _options, log) => { + log?.(message); + }); + + await engine.run('/test/dist', { dotfiles: true, notfound: true, nojekyll: true }, testLogger); + + expect(infoSpy).toHaveBeenCalledWith(message); + }); + it('should resolve when publish() resolves', async () => { ghpagesPublishSpy.mockResolvedValue(undefined); diff --git a/src/engine/engine.gh-pages-no-package-json.spec.ts b/src/engine/engine.gh-pages-no-package-json.spec.ts index 3762870..64effe4 100644 --- a/src/engine/engine.gh-pages-no-package-json.spec.ts +++ b/src/engine/engine.gh-pages-no-package-json.spec.ts @@ -17,18 +17,12 @@ import * as path from 'path'; import * as fs from 'fs/promises'; import * as os from 'os'; -import { execSync } from 'child_process'; import { logging } from '@angular-devkit/core'; +import { createBareRepo, seedBranch, tree } from '../test-helpers/git'; import * as engine from './engine'; -function git(args: string, cwd: string): string { - return execSync(`git -C "${cwd}" ${args}`, { stdio: ['pipe', 'pipe', 'pipe'] }) - .toString() - .trim(); -} - describe('engine.run() in a directory with no package.json (issue #203)', () => { let originalCwd: string; let originalEnv: NodeJS.ProcessEnv; @@ -55,20 +49,8 @@ describe('engine.run() in a directory with no package.json (issue #203)', () => await fs.writeFile(path.join(distDir, 'index.html'), 'fresh\n'); // Bare repo + seed a gh-pages branch, so clone + checkout run the existing-branch path. - bareRepoPath = path.join(workDir, 'bare.git'); - execSync(`git init --bare "${bareRepoPath}"`, { stdio: 'pipe' }); - - const seedDir = path.join(workDir, 'seed'); - await fs.mkdir(seedDir, { recursive: true }); - execSync(`git init "${seedDir}"`, { stdio: 'pipe' }); - git('config user.email "seed@test.com"', seedDir); - git('config user.name "Seed"', seedDir); - git('checkout -b gh-pages', seedDir); - await fs.writeFile(path.join(seedDir, 'old.html'), 'old'); - git('add .', seedDir); - git('commit -m "seed"', seedDir); - git(`remote add origin "${bareRepoPath}"`, seedDir); - git('push origin gh-pages', seedDir); + bareRepoPath = createBareRepo(path.join(workDir, 'bare.git')); + await seedBranch(bareRepoPath, path.join(workDir, 'seed'), 'gh-pages', { 'old.html': 'old' }); }); afterEach(async () => { @@ -102,11 +84,7 @@ describe('engine.run() in a directory with no package.json (issue #203)', () => ); // Positive proof that the deploy actually landed: - const tree = git('ls-tree -r gh-pages --name-only', bareRepoPath) - .split('\n') - .filter(Boolean) - .sort(); - expect(tree).toEqual(['index.html']); + expect(tree(bareRepoPath)).toEqual(['index.html']); // The clone went to the tmpdir fallback const cloning = messages.find((message) => message.startsWith('Cloning ')); diff --git a/src/engine/engine.spec.ts b/src/engine/engine.spec.ts index b0edd63..80cde37 100644 --- a/src/engine/engine.spec.ts +++ b/src/engine/engine.spec.ts @@ -13,15 +13,6 @@ vi.mock('../utils', async () => { import { pathExists } from '../utils'; -vi.mock('../gh-pages-fork/lib', async () => { - const actual = await vi.importActual('../gh-pages-fork/lib'); - return { - ...actual, - publish: vi.fn() - }; -}); - -import * as ghpages from '../gh-pages-fork/lib'; describe('engine', () => { describe('prepareOptions', () => { @@ -263,51 +254,4 @@ describe('engine', () => { expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('no-silent')); }); }); - - describe('run - publish error handling', () => { - const logger = new logging.NullLogger(); - const testDir = '/test/dist'; - const options = { dotfiles: true, notfound: true, nojekyll: true }; - - beforeEach(() => { - vi.mocked(pathExists).mockResolvedValue(true); - }); - - afterEach(() => { - vi.mocked(pathExists).mockReset(); - vi.mocked(ghpages.publish).mockReset(); - }); - - it('should reject with the error of publish()', async () => { - const publishError = new Error('Git push failed: permission denied'); - vi.mocked(ghpages.publish).mockRejectedValue(publishError); - - await expect(engine.run(testDir, options, logger)).rejects.toBe(publishError); - }); - - it('should resolve when publish() resolves', async () => { - vi.mocked(ghpages.publish).mockResolvedValue(undefined); - - await expect(engine.run(testDir, options, logger)).resolves.toBeUndefined(); - }); - - it('should not publish during dry-run', async () => { - await engine.run(testDir, { ...options, dryRun: true }, logger); - - expect(ghpages.publish).not.toHaveBeenCalled(); - }); - - it('should forward publish() log messages to the logger', async () => { - const testLogger = new logging.Logger('test'); - const infoSpy = vi.spyOn(testLogger, 'info'); - const message = 'Cloning https://github.com/test/repo.git into /cache'; - vi.mocked(ghpages.publish).mockImplementation(async (_dir, _options, log) => { - log?.(message); - }); - - await engine.run(testDir, options, testLogger); - - expect(infoSpy).toHaveBeenCalledWith(message); - }); - }); }); diff --git a/src/gh-pages-fork/test/git-commands.spec.ts b/src/gh-pages-fork/test/git-commands.spec.ts index f9f5539..f619c2e 100644 --- a/src/gh-pages-fork/test/git-commands.spec.ts +++ b/src/gh-pages-fork/test/git-commands.spec.ts @@ -3,7 +3,6 @@ * child_process is mocked; every command succeeds unless configured otherwise. */ -import { EventEmitter } from 'events'; import * as fs from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; @@ -12,6 +11,7 @@ const { mockSpawn } = vi.hoisted(() => ({ mockSpawn: vi.fn() })); vi.mock('child_process', () => ({ spawn: mockSpawn })); +import { createMockChildProcess } from '../../test-helpers/git'; import { publish } from '../lib'; interface FakeResult { @@ -47,13 +47,10 @@ describe('gh-pages-fork git commands', () => { calls.push(`${cmd} ${command}`); envs.push(options.env); const result = Object.entries(results).find(([key]) => command.startsWith(key))?.[1] ?? {}; - const child = Object.assign(new EventEmitter(), { - stdout: new EventEmitter(), - stderr: new EventEmitter() - }); + const child = createMockChildProcess(); setImmediate(() => { if (result.output) { - child.stdout.emit('data', Buffer.from(result.output)); + child.stdout!.emit('data', Buffer.from(result.output)); } child.emit('close', result.code ?? 0); }); @@ -83,7 +80,7 @@ describe('gh-pages-fork git commands', () => { const cloneDir = cloneDirOf(calls[0]); expect(calls).toEqual([ - `git clone ${repo} ${cloneDir} --branch gh-pages --single-branch --origin origin --depth 1`, + `git clone ${repo} ${cloneDir} --origin origin --branch gh-pages --single-branch --depth 1`, 'git ls-remote --exit-code . origin/gh-pages', 'git checkout gh-pages', 'git clean -f -d', @@ -115,8 +112,8 @@ describe('gh-pages-fork git commands', () => { const cloneDir = cloneDirOf(calls[0]); expect(calls.slice(0, 7)).toEqual([ - `git clone ${repo} ${cloneDir} --branch gh-pages --single-branch --origin origin --depth 1`, - `git clone ${repo} ${cloneDir} --origin origin`, + `git clone ${repo} ${cloneDir} --origin origin --branch gh-pages --single-branch --depth 1`, + `git clone ${repo} ${cloneDir} --origin origin --no-checkout --depth 1`, 'git ls-remote --exit-code . origin/gh-pages', 'git checkout --orphan gh-pages', // the orphan branch starts empty (the cloned default branch's files are removed) @@ -126,6 +123,29 @@ describe('gh-pages-fork git commands', () => { ]); }); + it('falls back to a full clone when the server rejects a shallow clone', async () => { + results['ls-remote'] = { code: 2 }; + mockSpawn.mockImplementation(((defaultImplementation) => + (cmd: string, args: string[], options: { cwd: string; env: NodeJS.ProcessEnv }) => { + if (args[0] === 'clone' && args.includes('--depth')) { + calls.push(`${cmd} ${args.join(' ')}`); + const child = createMockChildProcess(); + setImmediate(() => child.emit('close', 128)); + return child; + } + return defaultImplementation(cmd, args, options); + })(mockSpawn.getMockImplementation()!)); + + await publish(dist, { repo, user: { name: 'Bot', email: 'bot@example.org' } }); + + const cloneDir = cloneDirOf(calls[0]); + expect(calls.slice(0, 3)).toEqual([ + `git clone ${repo} ${cloneDir} --origin origin --branch gh-pages --single-branch --depth 1`, + `git clone ${repo} ${cloneDir} --origin origin --no-checkout --depth 1`, + `git clone ${repo} ${cloneDir} --origin origin --no-checkout` + ]); + }); + it('starts an orphan branch empty even with add', async () => { results['clone ' + repo + ' '] = { code: 128 }; results['ls-remote'] = { code: 2 }; diff --git a/src/gh-pages-fork/test/publish.spec.ts b/src/gh-pages-fork/test/publish.spec.ts index f057970..8f4fd97 100644 --- a/src/gh-pages-fork/test/publish.spec.ts +++ b/src/gh-pages-fork/test/publish.spec.ts @@ -2,27 +2,15 @@ * publish() against real git repositories (local bare repos as remotes). */ -import { execFileSync } from 'child_process'; import * as fs from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; +import { commitCount, createBareRepo, git, seedBranch, tree } from '../../test-helpers/git'; import { getCacheDir, publish, PublishOptions } from '../lib'; const user = { name: 'Deploy Bot', email: 'bot@example.org' }; -function git(cwd: string, ...args: string[]): string { - return execFileSync('git', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim(); -} - -function tree(remote: string, branch = 'gh-pages'): string[] { - return git(remote, 'ls-tree', '-r', '--name-only', branch).split('\n').filter(Boolean).sort(); -} - -function commitCount(remote: string, branch = 'gh-pages'): number { - return Number(git(remote, 'rev-list', '--count', branch)); -} - describe('gh-pages-fork publish()', () => { let tempDir: string; let remote: string; @@ -38,8 +26,7 @@ describe('gh-pages-fork publish()', () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gh-pages-fork-')); process.env.CACHE_DIR = path.join(tempDir, 'cache'); - remote = path.join(tempDir, 'remote.git'); - git(tempDir, 'init', '--bare', remote); + remote = createBareRepo(path.join(tempDir, 'remote.git')); dist = path.join(tempDir, 'dist'); await fs.mkdir(path.join(dist, 'assets'), { recursive: true }); @@ -53,20 +40,12 @@ describe('gh-pages-fork publish()', () => { await fs.rm(tempDir, { recursive: true, force: true }); }); - async function seedBranch(): Promise { - const seed = path.join(tempDir, 'seed'); - git(tempDir, 'init', '-b', 'gh-pages', seed); - git(seed, 'config', 'user.name', 'Seed'); - git(seed, 'config', 'user.email', 'seed@example.org'); - await fs.mkdir(path.join(seed, '.github', 'workflows'), { recursive: true }); - await fs.writeFile(path.join(seed, '.github', 'workflows', 'deploy.yml'), 'name: deploy'); - await fs.writeFile(path.join(seed, '.gitignore'), 'node_modules'); - await fs.writeFile(path.join(seed, 'stale.html'), 'stale'); - git(seed, 'add', '.'); - git(seed, 'commit', '-m', 'seed'); - git(seed, 'update-index', '--add', '--cacheinfo', `160000,${git(seed, 'rev-parse', 'HEAD')},build`); - git(seed, 'commit', '-m', 'gitlink'); - git(seed, 'push', remote, 'gh-pages'); + function seedGhPagesBranch(): Promise { + return seedBranch(remote, path.join(tempDir, 'seed'), 'gh-pages', { + '.github/workflows/deploy.yml': 'name: deploy', + '.gitignore': 'node_modules', + 'stale.html': 'stale' + }, ['build']); } it('creates the branch on the first deploy', async () => { @@ -77,7 +56,7 @@ describe('gh-pages-fork publish()', () => { }); it('replaces the whole branch content, including dotfiles and submodule gitlinks', async () => { - await seedBranch(); + await seedGhPagesBranch(); await deploy(); @@ -104,7 +83,7 @@ describe('gh-pages-fork publish()', () => { }); it('keeps existing files with add', async () => { - await seedBranch(); + await seedGhPagesBranch(); await deploy({ add: true }); @@ -186,16 +165,9 @@ describe('gh-pages-fork publish()', () => { }); it('removes a submodule gitlink that sorts before .gitmodules', async () => { - const seed = path.join(tempDir, 'seed-gitmodules'); - git(tempDir, 'init', '-b', 'gh-pages', seed); - git(seed, 'config', 'user.name', 'Seed'); - git(seed, 'config', 'user.email', 'seed@example.org'); - await fs.writeFile(path.join(seed, '.gitmodules'), '[submodule "shared"]\n\tpath = .github/actions/shared\n\turl = https://example.invalid/shared.git\n'); - git(seed, 'add', '.'); - git(seed, 'commit', '-m', 'seed'); - git(seed, 'update-index', '--add', '--cacheinfo', `160000,${git(seed, 'rev-parse', 'HEAD')},.github/actions/shared`); - git(seed, 'commit', '-m', 'gitlink'); - git(seed, 'push', remote, 'gh-pages'); + await seedBranch(remote, path.join(tempDir, 'seed-gitmodules'), 'gh-pages', { + '.gitmodules': '[submodule "shared"]\n\tpath = .github/actions/shared\n\turl = https://example.invalid/shared.git\n' + }, ['.github/actions/shared']); await deploy(); @@ -203,14 +175,7 @@ describe('gh-pages-fork publish()', () => { }); it('never publishes the default branch on the first deploy with add', async () => { - const source = path.join(tempDir, 'source'); - git(tempDir, 'init', '-b', 'main', source); - git(source, 'config', 'user.name', 'Dev'); - git(source, 'config', 'user.email', 'dev@example.org'); - await fs.writeFile(path.join(source, 'environment.ts'), 'apiKey=secret'); - git(source, 'add', '.'); - git(source, 'commit', '-m', 'source'); - git(source, 'push', remote, 'main'); + await seedBranch(remote, path.join(tempDir, 'source'), 'main', { 'environment.ts': 'apiKey=secret' }); await deploy({ add: true }); @@ -296,4 +261,22 @@ describe('gh-pages-fork publish()', () => { expect(git(remote, 'log', '-1', '--format=%an <%ae>', 'gh-pages')).toBe('Local User '); }); + + it('clones into the os temp directory when the cache directory is not writable', async () => { + const readOnly = path.join(tempDir, 'read-only'); + await fs.mkdir(readOnly, { mode: 0o500 }); + process.env.CACHE_DIR = readOnly; + const messages: string[] = []; + + try { + await deploy({}, (message) => messages.push(message)); + } finally { + await fs.chmod(readOnly, 0o700); + } + + const cloneDir = messages.find((message) => message.startsWith('Cloning '))!.split(' into ')[1]; + expect(path.dirname(cloneDir)).toBe(os.tmpdir()); + expect(path.basename(cloneDir)).toMatch(/^angular-cli-ghpages-clone-/); + expect(tree(remote)).toEqual(['assets/app.js', 'index.html']); + }); }); diff --git a/src/test-helpers/git.ts b/src/test-helpers/git.ts new file mode 100644 index 0000000..1141e52 --- /dev/null +++ b/src/test-helpers/git.ts @@ -0,0 +1,75 @@ +/** + * Shared helpers for specs that run real git commands or mock child processes. + * Not part of the published package (excluded in tsconfig.build.json). + */ + +import { ChildProcess, execFileSync } from 'child_process'; +import { EventEmitter } from 'events'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +/** + * Run git in `cwd` and return its trimmed stdout. + */ +export function git(cwd: string, ...args: string[]): string { + return execFileSync('git', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim(); +} + +/** + * Sorted list of all files on a branch. + */ +export function tree(repo: string, branch = 'gh-pages'): string[] { + return git(repo, 'ls-tree', '-r', '--name-only', branch).split('\n').filter(Boolean).sort(); +} + +export function commitCount(repo: string, branch = 'gh-pages'): number { + return Number(git(repo, 'rev-list', '--count', branch)); +} + +/** + * Create a bare repository that serves as the remote. + */ +export function createBareRepo(dir: string): string { + git(path.dirname(dir), 'init', '--bare', dir); + return dir; +} + +/** + * Push a branch with the given files (and optional submodule gitlinks) to `remote`. + */ +export async function seedBranch( + remote: string, + workDir: string, + branch: string, + files: Record, + gitlinks: string[] = [] +): Promise { + await fs.mkdir(workDir, { recursive: true }); + git(workDir, 'init', '-b', branch); + git(workDir, 'config', 'user.name', 'Seed'); + git(workDir, 'config', 'user.email', 'seed@example.org'); + for (const [file, content] of Object.entries(files)) { + await fs.mkdir(path.dirname(path.join(workDir, file)), { recursive: true }); + await fs.writeFile(path.join(workDir, file), content); + } + git(workDir, 'add', '.'); + git(workDir, 'commit', '--allow-empty', '-m', 'seed'); + for (const gitlink of gitlinks) { + // update-index --cacheinfo creates a 160000 (submodule) entry without a real subrepo + git(workDir, 'update-index', '--add', '--cacheinfo', `160000,${git(workDir, 'rev-parse', 'HEAD')},${gitlink}`); + } + if (gitlinks.length > 0) { + git(workDir, 'commit', '-m', 'gitlinks'); + } + git(workDir, 'push', remote, branch); +} + +/** + * Child process stand-in with what gh-pages-fork lib/git.ts uses: stdout/stderr 'data', 'close'. + */ +export function createMockChildProcess(): ChildProcess { + return Object.assign(new EventEmitter(), { + stdout: new EventEmitter(), + stderr: new EventEmitter() + }) as unknown as ChildProcess; +} diff --git a/src/tsconfig.build.json b/src/tsconfig.build.json index 0b245b3..5909ead 100644 --- a/src/tsconfig.build.json +++ b/src/tsconfig.build.json @@ -4,5 +4,5 @@ "declaration": true, "types": ["node"] }, - "exclude": ["node_modules", "dist", "**/*spec.ts", "vitest.config.mts"] + "exclude": ["node_modules", "dist", "**/*spec.ts", "test-helpers", "vitest.config.mts"] } From 84b7b0dc1f63507cde0877c83c064bf43ac3eea3 Mon Sep 17 00:00:00 2001 From: Johannes Hoppe Date: Mon, 12 Oct 2026 13:15:23 +0200 Subject: [PATCH 10/17] docs: describe the clone fallbacks of gh-pages-fork --- src/gh-pages-fork/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/gh-pages-fork/README.md b/src/gh-pages-fork/README.md index 9fbc9ce..118c849 100644 --- a/src/gh-pages-fork/README.md +++ b/src/gh-pages-fork/README.md @@ -52,13 +52,13 @@ The file structure and function names follow upstream, so upstream changes can b | Topic | Upstream | Fork | | ----------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | | Clone | one cached clone per repository URL, reused across runs, `gh-pages-clean` to reset it | a fresh temporary clone per `publish()` below the cache directory, removed afterwards; concurrent deploys never share a clone, and no token stays in `.git/config` | -| Cache directory | `find-cache-dir` | same lookup (`$CACHE_DIR`, closest `package.json`) plus an os temp dir fallback | +| Cache directory | `find-cache-dir` | same lookup (`$CACHE_DIR`, closest `package.json`) plus an os temp dir fallback, also used when the cache directory isn't writable | | Repository check | "Remote url mismatch" check against the cached clone | none (every clone is fresh), so relative repository paths work | | Git environment | inherited | `GIT_CEILING_DIRECTORIES` for all commands in the clone, so git never falls through to a repository above it; `-c credential.helper=` when the URL carries credentials, so no credential helper stores them | | Process results | resolves on a falsy exit code (also for a process killed by a signal); stdout and stderr in one buffer | rejects unless the exit code is 0 (a killed process rejects); `output` is stdout only, stderr only goes into error messages | | Logging | `util.debuglog('gh-pages')` | `log` callback parameter of `publish()`; credentials in the repository URL are replaced with `***` | | Removing files | glob `remove` pattern, then `git rm` in batches | `removeAll()`: `git rm` of `.gitmodules`, then of `.` (all tracked files, including dotfiles and submodule gitlinks) | -| New branch | `checkout --orphan` keeps the files of the cloned default branch (they get published with `add`) | the orphan branch starts empty | +| New branch | full clone of the default branch; `checkout --orphan` keeps its files (they get published with `add`) | shallow clone of the default branch without a checkout (full clone as the last resort); the orphan branch starts empty | | `checkout()` | `main` only runs `ls-remote` for an existing branch | `checkout`, `clean -f -d`, `reset --hard remote/branch` (the behavior of v6.3.0) | | File list | glob `src` pattern via tinyglobby | `listFiles()`: all files below `basePath`, follows symlinks, skips broken links and cycles, honors `dotfiles` | | Copying | `fs-extra` copy (keeps the file mode) | streams, files created with mode 0666 minus umask (no executable bit, like gh-pages v6.3.0); limited concurrency | From 7be60527c64da0c9fb060bc26dd7d99b78583e47 Mon Sep 17 00:00:00 2001 From: Johannes Hoppe Date: Mon, 12 Oct 2026 21:39:04 +0200 Subject: [PATCH 11/17] fix: prepare the deploy branch without checkout and isolate git from the user's repository - git ls-remote decides whether the branch exists: an existing branch is cloned (shallow, then in full), a new branch starts as an empty repository. A transient error on the first clone no longer turns an existing branch into an orphan, hard failures report the real git error after one attempt, and the default branch is never checked out - the branch is cloned without a checkout and staged with git add --force --all (--ignore-removal plus read-tree HEAD with --add): no git rm, so .gitmodules, ignore rules (global gitignore, .gitignore in dist) and symlinks on the branch can no longer drop or redirect files - commands for the clone run with GIT_DIR/GIT_WORK_TREE pinned to it, without inherited repository variables (hooks, aliases, worktrees) and with safe.directory for it - credential helpers are only disabled for URLs with a password; a URL with only a username (Bitbucket, Azure DevOps) keeps using them - the token is removed from the clone's remote config right after cloning and only passed to push on the command line - a failing cleanup of the temporary clone is logged instead of replacing the deploy result - stale clones of interrupted deploys and clones of older versions are removed before each deploy; without a cache directory, clones go to a private directory in the os temp dir - relative repository paths are resolved before cloning (from git config: against the repository top level) - commits skip hooks (--no-verify); signal kills keep git's output in the error; getRemoteUrl reports real git errors - listFiles skips .git and uses the JavaScript realpath - shared spawn mock and git helpers for the specs --- src/engine/engine.gh-pages-behavior.spec.ts | 868 +++++------------- src/engine/engine.gh-pages-clone.spec.ts | 2 +- .../engine.gh-pages-no-package-json.spec.ts | 5 +- src/gh-pages-fork/lib/git.ts | 176 ++-- src/gh-pages-fork/lib/index.ts | 178 +++- src/gh-pages-fork/lib/util.ts | 20 +- src/gh-pages-fork/test/cache-dir.spec.ts | 4 +- src/gh-pages-fork/test/git-commands.spec.ts | 267 +++--- src/gh-pages-fork/test/publish.spec.ts | 11 +- src/test-helpers/git.ts | 53 ++ 10 files changed, 669 insertions(+), 915 deletions(-) diff --git a/src/engine/engine.gh-pages-behavior.spec.ts b/src/engine/engine.gh-pages-behavior.spec.ts index 7e78b3c..ada991f 100644 --- a/src/engine/engine.gh-pages-behavior.spec.ts +++ b/src/engine/engine.gh-pages-behavior.spec.ts @@ -2,45 +2,20 @@ * Behavioral snapshot tests for gh-pages-fork publish() * * These tests pin the git commands that publish() executes, in order and with - * their arguments, to the behavior of gh-pages v6.3.0, the base of the fork. - * Any change to the fork that alters this behavior fails here. + * their arguments, plus the files it copies. Any change to the fork that + * alters this behavior fails here. * * Approach: Mock child_process and gh-pages-fork/lib/util (copy, getUser), use real filesystem */ -import { createMockChildProcess } from '../test-helpers/git'; -import * as engine from './engine'; -import { logging } from '@angular-devkit/core'; - -const path = require('path'); -const fs = require('fs/promises'); -const os = require('os'); - -// Mock utils.pathExists so engine.run() passes the dist folder check -vi.mock('../utils', async () => { - const actual = await vi.importActual('../utils'); - return { - ...actual, - pathExists: vi.fn().mockResolvedValue(true) - }; -}); - -// Types for better type safety -interface SpawnCall { - cmd: string; - args: string[]; - options: unknown; -} +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; -interface TestContext { - repo?: string; -} - -// Mock child_process to capture all git commands -let spawnCalls: SpawnCall[] = []; +import { logging } from '@angular-devkit/core'; -// Track current test context for deterministic mock behavior -let currentTestContext: TestContext = {}; +import { FakeResult, fakeSpawn, gitSubcommand, SpawnCall, withoutConfigArgs } from '../test-helpers/git'; +import * as engine from './engine'; /** * Whitelist of git subcommands that publish() may run. @@ -49,25 +24,15 @@ let currentTestContext: TestContext = {}; * so it can't slip in unnoticed. */ const EXPECTED_GIT_COMMANDS = [ - 'clone', 'clean', 'fetch', 'checkout', 'ls-remote', 'reset', - 'rm', 'add', 'config', 'diff-index', 'commit', 'push' + 'ls-remote', 'clone', 'init', 'remote', 'symbolic-ref', 'read-tree', + 'add', 'config', 'diff-index', 'commit', 'push' ]; -// Git subcommand of an argument list, skipping leading `-c key=value` pairs -function gitSubcommand(args: string[]): string | undefined { - let i = 0; - while (args[i] === '-c') { - i += 2; - } - return args[i]; -} - -const { mockSpawn, mockCopy, mockGetUser } = vi.hoisted(() => { - const mockCopy = vi.fn(() => Promise.resolve()); - const mockGetUser = vi.fn(() => Promise.resolve(null)); - const mockSpawn = vi.fn(); - return { mockSpawn, mockCopy, mockGetUser }; -}); +const { mockSpawn, mockCopy, mockGetUser } = vi.hoisted(() => ({ + mockSpawn: vi.fn(), + mockCopy: vi.fn(() => Promise.resolve()), + mockGetUser: vi.fn(() => Promise.resolve(null)) +})); vi.mock('child_process', () => ({ spawn: mockSpawn @@ -79,70 +44,41 @@ vi.mock('../gh-pages-fork/lib/util', async () => ({ getUser: mockGetUser })); -interface SpawnResult { - code?: number; - output?: string; - stderr?: string; -} - -// Per-test override: return a result to replace the default response for a git call -let spawnOverride: ((args: string[]) => SpawnResult | undefined) | undefined; - -function defaultResult(args: string[]): SpawnResult { - // git config --get remote.X.url returns the repo URL of the current test - if (args[0] === 'config' && args[1] === '--get' && args[2]?.startsWith('remote.')) { - return { output: currentTestContext.repo || '' }; - } - // the branch exists on the remote - if (args[0] === 'ls-remote') { - return { output: 'refs/heads/gh-pages' }; - } - // exit code 1: there are changes to commit - if (args[0] === 'diff-index') { - return { code: 1 }; - } - return {}; -} - -function defaultSpawn(cmd: string, args: string[] | undefined, opts: unknown) { - const capturedArgs = args || []; - spawnCalls.push({ cmd, args: capturedArgs, options: opts }); +import * as ghPages from '../gh-pages-fork/lib'; - // Validate git commands are expected - const subcommand = gitSubcommand(capturedArgs); - if (cmd === 'git' && subcommand && !EXPECTED_GIT_COMMANDS.includes(subcommand)) { - throw new Error(`Unexpected git command: ${subcommand}. Add to whitelist if intentional.`); - } +describe('gh-pages-fork publish() - behavioral snapshot', () => { + let tempDir: string; + let basePath: string; + let spawnCalls: SpawnCall[]; + let originalEnv: NodeJS.ProcessEnv; + // Per-test override: return a result to replace the default response for a git call + let spawnOverride: ((args: string[]) => FakeResult | undefined) | undefined; - const result = spawnOverride?.(capturedArgs) ?? defaultResult(capturedArgs); - const child = createMockChildProcess(); - setImmediate(() => { - if (result.stderr) { - child.stderr!.emit('data', Buffer.from(result.stderr)); + function respond(call: SpawnCall): FakeResult | undefined { + const subcommand = gitSubcommand(call.args); + if (call.cmd === 'git' && subcommand && !EXPECTED_GIT_COMMANDS.includes(subcommand)) { + throw new Error(`Unexpected git command: ${subcommand}. Add to whitelist if intentional.`); } - if (result.output) { - child.stdout!.emit('data', Buffer.from(result.output)); + const args = withoutConfigArgs(call.args); + const override = spawnOverride?.(args); + if (override) { + return override; } - child.emit('close', result.code ?? 0); - }); - return child; -} - -import * as ghPages from '../gh-pages-fork/lib'; + // exit code 1: there are changes to commit + return args[0] === 'diff-index' ? { code: 1 } : undefined; + } -async function publishAndHandle( - basePath: string, - options: ghPages.PublishOptions, - assertions: () => void -): Promise { - await ghPages.publish(basePath, options); - assertions(); -} + // Git calls by subcommand, without the leading `-c` pairs + function gitCalls(subcommand?: string): string[][] { + return spawnCalls + .filter((call) => call.cmd === 'git') + .map((call) => withoutConfigArgs(call.args)) + .filter((args) => !subcommand || args[0] === subcommand); + } -describe('gh-pages-fork publish() - behavioral snapshot (gh-pages v6.3.0 behavior)', () => { - let tempDir: string; - let basePath: string; - let originalEnv: NodeJS.ProcessEnv; + function gitCommands(): string[] { + return gitCalls().map((args) => args[0]); + } beforeAll(async () => { // Create a real temp directory with test files @@ -158,89 +94,57 @@ describe('gh-pages-fork publish() - behavioral snapshot (gh-pages v6.3.0 behavio }); afterAll(async () => { - // Clean up temp directory - // force: true matches fse.remove() behavior (no error if path doesn't exist) await fs.rm(tempDir, { recursive: true, force: true }); }); - afterEach(() => { - process.env = originalEnv; - }); - beforeEach(() => { - // Note: We DON'T call ghPages.clean() here because it interferes with test isolation - // Each test uses a unique repo URL which naturally isolates cache directories - - // Clear all mock calls and context spawnCalls = []; - currentTestContext = {}; - mockSpawn.mockReset(); - mockSpawn.mockImplementation(defaultSpawn); spawnOverride = undefined; + mockSpawn.mockReset(); + mockSpawn.mockImplementation(fakeSpawn(spawnCalls, respond)); + mockCopy.mockClear(); + mockGetUser.mockClear(); - // An injected token would make the repo URL differ from currentTestContext.repo + // Isolated clone directory; no injected token, no CI metadata originalEnv = { ...process.env }; + process.env.CACHE_DIR = path.join(tempDir, 'cache'); delete process.env.GH_TOKEN; delete process.env.PERSONAL_TOKEN; delete process.env.GITHUB_TOKEN; delete process.env.TRAVIS; delete process.env.CIRCLECI; delete process.env.GITHUB_ACTIONS; - mockCopy.mockClear(); - mockGetUser.mockClear(); + }); + + afterEach(() => { + process.env = originalEnv; }); describe('Git command execution order', () => { it('should execute critical git commands in sequence', async () => { const repo = 'https://github.com/test/order-test.git'; - const branch = 'gh-pages'; - currentTestContext.repo = repo; - - const options = { repo, branch, message: 'Deploy' }; - - await publishAndHandle(basePath, options, () => { - // Extract git commands in order - const gitCommands = spawnCalls - .filter(call => call.cmd === 'git') - .map(call => call.args[0]); - - // Verify critical commands appear - expect(gitCommands).toContain('clone'); - expect(gitCommands).toContain('add'); - expect(gitCommands).toContain('commit'); - expect(gitCommands).toContain('push'); - - // Verify critical ordering constraints (these MUST be in order) - const cloneIndex = gitCommands.indexOf('clone'); - const addIndex = gitCommands.indexOf('add'); - const commitIndex = gitCommands.indexOf('commit'); - const pushIndex = gitCommands.indexOf('push'); - - expect(cloneIndex).toBeGreaterThan(-1); - expect(addIndex).toBeGreaterThan(cloneIndex); // add after clone - expect(commitIndex).toBeGreaterThan(addIndex); // commit after add - expect(pushIndex).toBeGreaterThan(commitIndex); // push after commit - }); + + await ghPages.publish(basePath, { repo, branch: 'gh-pages', message: 'Deploy' }); + + const commands = gitCommands(); + const cloneIndex = commands.indexOf('clone'); + const addIndex = commands.indexOf('add'); + const commitIndex = commands.indexOf('commit'); + const pushIndex = commands.indexOf('push'); + + expect(cloneIndex).toBeGreaterThan(-1); + expect(addIndex).toBeGreaterThan(cloneIndex); // add after clone + expect(commitIndex).toBeGreaterThan(addIndex); // commit after add + expect(pushIndex).toBeGreaterThan(commitIndex); // push after commit }); - it('should execute ls-remote before checkout', async () => { + it('should check the branch with ls-remote before cloning', async () => { const repo = 'https://github.com/test/ls-remote-test.git'; - currentTestContext.repo = repo; - const options = { repo, message: 'Deploy' }; - - await publishAndHandle(basePath, options, () => { - const gitCommands = spawnCalls - .filter(call => call.cmd === 'git') - .map(call => call.args[0]); - - const lsRemoteIndex = gitCommands.indexOf('ls-remote'); - const checkoutIndex = gitCommands.indexOf('checkout'); - - // ls-remote checks if branch exists before checkout - expect(lsRemoteIndex).toBeGreaterThan(-1); - expect(checkoutIndex).toBeGreaterThan(-1); - expect(lsRemoteIndex).toBeLessThan(checkoutIndex); - }); + + await ghPages.publish(basePath, { repo, message: 'Deploy' }); + + expect(gitCalls()[0]).toEqual(['ls-remote', '--exit-code', '--heads', repo, 'refs/heads/gh-pages']); + expect(gitCommands()[1]).toBe('clone'); }); }); @@ -248,102 +152,61 @@ describe('gh-pages-fork publish() - behavioral snapshot (gh-pages v6.3.0 behavio it('should clone with exact repository URL', async () => { const repo = 'https://github.com/angular-schule/test-repo.git'; const branch = 'production'; - currentTestContext.repo = repo; - const options = { repo, branch, message: 'Deploy' }; - - await publishAndHandle(basePath, options, () => { - const cloneCall = spawnCalls.find(call => - call.cmd === 'git' && call.args[0] === 'clone' - ); - - expect(cloneCall).toBeDefined(); - expect(cloneCall?.args).toContain(repo); - expect(cloneCall?.args).toContain('--branch'); - expect(cloneCall?.args).toContain(branch); - expect(cloneCall?.args).toContain('--single-branch'); - }); + + await ghPages.publish(basePath, { repo, branch, message: 'Deploy' }); + + const cloneArgs = gitCalls('clone')[0]; + expect(cloneArgs[1]).toBe(repo); + expect(cloneArgs[cloneArgs.indexOf('--branch') + 1]).toBe(branch); + expect(cloneArgs).toContain('--single-branch'); + // the files of the branch are never checked out, the new content replaces them + expect(cloneArgs).toContain('--no-checkout'); }); /** * Git clone depth controls shallow clone behavior. * - * What is depth? - * - `--depth N` limits git clone history to N commits - * - publish() defaults to depth=1 (only latest commit, optimal for deployments) - * - Smaller depth = faster clones, less disk space, faster CI/CD - * - * Why angular-cli-ghpages does NOT expose this option: - * - We're deploying build artifacts (compiled Angular app) - * - We only care about the latest built version - * - History is in the gh-pages branch itself - * - depth=1 is perfect - no benefit to cloning more history - * - * What we're testing: - * - That publish() uses its default depth=1 - * - Exact argument position and value - * - This ensures optimal performance for all deployments + * - `--depth 1` clones only the latest commit of the branch + * - We're deploying build artifacts, the history is in the branch itself + * - angular-cli-ghpages doesn't expose this option */ it('should use default depth=1 for optimal performance', async () => { const repo = 'https://github.com/test/depth-test.git'; - const branch = 'gh-pages'; - currentTestContext.repo = repo; - - // Don't specify depth - rely on the default - const options = { repo, branch, message: 'Deploy' }; - - await publishAndHandle(basePath, options, () => { - const cloneCall = spawnCalls.find(call => - call.cmd === 'git' && call.args[0] === 'clone' - ); - expect(cloneCall).toBeDefined(); - - // Find --depth flag and verify next argument is exactly 1 - const depthFlagIndex = cloneCall?.args.indexOf('--depth'); - expect(depthFlagIndex).toBeGreaterThan(-1); - expect(cloneCall?.args[depthFlagIndex! + 1]).toBe('1'); - }); + + await ghPages.publish(basePath, { repo, branch: 'gh-pages', message: 'Deploy' }); + + const cloneArgs = gitCalls('clone')[0]; + const depthFlagIndex = cloneArgs.indexOf('--depth'); + expect(depthFlagIndex).toBeGreaterThan(-1); + expect(cloneArgs[depthFlagIndex + 1]).toBe('1'); }); }); describe('Git add and commit', () => { it('should add all files with dot notation', async () => { const repo = 'https://github.com/test/add-test.git'; - currentTestContext.repo = repo; - const options = { repo, message: 'Deploy' }; - await publishAndHandle(basePath, options, () => { - const addCall = spawnCalls.find(call => - call.cmd === 'git' && call.args[0] === 'add' - ); + await ghPages.publish(basePath, { repo, message: 'Deploy' }); - expect(addCall).toBeDefined(); - expect(addCall?.args[1]).toBe('.'); // Exact position check - }); + // --force: ignore rules (global gitignore, .gitignore in dist) must not drop files + expect(gitCalls('add')).toEqual([['add', '--force', '--all', '--', '.']]); }); it('should commit with exact message provided', async () => { const repo = 'https://github.com/test/commit-test.git'; const message = 'Custom deployment message with special chars: Γ©mojis πŸš€'; - currentTestContext.repo = repo; - const options = { repo, message }; - - await publishAndHandle(basePath, options, () => { - const commitCall = spawnCalls.find(call => - call.cmd === 'git' && call.args[0] === 'commit' - ); - - expect(commitCall).toBeDefined(); - expect(commitCall?.args).toContain('-m'); - const messageIndex = commitCall?.args.indexOf('-m'); - expect(commitCall?.args[messageIndex! + 1]).toBe(message); - }); + + await ghPages.publish(basePath, { repo, message }); + + const commitArgs = gitCalls('commit')[0]; + expect(commitArgs[commitArgs.indexOf('-m') + 1]).toBe(message); + // hooks of the user don't apply to deploy commits + expect(commitArgs).toContain('--no-verify'); }); }); /** * Git push command - * - * What we're testing: * - Push command includes correct remote and branch * - Push ALWAYS includes the --tags flag * - NO --force flag: every deploy adds a commit to the branch history @@ -353,542 +216,263 @@ describe('gh-pages-fork publish() - behavioral snapshot (gh-pages v6.3.0 behavio const repo = 'https://github.com/test/push-test.git'; const branch = 'gh-pages'; const remote = 'upstream'; - currentTestContext.repo = repo; - const options = { repo, branch, remote, message: 'Deploy' }; - - await publishAndHandle(basePath, options, () => { - const pushCall = spawnCalls.find(call => - call.cmd === 'git' && call.args[0] === 'push' - ); - - expect(pushCall).toBeDefined(); - expect(pushCall?.args).toContain(remote); - expect(pushCall?.args).toContain(branch); - expect(pushCall?.args).toContain('--tags'); - }); + + await ghPages.publish(basePath, { repo, branch, remote, message: 'Deploy' }); + + expect(gitCalls('push')).toEqual([['push', '--tags', remote, branch]]); }); it('should NOT use force push', async () => { const repo = 'https://github.com/test/no-force-test.git'; - currentTestContext.repo = repo; - const options = { repo, message: 'Deploy' }; - await publishAndHandle(basePath, options, () => { - const pushCall = spawnCalls.find(call => - call.cmd === 'git' && call.args[0] === 'push' - ); + await ghPages.publish(basePath, { repo, message: 'Deploy' }); - expect(pushCall).toBeDefined(); - expect(pushCall?.args).not.toContain('--force'); - }); + expect(gitCalls('push')[0]).not.toContain('--force'); }); }); describe('File copy operation', () => { - it('should copy exact test files from basePath to cache destination', async () => { + it('should copy exact test files from basePath to the temporary clone', async () => { const repo = 'https://github.com/test/copy-test.git'; - currentTestContext.repo = repo; - const options = { repo, message: 'Deploy' }; - - await publishAndHandle(basePath, options, () => { - expect(mockCopy).toHaveBeenCalledTimes(1); - - const callArgs = mockCopy.mock.calls[0]; - expect(callArgs).toBeDefined(); - expect(callArgs.length).toBe(3); - const [files, source, destination] = callArgs as unknown as [string[], string, string]; + await ghPages.publish(basePath, { repo, message: 'Deploy', dotfiles: false }); - // Verify files array contains EXACTLY our test files - expect(Array.isArray(files)).toBe(true); - expect(files.length).toBe(3); - expect(files).toContain('index.html'); - expect(files).toContain('main.js'); - expect(files).toContain('styles.css'); - - // Verify source is our basePath - expect(source).toBe(basePath); - - // Verify destination is the temporary clone in the cache directory - expect(destination).toContain('gh-pages'); - }); + expect(mockCopy).toHaveBeenCalledTimes(1); + const [files, source, destination] = mockCopy.mock.calls[0] as unknown as [string[], string, string]; + expect([...files].sort()).toEqual(['index.html', 'main.js', 'styles.css']); + expect(source).toBe(basePath); + expect(path.dirname(destination)).toBe(ghPages.getCacheDir()); + expect(path.basename(destination)).toMatch(/^clone-/); }); }); /** - * Git rm behavior with add option - * - * What is the add option? - * - add: false (default) - Removes existing files before deploying new ones (clean slate) - * - add: true - Adds files without removing existing ones (incremental deployment) - * - * Why this matters: - * - add: false ensures no stale files remain (recommended for most cases) - * - add: true useful for multi-source deployments or preserving manually added files + * Staging with the add option * - * publish() removes all tracked files after the checkout (`git rm` of - * `.gitmodules`, then of `.`), unless `add` is set. - * - * What we're testing: - * - With add: false, the removal runs between checkout and add - * - With add: true, git rm is NEVER called on an existing branch + * - add: false (default) - the staged files become the complete content of + * the branch (`add --all` into an empty index), so no stale files remain + * - add: true - the index starts with the tree of the branch (`read-tree HEAD`) + * and files missing from dist stay (`--ignore-removal`) + * - git rm is never needed: nothing of the branch is checked out */ - describe('Git rm command behavior', () => { - it('should maintain standard command order with add: false (default)', async () => { + describe('Staging behavior', () => { + it('should stage the complete new content with add: false (default)', async () => { const repo = 'https://github.com/test/rm-default-test.git'; - currentTestContext.repo = repo; - const options = { repo, message: 'Deploy', add: false }; - - await publishAndHandle(basePath, options, () => { - const gitCommands = spawnCalls - .filter(call => call.cmd === 'git') - .map(call => call.args[0]); - - // Verify standard flow: checkout β†’ removal β†’ add β†’ commit - const checkoutIndex = gitCommands.indexOf('checkout'); - const rmIndex = gitCommands.indexOf('rm'); - const addIndex = gitCommands.indexOf('add'); - const commitIndex = gitCommands.indexOf('commit'); - - expect(checkoutIndex).toBeGreaterThan(-1); - expect(rmIndex).toBeGreaterThan(checkoutIndex); - expect(addIndex).toBeGreaterThan(rmIndex); - expect(commitIndex).toBeGreaterThan(addIndex); - }); + + await ghPages.publish(basePath, { repo, message: 'Deploy', add: false }); + + expect(gitCommands()).not.toContain('read-tree'); + expect(gitCalls('add')).toEqual([['add', '--force', '--all', '--', '.']]); }); - it('should NEVER execute git rm when add: true (skips removal entirely)', async () => { + it('should keep the existing files with add: true', async () => { const repo = 'https://github.com/test/rm-add-test.git'; - currentTestContext.repo = repo; - const options = { repo, message: 'Deploy', add: true }; - - await publishAndHandle(basePath, options, () => { - const rmCall = spawnCalls.find(call => - call.cmd === 'git' && call.args[0] === 'rm' - ); - - // CRITICAL: git rm MUST NEVER be called when add: true - // This verifies the removal logic is completely skipped - expect(rmCall).toBeUndefined(); - }); + + await ghPages.publish(basePath, { repo, message: 'Deploy', add: true }); + + const commands = gitCommands(); + expect(commands.indexOf('read-tree')).toBeGreaterThan(commands.indexOf('clone')); + expect(gitCalls('add')).toEqual([['add', '--force', '--ignore-removal', '--', '.']]); + }); + + it('should NEVER execute git rm', async () => { + const repo = 'https://github.com/test/no-rm-test.git'; + + await ghPages.publish(basePath, { repo, message: 'Deploy' }); + await ghPages.publish(basePath, { repo, message: 'Deploy', add: true }); + + expect(gitCommands()).not.toContain('rm'); }); }); /** * Dotfiles option * - * What does angular-cli-ghpages do? * - The engine passes the dotfiles option to publish() * - Default in angular-cli-ghpages: dotfiles: true (include dotfiles) - * - User can disable with --no-dotfiles flag - * - * How publish() uses it (listFiles): * - dotfiles: true β†’ includes files starting with '.' (like .htaccess) * - dotfiles: false β†’ ignores files starting with '.' - * - * Test fixture has these files: - * - index.html (normal file) - * - main.js (normal file) - * - styles.css (normal file) - * - .htaccess (dotfile) */ describe('Dotfiles option', () => { it('should include dotfiles when dotfiles: true (our default)', async () => { const repo = 'https://github.com/test/dotfiles-true.git'; - currentTestContext.repo = repo; - const options = { repo, message: 'Deploy', dotfiles: true }; - - await publishAndHandle(basePath, options, () => { - expect(mockCopy).toHaveBeenCalledTimes(1); - const callArgs = mockCopy.mock.calls[0]; - expect(callArgs).toBeDefined(); - expect(callArgs.length).toBe(3); + await ghPages.publish(basePath, { repo, message: 'Deploy', dotfiles: true }); - const [files, source, destination] = callArgs as unknown as [string[], string, string]; - - // CRITICAL: Verify files array INCLUDES the .htaccess dotfile - expect(Array.isArray(files)).toBe(true); - expect(files.length).toBe(4); // index.html, main.js, styles.css, .htaccess - expect(files).toContain('index.html'); - expect(files).toContain('main.js'); - expect(files).toContain('styles.css'); - expect(files).toContain('.htaccess'); // This is the dotfile - - expect(source).toBe(basePath); - expect(destination).toContain('gh-pages'); - }); + const [files] = mockCopy.mock.calls[0] as unknown as [string[]]; + expect([...files].sort()).toEqual(['.htaccess', 'index.html', 'main.js', 'styles.css']); }); it('should exclude dotfiles when dotfiles: false', async () => { const repo = 'https://github.com/test/dotfiles-false.git'; - currentTestContext.repo = repo; - const options = { repo, message: 'Deploy', dotfiles: false }; - - await publishAndHandle(basePath, options, () => { - expect(mockCopy).toHaveBeenCalledTimes(1); - - const callArgs = mockCopy.mock.calls[0]; - expect(callArgs).toBeDefined(); - expect(callArgs.length).toBe(3); - const [files, source, destination] = callArgs as unknown as [string[], string, string]; + await ghPages.publish(basePath, { repo, message: 'Deploy', dotfiles: false }); - // CRITICAL: Verify files array EXCLUDES the .htaccess dotfile - expect(Array.isArray(files)).toBe(true); - expect(files.length).toBe(3); // Only index.html, main.js, styles.css - expect(files).toContain('index.html'); - expect(files).toContain('main.js'); - expect(files).toContain('styles.css'); - expect(files).not.toContain('.htaccess'); // Dotfile must be excluded - - expect(source).toBe(basePath); - expect(destination).toContain('gh-pages'); - }); + const [files] = mockCopy.mock.calls[0] as unknown as [string[]]; + expect([...files].sort()).toEqual(['index.html', 'main.js', 'styles.css']); }); }); - /** - * Git executable option - * - * What does angular-cli-ghpages do? - * - The engine passes the git option to publish() - * - Default: 'git' (relies on PATH), see engine/defaults.ts - * - * Testing approach: - * - We pass git: 'git' (our default) - * - Verify git commands are executed with it - */ describe('Git executable option', () => { - it('should accept git executable option (we pass our default "git")', async () => { + it('should run every git command with the git executable', async () => { const repo = 'https://github.com/test/git-exe-test.git'; - const gitExecutable = 'git'; // Our default from engine/defaults.ts - currentTestContext.repo = repo; - const options = { repo, message: 'Deploy', git: gitExecutable }; - - await publishAndHandle(basePath, options, () => { - // Find all git commands - const gitCalls = spawnCalls.filter(call => call.cmd === 'git'); - - // CRITICAL: Verify git commands were executed - expect(gitCalls.length).toBeGreaterThan(0); - - // Verify critical commands were executed with the git executable - expect(gitCalls.some(call => call.args[0] === 'clone')).toBe(true); - expect(gitCalls.some(call => call.args[0] === 'add')).toBe(true); - expect(gitCalls.some(call => call.args[0] === 'commit')).toBe(true); - }); + const gitExecutable = '/opt/git/bin/git'; + + await ghPages.publish(basePath, { repo, message: 'Deploy', git: gitExecutable }); + + expect(spawnCalls.length).toBeGreaterThan(0); + expect(spawnCalls.every((call) => call.cmd === gitExecutable)).toBe(true); }); }); describe('User credentials', () => { + const name = 'Deploy Bot'; + const email = 'bot@example.com'; + it('should configure git user.email with exact value', async () => { const repo = 'https://github.com/test/user-test.git'; - const name = 'Deploy Bot'; - const email = 'bot@example.com'; - currentTestContext.repo = repo; - const options = { repo, message: 'Deploy', user: { name, email } }; - - await publishAndHandle(basePath, options, () => { - const configEmailCall = spawnCalls.find(call => - call.cmd === 'git' && - call.args[0] === 'config' && - call.args[1] === 'user.email' - ); - - expect(configEmailCall).toBeDefined(); - expect(configEmailCall?.args[2]).toBe(email); // Exact position - }); + + await ghPages.publish(basePath, { repo, message: 'Deploy', user: { name, email } }); + + expect(gitCalls('config')).toContainEqual(['config', 'user.email', email]); }); it('should configure git user.name with exact value', async () => { const repo = 'https://github.com/test/username-test.git'; - const name = 'Deploy Bot'; - const email = 'bot@example.com'; - currentTestContext.repo = repo; - const options = { repo, message: 'Deploy', user: { name, email } }; - - await publishAndHandle(basePath, options, () => { - const configNameCall = spawnCalls.find(call => - call.cmd === 'git' && - call.args[0] === 'config' && - call.args[1] === 'user.name' - ); - - expect(configNameCall).toBeDefined(); - expect(configNameCall?.args[2]).toBe(name); // Exact position - }); + + await ghPages.publish(basePath, { repo, message: 'Deploy', user: { name, email } }); + + expect(gitCalls('config')).toContainEqual(['config', 'user.name', name]); }); - }); - /** - * Git tag command - Verify we DON'T use tagging - * - * publish() has no tag option and must never create tags. - */ - describe('Git tag command', () => { - it('should NEVER create git tags (we do not use tag option)', async () => { - const repo = 'https://github.com/test/no-tag-test.git'; - currentTestContext.repo = repo; - const options = { repo, message: 'Deploy' }; + it('should configure only the email when the user has no name', async () => { + const repo = 'https://github.com/test/email-only-test.git'; - await publishAndHandle(basePath, options, () => { - const tagCall = spawnCalls.find(call => - call.cmd === 'git' && call.args[0] === 'tag' - ); + await ghPages.publish(basePath, { repo, message: 'Deploy', user: { name: '', email } }); - // CRITICAL: We should NEVER create tags - expect(tagCall).toBeUndefined(); - }); + expect(gitCalls('config')).toEqual([['config', 'user.email', email]]); }); }); - /** - * Fixed behavior of publish() (upstream gh-pages has options for these): - * - always pushes to the remote - * - deploys to the root of the branch - * - includes all files of basePath - * - removes all existing files (unless add: true) - */ - describe('fixed publish behavior', () => { - it('should push to remote', async () => { - const repo = 'https://github.com/test/default-push-test.git'; - currentTestContext.repo = repo; - const options = { repo, message: 'Deploy' }; - - await publishAndHandle(basePath, options, () => { - const pushCall = spawnCalls.find(call => - call.cmd === 'git' && call.args[0] === 'push' - ); - - // CRITICAL: Push MUST be called - expect(pushCall).toBeDefined(); - }); - }); - - it('should copy to the root of the branch', async () => { - const repo = 'https://github.com/test/default-dest-test.git'; - currentTestContext.repo = repo; - const options = { repo, message: 'Deploy' }; - - await publishAndHandle(basePath, options, () => { - // Copy destination is the root of the clone (not a subdirectory) - expect(mockCopy).toHaveBeenCalledTimes(1); + describe('Git tag command', () => { + it('should NEVER create git tags', async () => { + const repo = 'https://github.com/test/no-tag-test.git'; - const callArgs = mockCopy.mock.calls[0]; - const [files, source, destination] = callArgs as unknown as [string[], string, string]; + await ghPages.publish(basePath, { repo, message: 'Deploy' }); - expect(path.basename(destination)).toMatch(/^clone-/); - }); + expect(gitCommands()).not.toContain('tag'); }); + }); - it('should include all files', async () => { - const repo = 'https://github.com/test/default-src-test.git'; - currentTestContext.repo = repo; - const options = { repo, message: 'Deploy' }; - - await publishAndHandle(basePath, options, () => { - expect(mockCopy).toHaveBeenCalledTimes(1); - - const callArgs = mockCopy.mock.calls[0]; - const [files] = callArgs as unknown as [string[], string, string]; + describe('New branch', () => { + it('should create an empty repository instead of cloning when the branch does not exist', async () => { + const repo = 'https://github.com/test/new-branch-test.git'; + const branch = 'pages'; + spawnOverride = (args) => args[0] === 'ls-remote' ? { code: 2 } : undefined; - // All our test files should be included - expect(files.length).toBe(3); - expect(files).toContain('index.html'); - expect(files).toContain('main.js'); - expect(files).toContain('styles.css'); - }); - }); + await ghPages.publish(basePath, { repo, branch, message: 'Deploy' }); - it('should remove .gitmodules first, then all tracked files', async () => { - const repo = 'https://github.com/test/default-remove-test.git'; - currentTestContext.repo = repo; - const options = { repo, message: 'Deploy', add: false }; - - await publishAndHandle(basePath, options, () => { - const rmCalls = spawnCalls - .filter(call => call.cmd === 'git' && call.args[0] === 'rm') - .map(call => call.args.join(' ')); - - // .gitmodules first: git rm fails when it is gone while a gitlink sorting before it remains - expect(rmCalls).toEqual([ - 'rm --ignore-unmatch -r -f -- .gitmodules', - 'rm --ignore-unmatch -r -f -- .' - ]); - }); + expect(gitCommands()).not.toContain('clone'); + expect(gitCalls('init')).toHaveLength(1); + expect(gitCalls('remote')).toEqual([['remote', 'add', 'origin', repo]]); + expect(gitCalls('symbolic-ref')).toEqual([['symbolic-ref', 'HEAD', 'refs/heads/' + branch]]); }); }); /** * Error scenarios - * - * Critical failure paths: - * 1. Git clone failure and retry (branch doesn't exist) + * 1. Shallow clone failure and retry * 2. No changes to commit (diff-index returns 0) * 3. Git commit when no user configured (uses getUser) - * - * Why test error scenarios: - * - Ensures deployments don't fail silently - * - Verifies retry/fallback mechanisms work - * - Documents expected error recovery behavior */ describe('Error scenarios', () => { - it('should retry git clone without the branch on failure', async () => { + it('should retry the clone of the branch without --depth on failure', async () => { const repo = 'https://github.com/test/clone-failure-test.git'; - currentTestContext.repo = repo; - const branch = 'new-branch'; - const options = { repo, branch, message: 'Deploy' }; - - // The clone of the branch fails, the fallback clone of the default branch succeeds + const branch = 'existing-branch'; spawnOverride = (args) => - args[0] === 'clone' && args.includes('--branch') - ? { code: 128, stderr: 'fatal: Remote branch new-branch not found' } + args[0] === 'clone' && args.includes('--depth') + ? { code: 128, stderr: 'fatal: dumb http transport does not support shallow capabilities' } : undefined; - await ghPages.publish(basePath, options); - - const cloneCalls = spawnCalls.filter(call => - call.cmd === 'git' && call.args[0] === 'clone' - ); + await ghPages.publish(basePath, { repo, branch, message: 'Deploy' }); - // CRITICAL: Must have exactly 2 clone attempts + const cloneCalls = gitCalls('clone'); expect(cloneCalls.length).toBe(2); - - // First attempt: with --branch and --depth - expect(cloneCalls[0].args).toContain('--branch'); - expect(cloneCalls[0].args).toContain(branch); - expect(cloneCalls[0].args).toContain('--depth'); - - // Second attempt: the default branch, without a checkout (the new branch starts empty) - expect(cloneCalls[1].args).not.toContain('--branch'); - expect(cloneCalls[1].args).toContain('--no-checkout'); - expect(cloneCalls[1].args).toContain(repo); + expect(cloneCalls[0]).toContain('--depth'); + // Second attempt: the same branch, in full + expect(cloneCalls[1]).not.toContain('--depth'); + expect(cloneCalls[1][cloneCalls[1].indexOf('--branch') + 1]).toBe(branch); }); it('should NOT commit when no changes exist (diff-index returns 0)', async () => { const repo = 'https://github.com/test/no-changes-test.git'; - currentTestContext.repo = repo; - const options = { repo, message: 'Deploy' }; - // exit code 0: no changes (opposite of the default) spawnOverride = (args) => args[0] === 'diff-index' ? { code: 0 } : undefined; - await ghPages.publish(basePath, options); - - const commitCall = spawnCalls.find(call => - call.cmd === 'git' && call.args[0] === 'commit' - ); + await ghPages.publish(basePath, { repo, message: 'Deploy' }); - // CRITICAL: Commit should NOT be called when no changes // publish() uses: git diff-index --quiet HEAD || git commit -m "message" - expect(commitCall).toBeUndefined(); - + expect(gitCommands()).not.toContain('commit'); // But push should still be called (even with no new commits) - const pushCall = spawnCalls.find(call => - call.cmd === 'git' && call.args[0] === 'push' - ); - expect(pushCall).toBeDefined(); + expect(gitCommands()).toContain('push'); }); it('should handle deployment without user credentials (uses getUser)', async () => { const repo = 'https://github.com/test/no-user-test.git'; - currentTestContext.repo = repo; - const options = { repo, message: 'Deploy' }; - // DON'T pass user option - publish() will call getUser() - await ghPages.publish(basePath, options); + // DON'T pass user option - publish() will call getUser() (our mock returns null) + await ghPages.publish(basePath, { repo, message: 'Deploy' }); - // Verify getUser was called (our mock returns null) expect(mockGetUser).toHaveBeenCalled(); - - // Verify NO git config commands for user.name/user.email - const configUserCalls = spawnCalls.filter(call => - call.cmd === 'git' && - call.args[0] === 'config' && - (call.args[1] === 'user.name' || call.args[1] === 'user.email') - ); - - // When getUser returns null, publish() skips git config - // (relies on global/local git config) - expect(configUserCalls.length).toBe(0); + // When getUser returns null, publish() skips git config (relies on global/local git config) + expect(gitCalls('config')).toEqual([]); }); }); - /** - * CNAME/.nojekyll file creation and the removal of the temporary clone run - * against real git in engine.gh-pages-filecreation.spec.ts and - * engine.gh-pages-clean.spec.ts. - */ - /** * Regression for issue #205: a failing git command must reject engine.run(), * never end in a "Successfully published" message. */ describe('auth-failure regression (issue #205)', () => { - it('engine.run() should reject when git clone fails with fatal: Authentication failed', async () => { - spawnOverride = () => ({ - code: 128, - stderr: "fatal: Authentication failed for 'https://github.com/owner/repo.git'" - }); - - const options = { - repo: 'https://x-access-token:bad-token@github.com/owner/repo.git', - branch: 'gh-pages', - dotfiles: true, - notfound: true, - nojekyll: true - }; + it('engine.run() should reject when git fails with fatal: Authentication failed', async () => { + const authError = "fatal: Authentication failed for 'https://github.com/owner/repo.git'"; + spawnOverride = () => ({ code: 128, stderr: authError }); + const repo = 'https://x-access-token:bad-token@github.com/owner/repo.git'; await expect( - engine.run(basePath, options, new logging.NullLogger()) - ).rejects.toThrow('fatal: Authentication failed'); + engine.run(basePath, { repo, branch: 'gh-pages', dotfiles: true, notfound: false, nojekyll: false }, new logging.NullLogger()) + ).rejects.toThrow(authError); - const cloneCall = spawnCalls.find(c => c.cmd === 'git' && gitSubcommand(c.args) === 'clone'); - expect(cloneCall).toBeDefined(); // credentials in the URL: no credential helper may store them - expect(cloneCall!.args.slice(0, 2)).toEqual(['-c', 'credential.helper=']); + expect(spawnCalls[0].args.slice(0, 2)).toEqual(['-c', 'credential.helper=']); }); }); /** * Regression for issue #204: leftover files of the gh-pages branch (dotfiles, - * dot-directories, submodule gitlinks) must not survive a deploy. publish() - * removes every tracked file before copying dist, unless `add` is set. + * dot-directories, submodule gitlinks) must not survive a deploy. The branch is + * cloned without a checkout and `add --all` into the empty index makes the + * copied files the complete new content. */ describe('submodule / dotfile cleanup regression (issue #204)', () => { - it('engine.run() should remove all tracked files after checkout and before adding dist', async () => { + it('engine.run() should stage the dist files as the complete content of the branch', async () => { const repo = 'https://github.com/owner/leftover-test.git'; - currentTestContext.repo = repo; - - const options = { - repo, - branch: 'gh-pages', - dotfiles: true, - notfound: false, - nojekyll: false - }; - - await engine.run(basePath, options, new logging.NullLogger()); - - const gitCalls = spawnCalls.filter(c => c.cmd === 'git').map(c => c.args.join(' ')); - const resetIndex = gitCalls.indexOf('reset --hard origin/gh-pages'); - const rmIndex = gitCalls.indexOf('rm --ignore-unmatch -r -f -- .'); - const addIndex = gitCalls.indexOf('add .'); - - expect(resetIndex).toBeGreaterThan(-1); - expect(rmIndex).toBeGreaterThan(resetIndex); - expect(addIndex).toBeGreaterThan(rmIndex); + + await engine.run(basePath, { repo, branch: 'gh-pages', dotfiles: true, notfound: false, nojekyll: false }, new logging.NullLogger()); + + expect(gitCalls('clone')[0]).toContain('--no-checkout'); + expect(gitCommands()).not.toContain('read-tree'); + expect(gitCalls('add')).toEqual([['add', '--force', '--all', '--', '.']]); expect(mockCopy).toHaveBeenCalledTimes(1); }); it('engine.run() should keep existing files with add: true', async () => { const repo = 'https://github.com/owner/leftover-add-test.git'; - currentTestContext.repo = repo; await engine.run(basePath, { repo, add: true, notfound: false, nojekyll: false }, new logging.NullLogger()); - expect(spawnCalls.some(c => c.cmd === 'git' && c.args[0] === 'rm')).toBe(false); + expect(gitCommands()).toContain('read-tree'); + expect(gitCalls('add')).toEqual([['add', '--force', '--ignore-removal', '--', '.']]); }); }); }); diff --git a/src/engine/engine.gh-pages-clone.spec.ts b/src/engine/engine.gh-pages-clone.spec.ts index 4bab5e5..0520aa4 100644 --- a/src/engine/engine.gh-pages-clone.spec.ts +++ b/src/engine/engine.gh-pages-clone.spec.ts @@ -27,7 +27,7 @@ describe('gh-pages-fork temporary clone', () => { originalEnv = { ...process.env }; tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gh-pages-clone-test-')); process.env.CACHE_DIR = path.join(tempDir, 'cache'); - cacheDir = ghPages.getCacheDir(); + cacheDir = ghPages.getCacheDir()!; basePath = path.join(tempDir, 'dist'); await fs.mkdir(basePath); diff --git a/src/engine/engine.gh-pages-no-package-json.spec.ts b/src/engine/engine.gh-pages-no-package-json.spec.ts index 64effe4..37ea437 100644 --- a/src/engine/engine.gh-pages-no-package-json.spec.ts +++ b/src/engine/engine.gh-pages-no-package-json.spec.ts @@ -86,9 +86,10 @@ describe('engine.run() in a directory with no package.json (issue #203)', () => // Positive proof that the deploy actually landed: expect(tree(bareRepoPath)).toEqual(['index.html']); - // The clone went to the tmpdir fallback + // The clone went to a private directory in the os temp dir const cloning = messages.find((message) => message.startsWith('Cloning ')); const cloneDir = cloning!.split(' into ')[1]; - expect(path.dirname(cloneDir)).toBe(path.join(os.tmpdir(), 'angular-cli-ghpages-cache', 'gh-pages')); + expect(path.dirname(cloneDir)).toBe(os.tmpdir()); + expect(path.basename(cloneDir)).toMatch(/^angular-cli-ghpages-clone-/); }, 30_000); }); diff --git a/src/gh-pages-fork/lib/git.ts b/src/gh-pages-fork/lib/git.ts index 33ac71c..021c949 100644 --- a/src/gh-pages-fork/lib/git.ts +++ b/src/gh-pages-fork/lib/git.ts @@ -6,11 +6,13 @@ */ import * as cp from 'child_process'; +import * as fs from 'fs/promises'; import * as path from 'path'; /** * Error for a git process that didn't exit with code 0. - * `message` holds the combined stderr/stdout output of the process. + * `message` holds the combined stderr/stdout output of the process + * (plus the signal, if the process was killed). */ export class ProcessError extends Error { constructor( @@ -24,6 +26,7 @@ export class ProcessError extends Error { export interface SpawnOptions { cwd?: string; + /** Complete environment of the process (default: process.env) */ env?: NodeJS.ProcessEnv; } @@ -36,7 +39,7 @@ export function spawn(exe: string, args: string[], options: SpawnOptions = {}): return new Promise((resolve, reject) => { const child = cp.spawn(exe, args, { cwd: options.cwd || process.cwd(), - env: options.env ? { ...process.env, ...options.env } : process.env + env: options.env ?? process.env }); const stdout: string[] = []; const output: string[] = []; @@ -49,14 +52,37 @@ export function spawn(exe: string, args: string[], options: SpawnOptions = {}): child.on('close', (code: number | null, signal: NodeJS.Signals | null) => { if (code === 0) { resolve(stdout.join('')); - } else { - const reason = signal ? 'Process killed by ' + signal : 'Process failed: ' + code; - reject(new ProcessError(code, output.join('') || reason)); + return; } + const message = [output.join('').trim(), signal ? 'Process killed by ' + signal : ''].filter(Boolean).join('\n'); + reject(new ProcessError(code, message || 'Process failed: ' + code)); }); }); } +/** + * Variables that point git at a repository. A deploy started by git itself + * (hooks, `!` aliases, worktrees) inherits them; commands for the clone must + * never act on that repository. + */ +const REPOSITORY_VARIABLES = [ + 'GIT_DIR', 'GIT_WORK_TREE', 'GIT_IMPLICIT_WORK_TREE', 'GIT_INDEX_FILE', + 'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_COMMON_DIR', + 'GIT_GRAFT_FILE', 'GIT_NO_REPLACE_OBJECTS', 'GIT_REPLACE_REF_BASE', + 'GIT_PREFIX', 'GIT_SHALLOW_FILE' +]; + +/** + * process.env without the repository variables, plus `extra`. + */ +function isolatedEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { + const env = { ...process.env }; + for (const name of REPOSITORY_VARIABLES) { + delete env[name]; + } + return { ...env, ...extra }; +} + export interface CloneOptions { git: string; remote: string; @@ -74,123 +100,113 @@ export class Git { * @param cwd Repository directory * @param cmd Git executable * @param args Arguments placed before every git subcommand (e.g. `-c key=value`) - * @param env Additional environment variables for every git process + * @param env Complete environment of every git process (default: process.env) + * @param isNewBranch The branch doesn't exist on the remote yet (set by Git.clone) */ constructor( public readonly cwd: string, public readonly cmd = 'git', private readonly args: string[] = [], - private readonly env?: NodeJS.ProcessEnv + private readonly env?: NodeJS.ProcessEnv, + public readonly isNewBranch = false ) {} /** - * Clone a branch of a repo into the given (empty or missing) dir. - * - * When the branch doesn't exist yet, the default branch is cloned without a - * checkout instead (only the remote is needed to create the new branch): - * shallow first, then in full for servers without shallow clone support. - * - * The returned instance never operates on a repository above `dir` - * (GIT_CEILING_DIRECTORIES), even if `dir` loses its .git directory. + * Whether `branch` exists in the repository `repo`. + * Rejects for every other failure (authentication, network, missing repository). */ - static async clone(repo: string, dir: string, branch: string, options: CloneOptions, args: string[] = []): Promise { - const env = { GIT_CEILING_DIRECTORIES: path.dirname(path.resolve(dir)) }; - const clone = (...cloneArgs: string[]) => - spawn(options.git, [...args, 'clone', repo, dir, '--origin', options.remote, ...cloneArgs], { env }); - const depth = String(options.depth); - + static async branchExists(repo: string, branch: string, cmd: string, args: string[] = []): Promise { try { - await clone('--branch', branch, '--single-branch', '--depth', depth); - } catch { - try { - await clone('--no-checkout', '--depth', depth); - } catch { - await clone('--no-checkout'); + await spawn(cmd, [...args, 'ls-remote', '--exit-code', '--heads', repo, 'refs/heads/' + branch], { env: isolatedEnv() }); + return true; + } catch (error) { + if (error instanceof ProcessError && error.code === 2) { + return false; } + throw error; } - return new Git(dir, options.git, args, env); } /** - * Execute an arbitrary git command. - */ - async exec(...args: string[]): Promise { - this.output = await spawn(this.cmd, [...this.args, ...args], { cwd: this.cwd, env: this.env }); - return this; - } - - /** - * Clean up unversioned files. - */ - clean(): Promise { - return this.exec('clean', '-f', '-d'); - } - - /** - * Hard reset to remote/branch. + * Prepare a repository for `branch` of `repo` in the given empty dir, + * without checking out any files (the index starts empty): + * - existing branch: clone only that branch (shallow, then in full for + * servers without shallow clone support) + * - new branch: an empty repository with `repo` as remote and HEAD on the unborn branch + * + * The returned instance always operates on `dir`: GIT_DIR and GIT_WORK_TREE + * point to it, inherited repository variables are removed, and + * safe.directory covers it. */ - reset(remote: string, branch: string): Promise { - return this.exec('reset', '--hard', remote + '/' + branch); - } + static async clone(repo: string, dir: string, branch: string, options: CloneOptions, args: string[] = []): Promise { + const exists = await Git.branchExists(repo, branch, options.git, args); + const run = (...gitArgs: string[]) => spawn(options.git, [...args, ...gitArgs], { env: isolatedEnv() }); - /** - * Checkout a branch. A branch that doesn't exist on the remote is created as - * an empty orphan branch (the files of the cloned default branch are removed). - */ - async checkout(remote: string, branch: string): Promise { - const treeish = remote + '/' + branch; - try { - await this.exec('ls-remote', '--exit-code', '.', treeish); - } catch (error) { - if (error instanceof ProcessError && error.code === 2) { - await this.exec('checkout', '--orphan', branch); - return this.removeAll(); + if (exists) { + const clone = ['clone', repo, dir, '--origin', options.remote, '--branch', branch, '--single-branch', '--no-checkout']; + try { + await run(...clone, '--depth', String(options.depth)); + } catch { + await fs.rm(dir, { recursive: true, force: true }); + await fs.mkdir(dir, { recursive: true }); + await run(...clone); } - throw error; + } else { + await run('init', dir); } - await this.exec('checkout', branch); - await this.clean(); - return this.reset(remote, branch); + const git = new Git( + dir, + options.git, + [...args, '-c', 'safe.directory=' + dir], + isolatedEnv({ GIT_DIR: path.join(dir, '.git'), GIT_WORK_TREE: dir }), + !exists + ); + if (!exists) { + await git.exec('remote', 'add', options.remote, repo); + await git.exec('symbolic-ref', 'HEAD', 'refs/heads/' + branch); + } + return git; } /** - * Remove files from the index and the working tree. + * Execute an arbitrary git command. */ - rm(files: string[]): Promise { - return this.exec('rm', '--ignore-unmatch', '-r', '-f', '--', ...files); + async exec(...args: string[]): Promise { + this.output = await spawn(this.cmd, [...this.args, ...args], { cwd: this.cwd, env: this.env }); + return this; } /** - * Remove all tracked files, including dotfiles and submodule gitlinks. - * `.gitmodules` goes first: `git rm` reads it to update submodule entries and - * fails when it is already gone while a gitlink sorting before it remains. + * Fill the index with the tree of HEAD (the clone has no checkout), so that + * a commit keeps the existing files of the branch. */ - async removeAll(): Promise { - await this.rm(['.gitmodules']); - return this.rm(['.']); + keepExistingFiles(): Promise { + return this.exec('read-tree', 'HEAD'); } /** - * Add files. + * Stage all files of the working tree, including files that match ignore rules + * (the user's global gitignore or a .gitignore in dist must not drop files). + * With `keepRemoved`, files missing from the working tree stay in the index. */ - add(files: string[]): Promise { - return this.exec('add', ...files); + addAll(keepRemoved: boolean): Promise { + return this.exec('add', '--force', keepRemoved ? '--ignore-removal' : '--all', '--', '.'); } /** - * Commit (if there are any changes). + * Commit (if there are any changes). Hooks of the user don't apply to deploy commits. */ async commit(message: string): Promise { try { return await this.exec('diff-index', '--quiet', 'HEAD'); } catch { - return this.exec('commit', '-m', message); + return this.exec('commit', '--no-verify', '-m', message); } } /** - * Push a branch. + * Push a branch to a remote name or URL. */ push(remote: string, branch: string): Promise { return this.exec('push', '--tags', remote, branch); @@ -208,6 +224,10 @@ export class Git { if (!(error instanceof ProcessError)) { throw error; } + // exit code 1: the key is not set; anything else is a real git error + if (error.code !== 1) { + throw new Error('Failed to get remote.' + remote + '.url: ' + error.message); + } } if (!repo) { throw new Error( diff --git a/src/gh-pages-fork/lib/index.ts b/src/gh-pages-fork/lib/index.ts index 52877bd..7d67bac 100644 --- a/src/gh-pages-fork/lib/index.ts +++ b/src/gh-pages-fork/lib/index.ts @@ -30,29 +30,34 @@ export interface PublishOptions { export type Log = (message: string) => void; /** - * Defaults of upstream gh-pages. angular-cli-ghpages passes every option - * explicitly (see engine/defaults.ts), so these only apply to direct calls. + * Same values as engine/defaults.ts; the engine passes every option except + * `depth` explicitly. */ export const defaults = { add: false, git: 'git', depth: 1, - dotfiles: false, + dotfiles: true, branch: 'gh-pages', remote: 'origin', - message: 'Updates', - nojekyll: false + message: 'Auto-generated commit', + nojekyll: true }; +/** Prefix of temporary clones in the os temp directory */ +const TMP_CLONE_PREFIX = 'angular-cli-ghpages-clone-'; + +/** Temporary clones older than this belong to interrupted deploys */ +const STALE_CLONE_MS = 6 * 60 * 60 * 1000; + /** - * Get the cache directory that holds the temporary clones. - * - * Resolution order: + * Get the cache directory that holds the temporary clones: * 1. `$CACHE_DIR/gh-pages` (boolean-like values such as `true` or `0` are ignored) - * 2. `node_modules/.cache/gh-pages` next to the closest package.json above cwd - * 3. `/angular-cli-ghpages-cache/gh-pages` + * 2. `node_modules/.cache/gh-pages` next to the closest package.json above `cwd` + * + * Returns undefined if neither applies; clones then go to the os temp directory. */ -export function getCacheDir(cwd = process.cwd()): string { +export function getCacheDir(cwd = process.cwd()): string | undefined { const cacheDirEnv = process.env.CACHE_DIR; if (cacheDirEnv && !['true', 'false', '1', '0'].includes(cacheDirEnv)) { return path.join(cacheDirEnv, 'gh-pages'); @@ -65,59 +70,139 @@ export function getCacheDir(cwd = process.cwd()): string { } const parent = path.dirname(dir); if (parent === dir) { - return path.join(os.tmpdir(), 'angular-cli-ghpages-cache', 'gh-pages'); + return undefined; } dir = parent; } } /** - * Create a fresh directory for a temporary clone below getCacheDir(). - * Falls back to the os temp directory when the cache directory isn't writable - * (e.g. a read-only node_modules). + * Create a fresh, private directory for a temporary clone: `clone-*` below + * getCacheDir(), or `angular-cli-ghpages-clone-*` in the os temp directory if + * there is no cache directory or it isn't writable (e.g. a read-only node_modules). */ async function createCloneDir(): Promise { - try { - const cacheDir = getCacheDir(); - await fs.mkdir(cacheDir, { recursive: true }); - return await fs.mkdtemp(path.join(cacheDir, 'clone-')); - } catch { - return fs.mkdtemp(path.join(os.tmpdir(), 'angular-cli-ghpages-clone-')); + const cacheDir = getCacheDir(); + if (cacheDir) { + try { + await fs.mkdir(cacheDir, { recursive: true }); + return await fs.mkdtemp(path.join(cacheDir, 'clone-')); + } catch { + // fall through to the os temp directory + } } + return fs.mkdtemp(path.join(os.tmpdir(), TMP_CLONE_PREFIX)); +} + +/** + * Remove what interrupted deploys (Ctrl+C, a cancelled CI job) leave behind: + * `clone-*` directories older than STALE_CLONE_MS and any other entry of the + * cache directory (clones of older angular-cli-ghpages versions, named after + * the repository URL including a token), plus old temporary clones in the os + * temp directory. Clones of concurrent deploys are younger and stay. + */ +async function removeStaleClones(): Promise { + const now = Date.now(); + const removeIfStale = async (dir: string, name: string, clonePrefix: string) => { + const target = path.join(dir, name); + try { + if (name.startsWith(clonePrefix) && now - (await fs.stat(target)).mtimeMs < STALE_CLONE_MS) { + return; + } + await fs.rm(target, { recursive: true, force: true }); + } catch { + // another deploy removed it, or it isn't ours to remove + } + }; + + const cacheDir = getCacheDir(); + if (cacheDir) { + const entries = await fs.readdir(cacheDir).catch(() => [] as string[]); + await Promise.all(entries.map((name) => removeIfStale(cacheDir, name, 'clone-'))); + } + + const tmpEntries = await fs.readdir(os.tmpdir()).catch(() => [] as string[]); + await Promise.all( + tmpEntries + .filter((name) => name.startsWith(TMP_CLONE_PREFIX)) + .map((name) => removeIfStale(os.tmpdir(), name, TMP_CLONE_PREFIX)) + ); +} + +/** + * Whether `repo` is a relative local path (not a URL, not an scp-like `host:path`). + */ +function isRelativePath(repo: string): boolean { + return !parseUrl(repo) && !/^[^/\\]+:/.test(repo) && !path.isAbsolute(repo); } /** * The repository to publish to: `options.repo`, or the URL of `options.remote` - * of the git repository in the current working directory. + * of the git repository in the current working directory. Relative local paths + * become absolute (the clone runs elsewhere): `options.repo` relative to the + * current working directory, a path from the git config relative to the top + * level of that repository, like git itself resolves it. */ export async function getRepo(options: { repo?: string; git?: string; remote?: string }): Promise { if (options.repo) { - return options.repo; + return isRelativePath(options.repo) ? path.resolve(options.repo) : options.repo; } - return new Git(process.cwd(), options.git ?? defaults.git).getRemoteUrl(options.remote ?? defaults.remote); + const git = new Git(process.cwd(), options.git ?? defaults.git); + const repo = await git.getRemoteUrl(options.remote ?? defaults.remote); + if (!isRelativePath(repo)) { + return repo; + } + await git.exec('rev-parse', '--show-toplevel'); + return path.resolve(git.output.trim(), repo); } /** - * Whether a repository URL carries credentials (e.g. an injected token). + * Parse a repository URL; undefined for scp-like addresses and local paths. */ -function hasCredentials(repo: string): boolean { - return /\/\/[^/@]+@/.test(repo); +function parseUrl(repo: string): URL | undefined { + try { + return new URL(repo); + } catch { + return undefined; + } +} + +/** + * Whether a repository URL carries a password or token. A URL with only a + * username (e.g. the HTTPS clone URLs of Bitbucket and Azure DevOps) relies on + * the user's credential helper and doesn't count. + */ +function hasPassword(repo: string): boolean { + return !!parseUrl(repo)?.password; +} + +function withoutCredentials(repo: string): string { + const url = parseUrl(repo)!; + url.username = ''; + url.password = ''; + return url.toString(); } /** * Hide credentials (e.g. an injected token) in a repository URL for logging. */ -function redact(repo: string): string { - return repo.replace(/\/\/[^/@]+@/, '//***@'); +export function redact(repo: string): string { + const url = parseUrl(repo); + if (!url || (!url.username && !url.password)) { + return repo; + } + return repo.replace(/^([^:/]+:\/\/)[^/]*@/, '$1***@'); } /** * Publish the files in `basePath` to a branch of a git repository. * - * Clones the branch into a fresh temporary directory, replaces its content - * with the files from `basePath` (unless `add` is set), commits and pushes. - * The clone is removed afterwards, so concurrent deploys never share a clone - * and an injected token doesn't stay on disk. + * Prepares the branch in a fresh temporary directory without checking out its + * files, writes the files from `basePath` (plus .nojekyll and CNAME), stages + * them as the complete new content of the branch (with `add`: on top of the + * existing files), commits and pushes. The temporary clone is removed when + * publish() finishes; a token in the repository URL is only passed on the + * command line, never kept in the clone's .git/config. */ export async function publish(basePath: string, config: PublishOptions, log: Log = () => undefined): Promise { const options = { ...defaults, ...config }; @@ -134,21 +219,23 @@ export async function publish(basePath: string, config: PublishOptions, log: Log const user = options.user ?? await getUser(process.cwd(), options.git); const repoUrl = await getRepo(options); + await removeStaleClones(); const clone = await createCloneDir(); try { // Credentials in the URL must not end up in a credential helper's store - const gitArgs = hasCredentials(repoUrl) ? ['-c', 'credential.helper='] : []; + const credentials = hasPassword(repoUrl); + const gitArgs = credentials ? ['-c', 'credential.helper='] : []; log(`Cloning ${redact(repoUrl)} into ${clone}`); const git = await Git.clone(repoUrl, clone, options.branch, options, gitArgs); + if (credentials) { + await git.exec('remote', 'set-url', options.remote, withoutCredentials(repoUrl)); + } - log(`Checking out ${options.remote}/${options.branch}`); - await git.checkout(options.remote, options.branch); - - if (!options.add) { - log('Removing files'); - await git.removeAll(); + if (options.add && !git.isNewBranch) { + log('Keeping the existing files'); + await git.keepExistingFiles(); } if (options.nojekyll) { @@ -165,7 +252,7 @@ export async function publish(basePath: string, config: PublishOptions, log: Log await copy(files, basePath, git.cwd); log('Adding all'); - await git.add(['.']); + await git.addAll(options.add); if (user) { await git.exec('config', 'user.email', user.email); @@ -178,8 +265,13 @@ export async function publish(basePath: string, config: PublishOptions, log: Log await git.commit(options.message); log('Pushing'); - await git.push(options.remote, options.branch); + await git.push(credentials ? repoUrl : options.remote, options.branch); } finally { - await fs.rm(clone, { recursive: true, force: true, maxRetries: 3 }); + try { + await fs.rm(clone, { recursive: true, force: true, maxRetries: 10 }); + } catch (error) { + // The deploy result doesn't depend on the cleanup; the next deploy removes stale clones + log(`Could not remove the temporary clone ${clone}: ${error instanceof Error ? error.message : String(error)}`); + } } } diff --git a/src/gh-pages-fork/lib/util.ts b/src/gh-pages-fork/lib/util.ts index 39cfb6b..ca2def5 100644 --- a/src/gh-pages-fork/lib/util.ts +++ b/src/gh-pages-fork/lib/util.ts @@ -5,10 +5,11 @@ * Copyright (c) 2014 Tim Schaub */ -import { createReadStream, createWriteStream } from 'fs'; +import { createReadStream, createWriteStream, realpath } from 'fs'; import * as fs from 'fs/promises'; import * as path from 'path'; import { pipeline } from 'stream/promises'; +import { promisify } from 'util'; import { Git } from './git'; @@ -21,13 +22,16 @@ export interface User { * List all files below `base` as relative paths with POSIX separators. * Symbolic links are followed (their targets get copied); broken links and * links to a directory of the current path (cycles) are skipped. - * Entries starting with `.` are skipped unless `dotfiles` is set. + * Entries starting with `.` are skipped unless `dotfiles` is set; entries named + * `.git` are always skipped (git can't track them). */ export async function listFiles(base: string, dotfiles: boolean): Promise { const files: string[] = []; + // JavaScript implementation: the native realpath fails on some Windows volumes (e.g. RAM disks) + const resolve = promisify(realpath); async function walk(relDir: string, ancestors: string[]): Promise { - const realDir = await fs.realpath(path.join(base, relDir)); + const realDir = await resolve(path.join(base, relDir)); if (ancestors.includes(realDir)) { return; } @@ -35,7 +39,7 @@ export async function listFiles(base: string, dotfiles: boolean): Promise { const pairs = files.map((file) => { diff --git a/src/gh-pages-fork/test/cache-dir.spec.ts b/src/gh-pages-fork/test/cache-dir.spec.ts index a9d84a1..bf52a60 100644 --- a/src/gh-pages-fork/test/cache-dir.spec.ts +++ b/src/gh-pages-fork/test/cache-dir.spec.ts @@ -47,10 +47,10 @@ describe('gh-pages-fork getCacheDir()', () => { expect(getCacheDir()).toBe(path.join(tempDir, 'node_modules', '.cache', 'gh-pages')); }); - it('falls back to the os temp directory without a package.json (issue #203)', () => { + it('returns undefined without $CACHE_DIR and package.json (issue #203: clones go to the os temp dir)', () => { process.chdir(tempDir); - expect(getCacheDir()).toBe(path.join(os.tmpdir(), 'angular-cli-ghpages-cache', 'gh-pages')); + expect(getCacheDir()).toBeUndefined(); }); it('resolves relative to the given directory', async () => { diff --git a/src/gh-pages-fork/test/git-commands.spec.ts b/src/gh-pages-fork/test/git-commands.spec.ts index f619c2e..1e73f10 100644 --- a/src/gh-pages-fork/test/git-commands.spec.ts +++ b/src/gh-pages-fork/test/git-commands.spec.ts @@ -11,23 +11,33 @@ const { mockSpawn } = vi.hoisted(() => ({ mockSpawn: vi.fn() })); vi.mock('child_process', () => ({ spawn: mockSpawn })); -import { createMockChildProcess } from '../../test-helpers/git'; -import { publish } from '../lib'; - -interface FakeResult { - code?: number; - output?: string; -} +import { FakeResult, fakeSpawn, gitSubcommand, SpawnCall, withoutConfigArgs } from '../../test-helpers/git'; +import { publish, PublishOptions } from '../lib'; describe('gh-pages-fork git commands', () => { const repo = 'https://github.com/user/repo.git'; - let calls: string[]; - let envs: (NodeJS.ProcessEnv | undefined)[]; + const user = { name: 'Bot', email: 'bot@example.org' }; + let calls: SpawnCall[]; + // Responses by command (without leading `-c` pairs), matched by prefix let results: Record; let tempDir: string; let dist: string; let originalEnv: NodeJS.ProcessEnv; + function respond(call: SpawnCall): FakeResult | undefined { + const command = withoutConfigArgs(call.args).join(' '); + return Object.entries(results).find(([key]) => command.startsWith(key))?.[1]; + } + + // Commands as `git ...`, without the `-c` pairs + function commands(): string[] { + return calls.map((call) => [call.cmd, ...withoutConfigArgs(call.args)].join(' ')); + } + + function deploy(options: PublishOptions = {}) { + return publish(dist, { repo, user, message: 'Deploy', nojekyll: false, ...options }); + } + beforeEach(async () => { originalEnv = { ...process.env }; tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gh-pages-fork-cmd-')); @@ -37,26 +47,11 @@ describe('gh-pages-fork git commands', () => { await fs.writeFile(path.join(dist, 'index.html'), '

test

'); calls = []; - envs = []; results = { - 'config --get remote.origin.url': { output: repo }, + // exit code 1: there are changes to commit 'diff-index --quiet HEAD': { code: 1 } }; - mockSpawn.mockImplementation((cmd: string, args: string[], options: { cwd: string; env: NodeJS.ProcessEnv }) => { - const command = args.join(' '); - calls.push(`${cmd} ${command}`); - envs.push(options.env); - const result = Object.entries(results).find(([key]) => command.startsWith(key))?.[1] ?? {}; - const child = createMockChildProcess(); - setImmediate(() => { - if (result.output) { - child.stdout!.emit('data', Buffer.from(result.output)); - } - child.emit('close', result.code ?? 0); - }); - expect(options.cwd).toBeTruthy(); - return child; - }); + mockSpawn.mockImplementation(fakeSpawn(calls, respond)); }); afterEach(async () => { @@ -65,155 +60,159 @@ describe('gh-pages-fork git commands', () => { await fs.rm(tempDir, { recursive: true, force: true }); }); - function cloneDirOf(command: string): string { - return command.split(' ')[3]; + function cloneDir(): string { + return withoutConfigArgs(calls.find((call) => gitSubcommand(call.args) === 'clone')!.args)[2]; } it('deploys to an existing branch', async () => { - await publish(dist, { - repo, - branch: 'gh-pages', - message: 'Deploy', - user: { name: 'Bot', email: 'bot@example.org' }, - nojekyll: true - }); - - const cloneDir = cloneDirOf(calls[0]); - expect(calls).toEqual([ - `git clone ${repo} ${cloneDir} --origin origin --branch gh-pages --single-branch --depth 1`, - 'git ls-remote --exit-code . origin/gh-pages', - 'git checkout gh-pages', - 'git clean -f -d', - 'git reset --hard origin/gh-pages', - 'git rm --ignore-unmatch -r -f -- .gitmodules', - 'git rm --ignore-unmatch -r -f -- .', - 'git add .', - 'git config user.email bot@example.org', - 'git config user.name Bot', + await deploy(); + + const dir = cloneDir(); + expect(commands()).toEqual([ + `git ls-remote --exit-code --heads ${repo} refs/heads/gh-pages`, + `git clone ${repo} ${dir} --origin origin --branch gh-pages --single-branch --no-checkout --depth 1`, + 'git add --force --all -- .', + `git config user.email ${user.email}`, + `git config user.name ${user.name}`, 'git diff-index --quiet HEAD', - 'git commit -m Deploy', + 'git commit --no-verify -m Deploy', 'git push --tags origin gh-pages' ]); }); - it('creates an orphan branch when the branch does not exist on the remote', async () => { - results['clone ' + repo] = { code: 128 }; + it('creates an empty repository for a branch that does not exist on the remote', async () => { results['ls-remote'] = { code: 2 }; - const defaultImplementation = mockSpawn.getMockImplementation()!; - mockSpawn.mockImplementation((cmd: string, args: string[], options: { cwd: string; env: NodeJS.ProcessEnv }) => { - // only the clone with --branch fails, the fallback clone succeeds - if (args[0] === 'clone' && !args.includes('--branch')) { - delete results['clone ' + repo]; - } - return defaultImplementation(cmd, args, options); - }); - - await publish(dist, { repo, user: { name: 'Bot', email: 'bot@example.org' } }); - - const cloneDir = cloneDirOf(calls[0]); - expect(calls.slice(0, 7)).toEqual([ - `git clone ${repo} ${cloneDir} --origin origin --branch gh-pages --single-branch --depth 1`, - `git clone ${repo} ${cloneDir} --origin origin --no-checkout --depth 1`, - 'git ls-remote --exit-code . origin/gh-pages', - 'git checkout --orphan gh-pages', - // the orphan branch starts empty (the cloned default branch's files are removed) - 'git rm --ignore-unmatch -r -f -- .gitmodules', - 'git rm --ignore-unmatch -r -f -- .', - 'git rm --ignore-unmatch -r -f -- .gitmodules' + + await deploy(); + + const dir = calls.find((call) => gitSubcommand(call.args) === 'init')!.args.at(-1); + expect(commands()).toEqual([ + `git ls-remote --exit-code --heads ${repo} refs/heads/gh-pages`, + `git init ${dir}`, + `git remote add origin ${repo}`, + 'git symbolic-ref HEAD refs/heads/gh-pages', + 'git add --force --all -- .', + `git config user.email ${user.email}`, + `git config user.name ${user.name}`, + 'git diff-index --quiet HEAD', + 'git commit --no-verify -m Deploy', + 'git push --tags origin gh-pages' ]); }); - it('falls back to a full clone when the server rejects a shallow clone', async () => { - results['ls-remote'] = { code: 2 }; - mockSpawn.mockImplementation(((defaultImplementation) => - (cmd: string, args: string[], options: { cwd: string; env: NodeJS.ProcessEnv }) => { - if (args[0] === 'clone' && args.includes('--depth')) { - calls.push(`${cmd} ${args.join(' ')}`); - const child = createMockChildProcess(); - setImmediate(() => child.emit('close', 128)); - return child; - } - return defaultImplementation(cmd, args, options); - })(mockSpawn.getMockImplementation()!)); - - await publish(dist, { repo, user: { name: 'Bot', email: 'bot@example.org' } }); - - const cloneDir = cloneDirOf(calls[0]); - expect(calls.slice(0, 3)).toEqual([ - `git clone ${repo} ${cloneDir} --origin origin --branch gh-pages --single-branch --depth 1`, - `git clone ${repo} ${cloneDir} --origin origin --no-checkout --depth 1`, - `git clone ${repo} ${cloneDir} --origin origin --no-checkout` + it('keeps the existing files with add', async () => { + await deploy({ add: true }); + + expect(commands().slice(2, 4)).toEqual([ + 'git read-tree HEAD', + 'git add --force --ignore-removal -- .' ]); }); - it('starts an orphan branch empty even with add', async () => { - results['clone ' + repo + ' '] = { code: 128 }; + it('adds nothing to keep for a new branch with add', async () => { results['ls-remote'] = { code: 2 }; - const defaultImplementation = mockSpawn.getMockImplementation()!; - mockSpawn.mockImplementation((cmd: string, args: string[], options: { cwd: string; env: NodeJS.ProcessEnv }) => { - if (args[0] === 'clone' && !args.includes('--branch')) { - delete results['clone ' + repo + ' ']; + + await deploy({ add: true }); + + expect(commands()).not.toContain('git read-tree HEAD'); + expect(commands()).toContain('git add --force --ignore-removal -- .'); + }); + + it('falls back to a full clone of the branch when the server rejects a shallow clone', async () => { + mockSpawn.mockImplementation(fakeSpawn(calls, (call) => { + const command = withoutConfigArgs(call.args); + if (command[0] === 'clone' && command.includes('--depth')) { + return { code: 128, stderr: 'fatal: dumb http transport does not support shallow capabilities' }; } - return defaultImplementation(cmd, args, options); - }); + return respond(call); + })); - await publish(dist, { repo, add: true, user: { name: 'Bot', email: 'bot@example.org' } }); + await deploy(); - expect(calls.filter((call) => call.startsWith('git rm'))).toEqual([ - 'git rm --ignore-unmatch -r -f -- .gitmodules', - 'git rm --ignore-unmatch -r -f -- .' + const dir = cloneDir(); + expect(commands().slice(1, 3)).toEqual([ + `git clone ${repo} ${dir} --origin origin --branch gh-pages --single-branch --no-checkout --depth 1`, + `git clone ${repo} ${dir} --origin origin --branch gh-pages --single-branch --no-checkout` ]); }); - it('never lets git operate on a repository above the clone', async () => { - await publish(dist, { repo, user: { name: 'Bot', email: 'bot@example.org' } }); + it('rejects with the error of ls-remote for failures other than a missing branch', async () => { + const error = "fatal: Authentication failed for 'https://github.com/user/repo.git/'"; + results['ls-remote'] = { code: 128, stderr: error }; - const cloneDir = cloneDirOf(calls[0]); - for (const env of envs) { - expect(env?.GIT_CEILING_DIRECTORIES).toBe(path.dirname(cloneDir)); - } + await expect(deploy()).rejects.toThrow(error); + expect(commands()).toHaveLength(1); }); - it('disables credential helpers for a repository URL with credentials', async () => { - const repoWithToken = 'https://x-access-token:secret@github.com/user/repo.git'; + it('skips the commit when nothing changed', async () => { + results['diff-index --quiet HEAD'] = { code: 0 }; - await publish(dist, { repo: repoWithToken, user: { name: 'Bot', email: 'bot@example.org' } }); + await deploy(); - expect(calls.every((call) => call.startsWith('git -c credential.helper= '))).toBe(true); + expect(commands().some((command) => command.startsWith('git commit'))).toBe(false); + expect(commands().at(-1)).toBe('git push --tags origin gh-pages'); }); - it('skips removing files with add', async () => { - await publish(dist, { repo, add: true, user: { name: 'Bot', email: 'bot@example.org' } }); + it('reads the git user when no user is given', async () => { + const name = 'Local User'; + const email = 'local@example.org'; + results['config user.name'] = { output: name + '\n' }; + results['config user.email'] = { output: email + '\n' }; + + await publish(dist, { repo, message: 'Deploy', nojekyll: false }); - expect(calls.some((call) => call.startsWith('git rm'))).toBe(false); + expect(commands().slice(0, 2).sort()).toEqual(['git config user.email', 'git config user.name']); + expect(commands()).toContain(`git config user.email ${email}`); + expect(commands()).toContain(`git config user.name ${name}`); }); - it('skips the commit when nothing changed', async () => { - results['diff-index --quiet HEAD'] = { code: 0 }; + it('uses the configured git executable and remote', async () => { + const git = '/usr/bin/git'; + const remote = 'upstream'; - await publish(dist, { repo, user: { name: 'Bot', email: 'bot@example.org' } }); + await deploy({ git, remote }); - expect(calls.some((call) => call.startsWith('git commit'))).toBe(false); - expect(calls[calls.length - 1]).toBe('git push --tags origin gh-pages'); + expect(calls.every((call) => call.cmd === git)).toBe(true); + expect(commands()[1]).toContain(` --origin ${remote} `); + expect(commands().at(-1)).toBe(`${git} push --tags ${remote} gh-pages`); }); - it('reads the git user when no user is given', async () => { - results['config user.name'] = { output: 'Local User\n' }; - results['config user.email'] = { output: 'local@example.org\n' }; + it('pins every command for the clone to the clone, without inherited repository variables', async () => { + process.env.GIT_DIR = '/project/.git'; + process.env.GIT_INDEX_FILE = '/project/.git/index.lock'; - await publish(dist, { repo }); + await deploy(); - expect(calls.slice(0, 2).sort()).toEqual(['git config user.email', 'git config user.name']); - expect(calls).toContain('git config user.email local@example.org'); - expect(calls).toContain('git config user.name Local User'); + const dir = cloneDir(); + for (const call of calls) { + expect(call.options.env?.GIT_INDEX_FILE).toBeUndefined(); + } + expect(calls[0].options.env?.GIT_DIR).toBeUndefined(); + expect(calls[1].options.env?.GIT_DIR).toBeUndefined(); + for (const call of calls.slice(2)) { + expect(call.options.env?.GIT_DIR).toBe(path.join(dir, '.git')); + expect(call.options.env?.GIT_WORK_TREE).toBe(dir); + expect(call.args.slice(0, 2)).toEqual(['-c', `safe.directory=${dir}`]); + } }); - it('uses the configured git executable and remote', async () => { - results['config --get remote.upstream.url'] = { output: repo }; + it('disables credential helpers and keeps the token out of the clone config for a URL with a password', async () => { + const repoWithToken = 'https://x-access-token:secret@github.com/user/repo.git'; + + await deploy({ repo: repoWithToken }); + + expect(calls.every((call) => call.args[0] === '-c' && call.args[1] === 'credential.helper=')).toBe(true); + expect(commands()).toContain('git remote set-url origin https://github.com/user/repo.git'); + expect(commands()).toContain('git commit --no-verify -m Deploy'); + expect(commands().at(-1)).toBe(`git push --tags ${repoWithToken} gh-pages`); + }); + + it('keeps credential helpers for a URL with only a username', async () => { + const repoWithUser = 'https://org@dev.azure.com/org/project/_git/repo'; - await publish(dist, { repo, git: '/usr/bin/git', remote: 'upstream', user: { name: 'Bot', email: 'bot@example.org' } }); + await deploy({ repo: repoWithUser }); - expect(calls.every((call) => call.startsWith('/usr/bin/git '))).toBe(true); - expect(calls[calls.length - 1]).toBe('/usr/bin/git push --tags upstream gh-pages'); + expect(calls.some((call) => call.args.includes('credential.helper='))).toBe(false); + expect(commands().at(-1)).toBe('git push --tags origin gh-pages'); }); }); diff --git a/src/gh-pages-fork/test/publish.spec.ts b/src/gh-pages-fork/test/publish.spec.ts index 8f4fd97..84dffa8 100644 --- a/src/gh-pages-fork/test/publish.spec.ts +++ b/src/gh-pages-fork/test/publish.spec.ts @@ -17,8 +17,9 @@ describe('gh-pages-fork publish()', () => { let dist: string; let originalEnv: NodeJS.ProcessEnv; + // dotfiles/nojekyll off by default here, so the expected trees only show what a test enables function deploy(options: PublishOptions = {}, log?: (message: string) => void) { - return publish(dist, { repo: remote, user, message: 'deploy', ...options }, log); + return publish(dist, { repo: remote, user, message: 'deploy', dotfiles: false, nojekyll: false, ...options }, log); } beforeEach(async () => { @@ -204,7 +205,7 @@ describe('gh-pages-fork publish()', () => { process.chdir(tempDir); try { - await publish(dist, { repo: './remote.git', user, message: 'deploy' }); + await publish(dist, { repo: './remote.git', user, message: 'deploy', dotfiles: false, nojekyll: false }); } finally { process.chdir(cwd); } @@ -219,17 +220,17 @@ describe('gh-pages-fork publish()', () => { await Promise.all([ deploy({ branch: 'pages-a' }), - publish(other, { repo: remote, user, branch: 'pages-b', message: 'deploy' }) + publish(other, { repo: remote, user, branch: 'pages-b', message: 'deploy', nojekyll: false }) ]); expect(tree(remote, 'pages-a')).toEqual(['assets/app.js', 'index.html']); expect(tree(remote, 'pages-b')).toEqual(['other.html']); - expect(await fs.readdir(getCacheDir())).toEqual([]); + expect(await fs.readdir(getCacheDir()!)).toEqual([]); }); it('rejects when git is killed by a signal', async () => { const fakeGit = path.join(tempDir, 'fake-git'); - await fs.writeFile(fakeGit, `#!/bin/sh\nif [ "$1" = "push" ]; then kill -9 $$; fi\nexec git "$@"\n`); + await fs.writeFile(fakeGit, `#!/bin/sh\nfor arg in "$@"; do [ "$arg" = "push" ] && kill -9 $$; done\nexec git "$@"\n`); await fs.chmod(fakeGit, 0o755); await expect(deploy({ git: fakeGit })).rejects.toThrow('Process killed by SIGKILL'); diff --git a/src/test-helpers/git.ts b/src/test-helpers/git.ts index 1141e52..f30c0db 100644 --- a/src/test-helpers/git.ts +++ b/src/test-helpers/git.ts @@ -73,3 +73,56 @@ export function createMockChildProcess(): ChildProcess { stderr: new EventEmitter() }) as unknown as ChildProcess; } + +/** + * Git subcommand of an argument list, skipping leading `-c key=value` pairs. + */ +export function gitSubcommand(args: string[]): string | undefined { + return withoutConfigArgs(args)[0]; +} + +/** + * Arguments after the leading `-c key=value` pairs. + */ +export function withoutConfigArgs(args: string[]): string[] { + let i = 0; + while (args[i] === '-c') { + i += 2; + } + return args.slice(i); +} + +export interface SpawnCall { + cmd: string; + args: string[]; + options: { cwd?: string; env?: NodeJS.ProcessEnv }; +} + +export interface FakeResult { + code?: number; + output?: string; + stderr?: string; +} + +/** + * Implementation for a mocked child_process.spawn: records every call in + * `calls` and answers with `respond(call)` (default: exit code 0, no output). + */ +export function fakeSpawn(calls: SpawnCall[], respond: (call: SpawnCall) => FakeResult | undefined) { + return (cmd: string, args: string[] = [], options: SpawnCall['options'] = {}): ChildProcess => { + const call = { cmd, args, options }; + calls.push(call); + const result = respond(call) ?? {}; + const child = createMockChildProcess(); + setImmediate(() => { + if (result.stderr) { + child.stderr!.emit('data', Buffer.from(result.stderr)); + } + if (result.output) { + child.stdout!.emit('data', Buffer.from(result.output)); + } + child.emit('close', result.code ?? 0, null); + }); + return child; + }; +} From 947ca9ae631360e03d5ba2e3237d33f77c4ab583 Mon Sep 17 00:00:00 2001 From: Johannes Hoppe Date: Mon, 12 Oct 2026 21:49:08 +0200 Subject: [PATCH 12/17] fix: standalone CLI options, Node 18 support and engine edge cases - commander-fork: boolean flags accept =true/=false (--add=false turned add mode on, --dry-run=false still ran a dry run) - standalone CLI: unexpected positional arguments are an error (a directory argument was silently ignored); --dir accepts absolute paths - utils.ts loads @angular-devkit/schematics only in createHost, so the engine and the standalone CLI run on Node 18 with a fresh install - ng deploy resolves the auto-detected output directory against the workspace root instead of the current working directory - an existing 404.html in dist is kept instead of being overwritten with index.html - the dry-run output hides credentials of the repository URL - the GitHub Actions commit link in the commit message uses GITHUB_SERVER_URL (GitHub Enterprise Server) - the --no-silent deprecation warning only appears when the option is passed (schema without default; standalone CLI --no-silent) - ng add keeps an existing angular-cli-ghpages deploy target and explains an existing deploy target of another builder; the workspace write is awaited - deploy/schema.d.ts ships with the package (the published typings import it) - remote URL discovery uses gh-pages-fork getRepo directly --- src/angular-cli-ghpages | 11 +++--- src/commander-fork/README.md | 6 ++++ src/commander-fork/index.js | 26 ++++++++++++-- src/deploy/actions.spec.ts | 18 ++++++---- src/deploy/actions.ts | 3 ++ src/deploy/schema.json | 3 +- src/engine/engine.prepare-options-helpers.ts | 36 +++++++------------- src/engine/engine.ts | 15 ++++---- src/ng-add.ts | 23 +++++++++---- src/scripts/postbuild.js | 1 + src/utils.ts | 14 ++++++-- 11 files changed, 101 insertions(+), 55 deletions(-) diff --git a/src/angular-cli-ghpages b/src/angular-cli-ghpages index b1b8840..51e0a5d 100755 --- a/src/angular-cli-ghpages +++ b/src/angular-cli-ghpages @@ -100,11 +100,14 @@ var consoleLogger = { // --no-notfound becomes notfound: false // --no-nojekyll becomes nojekyll: false -// console.log('*** dotfiles', commander.dotfiles) -// console.log('*** notfound', commander.notfound) -// console.log('*** nojekyll', commander.nojekyll) +if (commander.args.length > 0) { + consoleLogger.error('❌ Unexpected argument(s): ' + commander.args.join(' ')); + consoleLogger.error(' Use --dir= to choose the directory to deploy.'); + process.exit(1); +} -var dir = path.join(process.cwd(), commander.dir); +// absolute paths (also Windows drive paths) stay as they are +var dir = path.resolve(process.cwd(), commander.dir); engine.run(dir, commander, consoleLogger).catch(function (error) { consoleLogger.error('❌ An error occurred when trying to deploy:'); diff --git a/src/commander-fork/README.md b/src/commander-fork/README.md index 2558c3d..3e47d7e 100644 --- a/src/commander-fork/README.md +++ b/src/commander-fork/README.md @@ -43,6 +43,12 @@ This fork intentionally removes features (subcommands, actions, etc.) and preser - Custom event listeners (except version and help) - Most EventEmitter functionality +### Fixes on top of commander v3.0.2 πŸ”§ + +1. **Negate detection**: only real `--no-` flags count as negated options +2. **Version flags**: short-only (`-v`) and custom version flags work +3. **Boolean flags with a value**: `--add=true` sets the flag, `--add=false` leaves it unset, any other value is an error (commander v3.0.2 turned the value into a positional argument and set the flag) + ## Compatibility Matrix | Capability | Commander v3.0.2 | commander-fork | diff --git a/src/commander-fork/index.js b/src/commander-fork/index.js index a83e232..a6bc0c7 100644 --- a/src/commander-fork/index.js +++ b/src/commander-fork/index.js @@ -21,6 +21,11 @@ * - .opts() - get parsed options * - Property access (e.g., program.dir) * - Basic --help output + * + * Fixes on top of commander v3.0.2 (marked FORK FIX 1/3 to 3/3 below): + * 1. Tightened negate detection for --no- flags + * 2. Short-only and custom version flags + * 3. Boolean flags accept "=true" / "=false" */ /** @@ -67,7 +72,7 @@ function Option(flags, description) { this.flags = flags; this.required = flags.indexOf('<') >= 0; this.optional = flags.indexOf('[') >= 0; - // FORK FIX 1/2: Tightened negate detection to avoid false positives + // FORK FIX 1/3: Tightened negate detection to avoid false positives // Original: this.negate = flags.indexOf('-no-') !== -1; // Problem: Would match '-no-' anywhere, e.g., '--enable-notifications' would incorrectly match // Fix: Only match '--no-' at word boundaries (start of string or after delimiter) @@ -334,7 +339,22 @@ Command.prototype.normalize = function(args) { }); } } else if (/^--/.test(arg) && ~(index = arg.indexOf('='))) { - ret.push(arg.slice(0, index), arg.slice(index + 1)); + // FORK FIX 3/3: Boolean flags accept "=true" / "=false" + // Original: "--add=false" was split into "--add" plus a positional "false", + // so the flag was set although the user asked for the opposite. + // Fix: "=true" sets the flag, "=false" leaves it unset, any other value is an error. + opt = this.optionFor(arg.slice(0, index)); + if (opt && !opt.required && !opt.optional) { + var value = arg.slice(index + 1); + if (value === 'true') { + ret.push(arg.slice(0, index)); + } else if (value !== 'false') { + console.error("error: option '%s' doesn't take a value (use '%s' or '%s=false')", opt.flags, arg.slice(0, index), arg.slice(0, index)); + process.exit(1); + } + } else { + ret.push(arg.slice(0, index), arg.slice(index + 1)); + } } else { ret.push(arg); } @@ -506,7 +526,7 @@ Command.prototype.version = function(str, flags, description) { flags = flags || '-V, --version'; description = description || 'output the version number'; var versionOption = new Option(flags, description); - // FORK FIX 2/2: Support short-only and custom version flags properly + // FORK FIX 2/3: Support short-only and custom version flags properly // Original: this._versionOptionName = versionOption.long.substr(2) || 'version'; // Problem: .substr(2) on '-v' gives empty string, falls back to 'version', // but event is 'option:v' (or 'option:-v'), so listener never fires. diff --git a/src/deploy/actions.spec.ts b/src/deploy/actions.spec.ts index 5abbc5d..b93bf92 100644 --- a/src/deploy/actions.spec.ts +++ b/src/deploy/actions.spec.ts @@ -9,8 +9,12 @@ import { JsonObject, logging } from '@angular-devkit/core'; import { BuildTarget } from '../interfaces'; import { Schema } from './schema'; +import * as path from 'path'; + import deploy from './actions'; +const WORKSPACE_ROOT = path.resolve('/workspace'); + interface EngineHost { run(dir: string, options: Schema, logger: logging.LoggerApi): Promise; } @@ -58,7 +62,7 @@ describe('Deploy Angular apps', () => { const spy = vi.spyOn(mockEngine, 'run'); await deploy(mockEngine, context, BUILD_TARGET, {}); - expect(spy).toHaveBeenCalledWith('dist/some-folder/browser', {}, context.logger); + expect(spy).toHaveBeenCalledWith(path.join(WORKSPACE_ROOT, 'dist/some-folder/browser'), {}, context.logger); }); describe('error handling', () => { @@ -116,27 +120,27 @@ describe('Deploy Angular apps', () => { it('uses default path when outputPath is undefined (Angular 20+)', async () => { const dir = await captureDir(undefined); - expect(dir).toBe(`dist/${PROJECT}/browser`); + expect(dir).toBe(path.join(WORKSPACE_ROOT, `dist/${PROJECT}/browser`)); }); it('appends /browser when outputPath is string (Angular 18-19)', async () => { const dir = await captureDir('dist/my-app'); - expect(dir).toBe('dist/my-app/browser'); + expect(dir).toBe(path.join(WORKSPACE_ROOT, 'dist/my-app/browser')); }); it('uses base/browser when outputPath is object', async () => { const dir = await captureDir({ base: 'dist/my-app', browser: 'browser' }); - expect(dir).toBe('dist/my-app/browser'); + expect(dir).toBe(path.join(WORKSPACE_ROOT, 'dist/my-app/browser')); }); it('defaults browser to "browser" when omitted from object', async () => { const dir = await captureDir({ base: 'dist/my-app' }); - expect(dir).toBe('dist/my-app/browser'); + expect(dir).toBe(path.join(WORKSPACE_ROOT, 'dist/my-app/browser')); }); it('uses base only when browser is empty string (SPA mode)', async () => { const dir = await captureDir({ base: 'dist/my-app', browser: '' }); - expect(dir).toBe('dist/my-app'); + expect(dir).toBe(path.join(WORKSPACE_ROOT, 'dist/my-app')); }); }); }); @@ -156,7 +160,7 @@ const initMocks = () => { currentDirectory: 'cwd', id: 1, logger: new logging.NullLogger(), - workspaceRoot: 'cwd', + workspaceRoot: WORKSPACE_ROOT, addTeardown: _ => {}, validateOptions: (_options: JsonObject) => Promise.resolve({} as T), getBuilderNameForTarget: () => Promise.resolve(''), diff --git a/src/deploy/actions.ts b/src/deploy/actions.ts index d43e4a1..63d1e80 100644 --- a/src/deploy/actions.ts +++ b/src/deploy/actions.ts @@ -86,6 +86,9 @@ export default async function deploy( `Expected string or {base, browser} object.` ); } + + // outputPath is relative to the workspace root, not to the current working directory + dir = path.resolve(context.workspaceRoot, dir); } await engine.run( diff --git a/src/deploy/schema.json b/src/deploy/schema.json index c4cad1f..034efd8 100644 --- a/src/deploy/schema.json +++ b/src/deploy/schema.json @@ -49,8 +49,7 @@ }, "noSilent": { "type": "boolean", - "description": "Deprecated! This parameter is no longer needed. It will be ignored.", - "default": false + "description": "Deprecated! This parameter is no longer needed. It will be ignored." }, "noDotfiles": { "type": "boolean", diff --git a/src/engine/engine.prepare-options-helpers.ts b/src/engine/engine.prepare-options-helpers.ts index 5e22e9a..e7504d6 100644 --- a/src/engine/engine.prepare-options-helpers.ts +++ b/src/engine/engine.prepare-options-helpers.ts @@ -67,10 +67,12 @@ export function handleUserCredentials( } /** - * Warn if deprecated parameters are used + * Warn if deprecated parameters are used: `noSilent` from ng deploy (the schema + * has no default, so it is only set when passed), `silent: false` from the + * standalone CLI's `--no-silent` flag. */ -export function warnDeprecatedParameters(origOptions: Schema, logger: logging.LoggerApi): void { - if (origOptions.noSilent !== undefined) { +export function warnDeprecatedParameters(origOptions: Schema & { silent?: boolean }, logger: logging.LoggerApi): void { + if (origOptions.noSilent !== undefined || origOptions.silent === false) { logger.warn( 'The --no-silent parameter is deprecated and no longer needed. ' + 'Verbose logging is now always enabled. This parameter will be ignored.' @@ -113,7 +115,9 @@ export function appendCIMetadata(options: PreparedOptions): void { if (process.env.GITHUB_ACTIONS) { options.message += '\n\n' + - 'Triggered by commit: https://github.com/' + + 'Triggered by commit: ' + + (process.env.GITHUB_SERVER_URL || 'https://github.com') + + '/' + (process.env.GITHUB_REPOSITORY || '') + '/commit/' + (process.env.GITHUB_SHA || '') + @@ -134,17 +138,14 @@ export function appendCIMetadata(options: PreparedOptions): void { * Also handles legacy GH_TOKEN placeholder replacement for backwards compatibility. */ export async function injectTokenIntoRepoUrl(options: PreparedOptions): Promise { - // NEW in 0.6.2: always discover remote URL (if not set) - // this allows us to inject tokens from environment even if `--repo` is not set manually + // Discover the remote URL when --repo isn't set, so tokens from the environment + // are injected into it as well if (!options.repo) { - options.repo = await getRemoteUrl(options); + options.repo = await getRepo({ git: options.git, remote: options.remote }); } - // for backwards compatibility only, - // in the past --repo=https://GH_TOKEN@github.com//.git was advised - // - // this replacement was also used to inject other tokens into the URL, - // so it should only be removed with the next major version + // --repo=https://GH_TOKEN@github.com//.git gets the token + // in place of the placeholder; CI configurations use this to inject other tokens, too if ( process.env.GH_TOKEN && options.repo && @@ -173,14 +174,3 @@ export async function injectTokenIntoRepoUrl(options: PreparedOptions): Promise< } } } - -/** - * Get the URL of `options.remote` from the git repository in the current working directory. - * - * process.cwd() is the directory from which ng deploy was invoked, i.e. the project root. - * - * Exported for testing - internal use only - */ -export async function getRemoteUrl(options: Schema & { git?: string; remote?: string }): Promise { - return getRepo({ git: options.git, remote: options.remote }); -} diff --git a/src/engine/engine.ts b/src/engine/engine.ts index e76c7f1..b56fa6f 100644 --- a/src/engine/engine.ts +++ b/src/engine/engine.ts @@ -1,4 +1,5 @@ import {logging} from '@angular-devkit/core'; +import {constants as fsConstants} from 'fs'; import * as fs from 'fs/promises'; import * as path from 'path'; @@ -109,8 +110,13 @@ async function createNotFoundFile( const indexHtml = path.join(dir, 'index.html'); const notFoundFile = path.join(dir, '404.html'); + if (await pathExists(notFoundFile)) { + logger.info('404.html already exists in the dist folder and is kept.'); + return; + } + try { - await fs.copyFile(indexHtml, notFoundFile); + await fs.copyFile(indexHtml, notFoundFile, fsConstants.COPYFILE_EXCL); logger.info('404.html file created'); } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -126,16 +132,13 @@ async function publishViaGhPages( logger: logging.LoggerApi ) { if (options.dryRun) { - // Note: options.repo may contain auth tokens. This is acceptable because: - // 1. CI environments (GitHub Actions, etc.) automatically mask secrets in logs - // 2. Dry-run is typically used for debugging, where seeing the full URL helps - // 3. Local dry-runs don't persist logs + // options.repo may contain an injected token, so its credentials are hidden logger.info( `Dry-run / SKIPPED: publishing folder '${dir}' with the following options: ` + JSON.stringify( { dir, - repo: options.repo || 'current working directory (which must be a git repo in this case) will be used to commit & push', + repo: options.repo ? ghpages.redact(options.repo) : 'current working directory (which must be a git repo in this case) will be used to commit & push', remote: options.remote, message: options.message, branch: options.branch, diff --git a/src/ng-add.ts b/src/ng-add.ts index f9f85ae..2bf1503 100644 --- a/src/ng-add.ts +++ b/src/ng-add.ts @@ -67,13 +67,22 @@ export const ngAdd = (options: NgAddOptions) => async ( ); } - project.targets.add({ - name: 'deploy', - builder: 'angular-cli-ghpages:deploy', - options: {} - }); - - workspaces.writeWorkspace(workspace, host); + const existingDeployTarget = project.targets.get('deploy'); + if (existingDeployTarget?.builder === 'angular-cli-ghpages:deploy') { + context.logger.info(`The project "${options.project}" already has a deploy target for angular-cli-ghpages.`); + } else if (existingDeployTarget) { + throw new SchematicsException( + `The project "${options.project}" already has a deploy target with the builder "${existingDeployTarget.builder}". ` + + `Remove it from angular.json to use angular-cli-ghpages.` + ); + } else { + project.targets.add({ + name: 'deploy', + builder: 'angular-cli-ghpages:deploy', + options: {} + }); + await workspaces.writeWorkspace(workspace, host); + } // Show next steps context.logger.info(''); diff --git a/src/scripts/postbuild.js b/src/scripts/postbuild.js index fcf57c2..03d2f53 100644 --- a/src/scripts/postbuild.js +++ b/src/scripts/postbuild.js @@ -10,6 +10,7 @@ const files = [ 'package.json', 'angular-cli-ghpages', 'deploy/schema.json', + 'deploy/schema.d.ts', 'commander-fork/index.js', 'gh-pages-fork/LICENSE' ]; diff --git a/src/utils.ts b/src/utils.ts index cd7e38c..f4190c4 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,9 +1,9 @@ -import { workspaces } from '@angular-devkit/core'; -import { SchematicsException, Tree } from '@angular-devkit/schematics'; +import type { workspaces } from '@angular-devkit/core'; +import type { Tree } from '@angular-devkit/schematics'; import * as fs from 'fs/promises'; /** - * Check if a path exists (replacement for fs-extra.pathExists) + * Check if a path exists */ export async function pathExists(filePath: string): Promise { try { @@ -14,11 +14,19 @@ export async function pathExists(filePath: string): Promise { } } +/** + * Workspace host for a schematic tree (ng add). + * + * @angular-devkit/schematics is only loaded here: the deploy engine and the + * standalone CLI import this module too, and must run on every supported + * Node.js version without the schematics dependencies. + */ export function createHost(tree: Tree): workspaces.WorkspaceHost { return { async readFile(path: string): Promise { const data = tree.read(path); if (!data) { + const { SchematicsException } = await import('@angular-devkit/schematics'); throw new SchematicsException('File not found.'); } return data.toString('utf-8'); From 1390bcf3e200fd2d72d17ff9a9b8d9fb4a59cea9 Mon Sep 17 00:00:00 2001 From: Johannes Hoppe Date: Mon, 12 Oct 2026 21:49:08 +0200 Subject: [PATCH 13/17] test: cover the new fixes, isolate the git environment and run the CLI on all Node versions - vitest setup: empty global git config, no system config, no inherited repository variables, tokens or CI variables - real-git tests use file:// remotes (shallow clones are real) and bare repositories with main as default branch - new real-git tests: more files than the copy concurrency, nested dotfiles and .well-known, non-default remote, transient shallow clone failure keeps history, global gitignore, basePath that is a file, git identity from the environment, stale clone removal, failing cleanup (separate spec) - standalone CLI: argument validation, --add=true/false, absolute --dir and a real deploy - engine: kept 404.html, dry-run token redaction, GitHub Enterprise links, --no-silent of the standalone CLI, exact error preservation, ng add with an existing deploy target - commander-fork: tests for boolean flags with a value - behavior and git-commands specs share one spawn mock - CI: the packed package is installed fresh and deploys with the standalone CLI on Node 18.19.1, 20, 22 and 24 --- .github/workflows/main.yml | 36 ++++++ .../test/test.fork-fixes.spec.ts | 58 ++++++++- src/engine/engine-filesystem.spec.ts | 13 +- src/engine/engine.gh-pages-cleanup.spec.ts | 2 +- src/engine/engine.gh-pages-clone.spec.ts | 16 +++ .../engine.gh-pages-integration.spec.ts | 21 ++++ .../engine.prepare-options-helpers.spec.ts | 52 +++++++- src/engine/engine.spec.ts | 4 +- src/gh-pages-fork/test/cache-dir.spec.ts | 18 ++- .../test/cleanup-failure.spec.ts | 68 +++++++++++ src/gh-pages-fork/test/publish.spec.ts | 111 +++++++++++++++++- src/ng-add.spec.ts | 26 ++++ .../builder-passthrough.spec.ts | 4 +- src/parameter-tests/cli-e2e.spec.ts | 95 ++++++++++++++- src/test-helpers/git.ts | 13 +- src/test-helpers/vitest-setup.ts | 26 ++++ src/vitest.config.mts | 3 +- 17 files changed, 540 insertions(+), 26 deletions(-) create mode 100644 src/gh-pages-fork/test/cleanup-failure.spec.ts create mode 100644 src/test-helpers/vitest-setup.ts diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0ca8eb9..d2ed515 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -34,6 +34,42 @@ jobs: path: src/dist retention-days: 1 + # Install the built package fresh (like `npx angular-cli-ghpages` in a + # non-Angular project) on every Node.js version that engines allows and + # deploy with the standalone CLI to a local bare repository. + test-node-versions: + needs: build-and-test + runs-on: ubuntu-latest + strategy: + matrix: + node-version: ['18.19.1', '20', '22', '24'] + + steps: + - uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node-version }} + + - name: Download build artifact + uses: actions/download-artifact@v8 + with: + name: dist + path: dist + + - name: Install the package + run: | + (cd dist && npm pack --pack-destination /tmp) + mkdir /tmp/site && cd /tmp/site + npm init -y > /dev/null + npm install --no-fund --no-audit /tmp/angular-cli-ghpages-*.tgz + + - name: Deploy with the standalone CLI + run: | + cd /tmp/site + git init --bare --initial-branch=main /tmp/remote.git + mkdir public && echo '

site

' > public/index.html + node node_modules/angular-cli-ghpages/angular-cli-ghpages --dir=public --repo=/tmp/remote.git --name="CI Bot" --email="ci@test.local" + git --git-dir=/tmp/remote.git ls-tree --name-only gh-pages | sort | tr '\n' ' ' | grep -x '.nojekyll 404.html index.html ' + # Test ng add and ng deploy across multiple Angular versions (sequentially) # This catches breaking changes in angular.json structure # Each test creates its own git repo and deploys locally to verify the full flow diff --git a/src/commander-fork/test/test.fork-fixes.spec.ts b/src/commander-fork/test/test.fork-fixes.spec.ts index bef6bea..4e4f6d3 100644 --- a/src/commander-fork/test/test.fork-fixes.spec.ts +++ b/src/commander-fork/test/test.fork-fixes.spec.ts @@ -1,11 +1,12 @@ /** * Tests for angular-cli-ghpages fork-specific fixes and features * - * This file contains tests for the 2 intentional improvements we made + * This file contains tests for the 3 intentional improvements we made * to commander v3.0.2 for angular-cli-ghpages: * - * FIX 1/2: Tightened negate detection regex - * FIX 2/2: Support for short-only and custom version flags + * FIX 1/3: Tightened negate detection regex + * FIX 2/3: Support for short-only and custom version flags + * FIX 3/3: Boolean flags accept "=true" / "=false" * * These tests do NOT exist in the original commander v3.0.2. */ @@ -234,3 +235,54 @@ describe('Fork Fix 2: Version short-only and custom flags', () => { }); export {}; + +describe('Fork Fix 3: Boolean flags with a value', () => { + let exitSpy: MockInstance; + let errorSpy: MockInstance; + + beforeEach(() => { + exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit'); + }); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + exitSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + function parse(...args: string[]) { + const program = new commander.Command(); + program + .option('-a, --add', 'add') + .option('--no-dotfiles', 'no dotfiles') + .option('-d, --dir ', 'dir'); + program.parse(['node', 'test', ...args]); + return program; + } + + it('sets the flag for =true', () => { + expect(parse('--add=true').add).toBe(true); + }); + + it('leaves the flag unset for =false', () => { + const program = parse('--add=false'); + expect(program.add).toBeUndefined(); + expect(program.args).toEqual([]); + }); + + it('handles negated flags', () => { + expect(parse('--no-dotfiles=true').dotfiles).toBe(false); + expect(parse('--no-dotfiles=false').dotfiles).toBe(true); + }); + + it('rejects other values', () => { + expect(() => parse('--add=yes')).toThrow('process.exit'); + expect(errorSpy.mock.calls[0][0]).toContain("doesn't take a value"); + }); + + it('keeps splitting options with an argument', () => { + expect(parse('--dir=dist/app').dir).toBe('dist/app'); + }); +}); diff --git a/src/engine/engine-filesystem.spec.ts b/src/engine/engine-filesystem.spec.ts index bee9fa0..ebc06a3 100644 --- a/src/engine/engine-filesystem.spec.ts +++ b/src/engine/engine-filesystem.spec.ts @@ -120,6 +120,17 @@ describe('engine - real filesystem tests', () => { expect(exists).toBe(false); }); + it('should keep an existing 404.html', async () => { + const notFoundContent = 'Custom 404'; + await fs.writeFile(path.join(testDir, 'index.html'), 'Index'); + await fs.writeFile(path.join(testDir, '404.html'), notFoundContent); + + await engine.run(testDir, { notfound: true, nojekyll: false, dotfiles: true }, logger); + + expect(await fs.readFile(path.join(testDir, '404.html'), 'utf-8')).toBe(notFoundContent); + expect(loggerInfoSpy).toHaveBeenCalledWith('404.html already exists in the dist folder and is kept.'); + }); + it('should NOT create 404.html when dry-run is true', async () => { const indexPath = path.join(testDir, 'index.html'); await fs.writeFile(indexPath, 'Test'); @@ -205,7 +216,7 @@ describe('engine - real filesystem tests', () => { expect(capturedOptions.cname).toBe(testDomain); expect(capturedOptions.nojekyll).toBe(true); - // Verify 404.html is still created by us (not delegated to publish()) + // 404.html is created by the engine in dist, not by publish() const notFoundPath = path.join(testDir, '404.html'); expect(await pathExists(notFoundPath)).toBe(true); }); diff --git a/src/engine/engine.gh-pages-cleanup.spec.ts b/src/engine/engine.gh-pages-cleanup.spec.ts index 60c8eb4..d662277 100644 --- a/src/engine/engine.gh-pages-cleanup.spec.ts +++ b/src/engine/engine.gh-pages-cleanup.spec.ts @@ -2,7 +2,7 @@ * End-to-end integration test for issue #204 β€” REAL git, REAL gh-pages-fork, REAL filesystem. * * No mocks. This test proves that a deploy produces a clean gh-pages commit - * when the branch previously contained dotfiles and submodule gitlinks. + * when the branch contains dotfiles and submodule gitlinks before the deploy. * * Flow: * 1. Create a local bare git repo (serves as "remote"). diff --git a/src/engine/engine.gh-pages-clone.spec.ts b/src/engine/engine.gh-pages-clone.spec.ts index 0520aa4..40def42 100644 --- a/src/engine/engine.gh-pages-clone.spec.ts +++ b/src/engine/engine.gh-pages-clone.spec.ts @@ -67,4 +67,20 @@ describe('gh-pages-fork temporary clone', () => { expect(await fs.readdir(cacheDir)).toEqual([]); }); + + it('should remove stale clones and clones of older versions, but keep recent clones', async () => { + await fs.mkdir(cacheDir, { recursive: true }); + const stale = path.join(cacheDir, 'clone-stale'); + const recent = path.join(cacheDir, 'clone-recent'); + const legacy = path.join(cacheDir, 'https!x-access-token!secret@github.com!owner!repo.git'); + for (const dir of [stale, recent, legacy]) { + await fs.mkdir(dir); + } + const sevenHoursAgo = new Date(Date.now() - 7 * 60 * 60 * 1000); + await fs.utimes(stale, sevenHoursAgo, sevenHoursAgo); + + await ghPages.publish(basePath, { repo: bareRepoPath, user }); + + expect(await fs.readdir(cacheDir)).toEqual(['clone-recent']); + }); }); diff --git a/src/engine/engine.gh-pages-integration.spec.ts b/src/engine/engine.gh-pages-integration.spec.ts index d6a7a2f..40b1d22 100644 --- a/src/engine/engine.gh-pages-integration.spec.ts +++ b/src/engine/engine.gh-pages-integration.spec.ts @@ -377,6 +377,18 @@ describe('engine - gh-pages-fork integration', () => { expect.stringContaining(testDir) ); }); + + it('should hide an injected token in the dry-run output', async () => { + process.env.GH_TOKEN = 'secret-token'; + const testLogger = new logging.Logger('test'); + const infoSpy = vi.spyOn(testLogger, 'info'); + + await engine.run('/test/dist', { dryRun: true, repo: 'https://github.com/test/repo.git' }, testLogger); + + const output = infoSpy.mock.calls.map(([message]) => message).join('\n'); + expect(output).toContain('"repo": "https://***@github.com/test/repo.git"'); + expect(output).not.toContain('secret-token'); + }); }); describe('options transformation verification', () => { @@ -499,6 +511,15 @@ describe('engine - gh-pages-fork integration', () => { ).resolves.toBeUndefined(); }); + it('should preserve the exact error through rejection', async () => { + const detailedError = new Error('Remote rejected the push: pre-receive hook declined (refs/heads/gh-pages)'); + ghpagesPublishSpy.mockRejectedValue(detailedError); + + await expect( + engine.run('/test/dist', { dotfiles: true, notfound: true, nojekyll: true }, logger) + ).rejects.toBe(detailedError); + }); + it('should reject when publish() rejects', async () => { const publishError = new Error('Git push failed'); ghpagesPublishSpy.mockRejectedValue(publishError); diff --git a/src/engine/engine.prepare-options-helpers.spec.ts b/src/engine/engine.prepare-options-helpers.spec.ts index 0e9cd63..3aca5b1 100644 --- a/src/engine/engine.prepare-options-helpers.spec.ts +++ b/src/engine/engine.prepare-options-helpers.spec.ts @@ -12,6 +12,7 @@ import { logging } from '@angular-devkit/core'; import { MockInstance } from 'vitest'; import * as helpers from './engine.prepare-options-helpers'; +import { getRepo } from '../gh-pages-fork/lib'; import { Schema } from '../deploy/schema'; describe('prepareOptions helpers - intensive tests', () => { @@ -238,6 +239,21 @@ describe('prepareOptions helpers - intensive tests', () => { expect(warnSpy).toHaveBeenCalledWith(expectedWarning); }); + it('should warn for --no-silent of the standalone CLI (silent: false)', () => { + const origOptions = { silent: false }; + const expectedWarning = 'The --no-silent parameter is deprecated and no longer needed. Verbose logging is now always enabled. This parameter will be ignored.'; + + helpers.warnDeprecatedParameters(origOptions, testLogger); + + expect(warnSpy).toHaveBeenCalledWith(expectedWarning); + }); + + it('should NOT warn for the standalone CLI default (silent: true)', () => { + helpers.warnDeprecatedParameters({ silent: true }, testLogger); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + it('should NOT warn when noSilent is undefined', () => { const origOptions: Schema = {}; @@ -321,6 +337,30 @@ describe('prepareOptions helpers - intensive tests', () => { expect(options.message).toBe(expectedMessage); }); + it('should use GITHUB_SERVER_URL for both links (GitHub Enterprise Server)', () => { + process.env.GITHUB_ACTIONS = 'true'; + process.env.GITHUB_SERVER_URL = 'https://ghes.example.com'; + process.env.GITHUB_REPOSITORY = 'org/repo'; + process.env.GITHUB_SHA = 'abc123'; + process.env.GITHUB_RUN_ID = '42'; + + const options: helpers.PreparedOptions = { + message: baseMessage, + dotfiles: true, + notfound: true, + nojekyll: true + }; + + helpers.appendCIMetadata(options); + + const expectedMessage = + 'Deploy to gh-pages\n\n' + + 'Triggered by commit: https://ghes.example.com/org/repo/commit/abc123\n' + + 'GitHub Actions build: https://ghes.example.com/org/repo/actions/runs/42'; + + expect(options.message).toBe(expectedMessage); + }); + it('should NOT modify message when no CI env is set', () => { const options: helpers.PreparedOptions = { message: baseMessage, @@ -520,7 +560,7 @@ describe('prepareOptions helpers - intensive tests', () => { }); - describe('getRemoteUrl', () => { + describe('remote URL discovery (gh-pages-fork getRepo)', () => { /** * Environment assumptions for this test: * - Tests must be run from a git clone of angular-schule/angular-cli-ghpages @@ -537,12 +577,12 @@ describe('prepareOptions helpers - intensive tests', () => { const { execSync } = require('child_process'); const expectedUrl = execSync('git config --get remote.origin.url', { encoding: 'utf8' }).trim(); - // Verify getRemoteUrl returns the same value - const result = await helpers.getRemoteUrl(options); + // Verify getRepo returns the same value + const result = await getRepo(options); expect(result).toBe(expectedUrl); // Verify consistency across multiple calls - const result2 = await helpers.getRemoteUrl(options); + const result2 = await getRepo(options); expect(result2).toBe(expectedUrl); expect(result).toBe(result2); }); @@ -552,7 +592,7 @@ describe('prepareOptions helpers - intensive tests', () => { remote: 'nonexistent-remote-12345' // Remote that definitely doesn't exist }; - await expect(helpers.getRemoteUrl(options)) + await expect(getRepo(options)) .rejects .toThrow('Failed to get remote.nonexistent-remote-12345.url (task must either be run in a git repository with a configured nonexistent-remote-12345 remote or must be configured with the "repo" option).'); }); @@ -568,7 +608,7 @@ describe('prepareOptions helpers - intensive tests', () => { const options = { remote: 'origin' }; // Same error for both "not in a git repo" and "remote doesn't exist" - await expect(helpers.getRemoteUrl(options)) + await expect(getRepo(options)) .rejects .toThrow('Failed to get remote.origin.url (task must either be run in a git repository with a configured origin remote or must be configured with the "repo" option).'); } finally { diff --git a/src/engine/engine.spec.ts b/src/engine/engine.spec.ts index 80cde37..1300c60 100644 --- a/src/engine/engine.spec.ts +++ b/src/engine/engine.spec.ts @@ -102,7 +102,6 @@ describe('engine', () => { ); }); - // NEW in 0.6.2: always discover remote URL (if not set) /** * Environment assumptions for this test: * - Tests must be run from a git clone of angular-schule/angular-cli-ghpages @@ -111,8 +110,7 @@ describe('engine', () => { * * If run from a bare copy of files (no .git), this test will fail by design. */ - // this allows us to inject tokens from environment even if --repo is not set manually - // it uses gh-pages lib directly for this + // the remote url is discovered, so tokens from the environment get injected even without --repo it('should discover the remote url, if no --repo is set', async () => { const options = {}; const finalOptions = await engine.prepareOptions(options, logger); diff --git a/src/gh-pages-fork/test/cache-dir.spec.ts b/src/gh-pages-fork/test/cache-dir.spec.ts index bf52a60..74c4d38 100644 --- a/src/gh-pages-fork/test/cache-dir.spec.ts +++ b/src/gh-pages-fork/test/cache-dir.spec.ts @@ -2,7 +2,7 @@ import * as fs from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; -import { getCacheDir } from '../lib'; +import { getCacheDir, redact } from '../lib'; describe('gh-pages-fork getCacheDir()', () => { let tempDir: string; @@ -59,3 +59,19 @@ describe('gh-pages-fork getCacheDir()', () => { expect(getCacheDir(tempDir)).toBe(path.join(tempDir, 'node_modules', '.cache', 'gh-pages')); }); }); + +describe('gh-pages-fork redact()', () => { + it('hides a token and a password', () => { + expect(redact('https://x-access-token:secret@github.com/o/r.git')).toBe('https://***@github.com/o/r.git'); + }); + + it('hides a username-only userinfo (it may be a token)', () => { + expect(redact('https://ghp_token@github.com/o/r.git')).toBe('https://***@github.com/o/r.git'); + }); + + it('keeps URLs without credentials, scp-like addresses and local paths', () => { + for (const repo of ['https://github.com/o/r.git', 'git@github.com:o/r.git', '/srv/git/r.git', '../r.git']) { + expect(redact(repo)).toBe(repo); + } + }); +}); diff --git a/src/gh-pages-fork/test/cleanup-failure.spec.ts b/src/gh-pages-fork/test/cleanup-failure.spec.ts new file mode 100644 index 0000000..6734358 --- /dev/null +++ b/src/gh-pages-fork/test/cleanup-failure.spec.ts @@ -0,0 +1,68 @@ +/** + * A failing removal of the temporary clone never decides the deploy result. + * fs/promises is mocked to make the removal fail (e.g. a virus scanner holding a file on Windows). + */ + +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; + +const { rmFailures } = vi.hoisted(() => ({ rmFailures: new Set() })); + +vi.mock('fs/promises', async () => { + const actual = await vi.importActual('fs/promises'); + return { + ...actual, + rm: vi.fn(async (target: string, options?: Parameters[1]) => { + if ([...rmFailures].some((prefix) => path.basename(target).startsWith(prefix))) { + throw Object.assign(new Error(`EBUSY: resource busy or locked, rmdir '${target}'`), { code: 'EBUSY' }); + } + return actual.rm(target, options); + }) + }; +}); + +import { createBareRepo, fileUrl, tree } from '../../test-helpers/git'; +import { publish } from '../lib'; + +describe('gh-pages-fork publish() with a failing cleanup', () => { + const user = { name: 'Deploy Bot', email: 'bot@example.org' }; + let tempDir: string; + let remote: string; + let dist: string; + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(async () => { + originalEnv = { ...process.env }; + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gh-pages-fork-cleanup-')); + process.env.CACHE_DIR = path.join(tempDir, 'cache'); + remote = createBareRepo(path.join(tempDir, 'remote.git')); + dist = path.join(tempDir, 'dist'); + await fs.mkdir(dist); + await fs.writeFile(path.join(dist, 'index.html'), '

test

'); + rmFailures.add('clone-'); + }); + + afterEach(async () => { + rmFailures.clear(); + process.env = originalEnv; + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('resolves after a successful push and logs the cleanup error', async () => { + const messages: string[] = []; + + await publish(dist, { repo: fileUrl(remote), user, nojekyll: false }, (message) => messages.push(message)); + + expect(tree(remote)).toEqual(['index.html']); + expect(messages.at(-1)).toMatch(/^Could not remove the temporary clone .*clone-.*: EBUSY: resource busy or locked/); + }); + + it('rejects with the git error, not the cleanup error, when the push fails', async () => { + const hook = path.join(remote, 'hooks', 'pre-receive'); + await fs.writeFile(hook, '#!/bin/sh\necho "rejected by hook" >&2\nexit 1\n'); + await fs.chmod(hook, 0o755); + + await expect(publish(dist, { repo: fileUrl(remote), user, nojekyll: false })).rejects.toThrow('rejected by hook'); + }); +}); diff --git a/src/gh-pages-fork/test/publish.spec.ts b/src/gh-pages-fork/test/publish.spec.ts index 84dffa8..0527001 100644 --- a/src/gh-pages-fork/test/publish.spec.ts +++ b/src/gh-pages-fork/test/publish.spec.ts @@ -6,7 +6,7 @@ import * as fs from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; -import { commitCount, createBareRepo, git, seedBranch, tree } from '../../test-helpers/git'; +import { commitCount, createBareRepo, fileUrl, git, seedBranch, tree } from '../../test-helpers/git'; import { getCacheDir, publish, PublishOptions } from '../lib'; const user = { name: 'Deploy Bot', email: 'bot@example.org' }; @@ -19,7 +19,8 @@ describe('gh-pages-fork publish()', () => { // dotfiles/nojekyll off by default here, so the expected trees only show what a test enables function deploy(options: PublishOptions = {}, log?: (message: string) => void) { - return publish(dist, { repo: remote, user, message: 'deploy', dotfiles: false, nojekyll: false, ...options }, log); + // file:// URL: git honors --depth only for real URLs, so clones are really shallow + return publish(dist, { repo: fileUrl(remote), user, message: 'deploy', dotfiles: false, nojekyll: false, ...options }, log); } beforeEach(async () => { @@ -155,13 +156,14 @@ describe('gh-pages-fork publish()', () => { it('hides credentials of the repository URL in log messages', async () => { const messages: string[] = []; - const repoWithToken = 'https://x-access-token:secret123@example.invalid/user/repo.git'; + // port 9 on localhost refuses the connection immediately, no network needed + const repoWithToken = 'https://x-access-token:secret123@127.0.0.1:9/user/repo.git'; await expect( publish(dist, { repo: repoWithToken, user }, (message) => messages.push(message)) ).rejects.toThrow(); - expect(messages[0].split(' into ')[0]).toBe('Cloning https://***@example.invalid/user/repo.git'); + expect(messages[0].split(' into ')[0]).toBe('Cloning https://***@127.0.0.1:9/user/repo.git'); expect(messages.join('\n')).not.toContain('secret123'); }); @@ -263,7 +265,8 @@ describe('gh-pages-fork publish()', () => { expect(git(remote, 'log', '-1', '--format=%an <%ae>', 'gh-pages')).toBe('Local User '); }); - it('clones into the os temp directory when the cache directory is not writable', async () => { + // chmod has no effect for root (Docker CI) and on Windows + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)('clones into the os temp directory when the cache directory is not writable', async () => { const readOnly = path.join(tempDir, 'read-only'); await fs.mkdir(readOnly, { mode: 0o500 }); process.env.CACHE_DIR = readOnly; @@ -280,4 +283,102 @@ describe('gh-pages-fork publish()', () => { expect(path.basename(cloneDir)).toMatch(/^angular-cli-ghpages-clone-/); expect(tree(remote)).toEqual(['assets/app.js', 'index.html']); }); + + it('copies more files than the copy concurrency', async () => { + const files = Array.from({ length: 40 }, (_, i) => `chunk-${String(i).padStart(2, '0')}.js`); + for (const file of files) { + await fs.writeFile(path.join(dist, 'assets', file), file); + } + + await deploy(); + + expect(tree(remote)).toEqual([...files.map((file) => 'assets/' + file), 'assets/app.js', 'index.html'].sort()); + }); + + it('includes nested dotfiles and dot-directories unless dotfiles is off', async () => { + await fs.mkdir(path.join(dist, '.well-known')); + await fs.writeFile(path.join(dist, '.well-known', 'security.txt'), 'Contact: mailto:security@example.org'); + await fs.writeFile(path.join(dist, 'assets', '.gitkeep'), ''); + + await deploy({ dotfiles: true }); + expect(tree(remote)).toEqual(['.htaccess', '.well-known/security.txt', 'assets/.gitkeep', 'assets/app.js', 'index.html']); + + await deploy({ dotfiles: false }); + expect(tree(remote)).toEqual(['assets/app.js', 'index.html']); + }); + + it('publishes to a non-default remote of the current repository', async () => { + const app = path.join(tempDir, 'app'); + git(tempDir, 'init', app); + git(app, 'remote', 'add', 'upstream', fileUrl(remote)); + const cwd = process.cwd(); + process.chdir(app); + + try { + await publish(dist, { remote: 'upstream', user, message: 'deploy', dotfiles: false, nojekyll: false }); + await publish(dist, { remote: 'upstream', user, message: 'redeploy', dotfiles: true, nojekyll: false }); + } finally { + process.chdir(cwd); + } + + expect(tree(remote)).toEqual(['.htaccess', 'assets/app.js', 'index.html']); + expect(commitCount(remote)).toBe(2); + }); + + it('keeps the history when the shallow clone fails once', async () => { + await deploy(); + const firstCommit = git(remote, 'rev-parse', 'gh-pages'); + const flakyGit = path.join(tempDir, 'flaky-git'); + const flag = path.join(tempDir, 'failed-once'); + await fs.writeFile(flakyGit, [ + '#!/bin/sh', + `for arg in "$@"; do if [ "$arg" = "--depth" ] && [ ! -f "${flag}" ]; then touch "${flag}"; echo "error: RPC failed; HTTP 503" >&2; exit 128; fi; done`, + 'exec git "$@"', + '' + ].join('\n')); + await fs.chmod(flakyGit, 0o755); + await fs.writeFile(path.join(dist, 'index.html'), '

v2

'); + + await deploy({ git: flakyGit }); + + expect(git(remote, 'rev-parse', 'gh-pages~1')).toBe(firstCommit); + }); + + it('rejects when basePath is not a directory', async () => { + const file = path.join(dist, 'index.html'); + + await expect(publish(file, { repo: fileUrl(remote), user })).rejects.toThrow('The "base" option must be an existing directory'); + }); + + it('commits with the git user from the environment when the project has none', async () => { + const app = path.join(tempDir, 'no-user'); + git(tempDir, 'init', app); + const cwd = process.cwd(); + process.chdir(app); + process.env.GIT_AUTHOR_NAME = process.env.GIT_COMMITTER_NAME = 'Env User'; + process.env.GIT_AUTHOR_EMAIL = process.env.GIT_COMMITTER_EMAIL = 'env@example.org'; + + try { + await publish(dist, { repo: fileUrl(remote), message: 'deploy', nojekyll: false }); + } finally { + process.chdir(cwd); + } + + expect(git(remote, 'log', '-1', '--format=%an <%ae>', 'gh-pages')).toBe('Env User '); + }); + + it('publishes files that match the global gitignore of the user', async () => { + const ignoreFile = path.join(tempDir, 'global-ignore'); + await fs.writeFile(ignoreFile, '*.map\n.htaccess\n'); + await fs.writeFile(process.env.GIT_CONFIG_GLOBAL!, `[core]\n\texcludesFile = ${ignoreFile}\n`); + await fs.writeFile(path.join(dist, 'assets', 'app.js.map'), '{}'); + + try { + await deploy({ dotfiles: true }); + } finally { + await fs.writeFile(process.env.GIT_CONFIG_GLOBAL!, ''); + } + + expect(tree(remote)).toEqual(['.htaccess', 'assets/app.js', 'assets/app.js.map', 'index.html']); + }); }); diff --git a/src/ng-add.spec.ts b/src/ng-add.spec.ts index bc85daa..1963468 100644 --- a/src/ng-add.spec.ts +++ b/src/ng-add.spec.ts @@ -53,6 +53,32 @@ describe('ng-add', () => { }); }); + describe('existing deploy target', () => { + it('keeps an existing angular-cli-ghpages deploy target when run again', async () => { + const tree = Tree.empty(); + tree.create('angular.json', JSON.stringify(generateAngularJson())); + const first = await ngAdd({ project: PROJECT_NAME })(tree, mockContext); + const angularJson = first.read('angular.json')!.toString(); + + const second = await ngAdd({ project: PROJECT_NAME })(first, mockContext); + + expect(second.read('angular.json')!.toString()).toBe(angularJson); + expect(mockLogger.info).toHaveBeenCalledWith(`The project "${PROJECT_NAME}" already has a deploy target for angular-cli-ghpages.`); + }); + + it('refuses to replace a deploy target of another builder', async () => { + const angularJson = generateAngularJson(); + (angularJson.projects[PROJECT_NAME].architect as Record).deploy = { builder: '@angular/fire:deploy' }; + const tree = Tree.empty(); + tree.create('angular.json', JSON.stringify(angularJson)); + + await expect(ngAdd({ project: PROJECT_NAME })(tree, mockContext)).rejects.toThrow( + `The project "${PROJECT_NAME}" already has a deploy target with the builder "@angular/fire:deploy". ` + + 'Remove it from angular.json to use angular-cli-ghpages.' + ); + }); + }); + describe('project selection', () => { it('should select the first project if there is only one', async () => { diff --git a/src/parameter-tests/builder-passthrough.spec.ts b/src/parameter-tests/builder-passthrough.spec.ts index 88c12b4..e33cd64 100644 --- a/src/parameter-tests/builder-passthrough.spec.ts +++ b/src/parameter-tests/builder-passthrough.spec.ts @@ -1,5 +1,6 @@ import { BuilderContext, BuilderRun, ScheduleOptions, Target } from '@angular-devkit/architect/src'; import { JsonObject, logging } from '@angular-devkit/core'; +import * as path from 'path'; import deploy from '../deploy/actions'; import { BuildTarget } from '../interfaces'; import { Schema } from '../deploy/schema'; @@ -180,7 +181,8 @@ describe('Angular Builder Parameter Passthrough', () => { }); it('should use default outputPath/browser when dir not provided', async () => { - const expectedDir = 'dist/some-folder/browser'; + // relative to the workspace root of the mock context + const expectedDir = path.join('/test', 'dist/some-folder/browser'); const options: Schema = { noBuild: true }; await deploy(mockEngine, context, BUILD_TARGET, options); diff --git a/src/parameter-tests/cli-e2e.spec.ts b/src/parameter-tests/cli-e2e.spec.ts index 51aa0e4..212c56b 100644 --- a/src/parameter-tests/cli-e2e.spec.ts +++ b/src/parameter-tests/cli-e2e.spec.ts @@ -1,6 +1,9 @@ import path from 'path'; import fs from 'fs'; -import { execSync } from 'child_process'; +import os from 'os'; +import { execSync, spawnSync } from 'child_process'; + +import { createBareRepo, tree } from '../test-helpers/git'; /** * END-TO-END CLI TESTS @@ -35,12 +38,28 @@ function runCli(args: string): string { const program = path.resolve(__dirname, '../dist/angular-cli-ghpages'); // Clear CI environment variables to ensure consistent test behavior across environments + return execSync(`node ${program} --dry-run ${args}`, { env: cliEnv() }).toString(); +} + +// No CI metadata, no injected token: consistent test behavior across environments +function cliEnv(): NodeJS.ProcessEnv { const env = { ...process.env }; delete env.TRAVIS; delete env.CIRCLECI; delete env.GITHUB_ACTIONS; + delete env.GH_TOKEN; + delete env.PERSONAL_TOKEN; + delete env.GITHUB_TOKEN; + return env; +} - return execSync(`node ${program} --dry-run ${args}`, { env }).toString(); +/** + * Run the CLI without --dry-run; returns exit code and combined output. + */ +function runCliRaw(args: string[], cwd: string): { status: number | null; output: string } { + const program = path.resolve(__dirname, '../dist/angular-cli-ghpages'); + const result = spawnSync('node', [program, ...args], { cwd, env: cliEnv() }); + return { status: result.status, output: result.stdout.toString() + result.stderr.toString() }; } function parseJsonFromCliOutput(output: string): DryRunOutput { @@ -387,4 +406,76 @@ describe('CLI End-to-End Tests', () => { expect(json.cname).toBe(expectedMessage); }); }); + + describe('Argument validation', () => { + it('should reject positional arguments', () => { + const result = runCliRaw(['dist/my-app/browser', '--dry-run'], path.resolve(__dirname, '..')); + + expect(result.status).toBe(1); + expect(result.output).toContain('Unexpected argument(s): dist/my-app/browser'); + }); + + it('should treat --add=false as add disabled', () => { + const output = runCli('--add=false'); + const json = parseJsonFromCliOutput(output); + + expect(json.add).toBe('existing files will be removed from the branch before adding the new ones'); + }); + + it('should treat --add=true as add enabled', () => { + const output = runCli('--add=true'); + const json = parseJsonFromCliOutput(output); + + expect(json.add).toBe('all files will be added to the branch. Existing files will not be removed'); + }); + + it('should accept an absolute --dir', () => { + // an existing absolute path, resolved independently of the cwd + const dir = path.resolve(__dirname, '..', 'dist'); + const output = execSync(`node ${path.join(dir, 'angular-cli-ghpages')} --dry-run --dir=${dir} --repo=https://github.com/test/repo.git`, { cwd: os.tmpdir(), env: cliEnv() }).toString(); + const json = parseJsonFromCliOutput(output); + + expect(json.dir).toBe(dir); + }); + }); + + describe('Real deploy', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-e2e-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should publish the directory to the repository', () => { + const remote = createBareRepo(path.join(tempDir, 'remote.git')); + const dist = path.join(tempDir, 'dist'); + fs.mkdirSync(dist); + fs.writeFileSync(path.join(dist, 'index.html'), '

cli

'); + + const result = runCliRaw( + [`--dir=${dist}`, `--repo=${remote}`, '--name=CLI Bot', '--email=cli@example.org', '--cname=example.org'], + tempDir + ); + + expect(result.output).toContain('Successfully published via angular-cli-ghpages!'); + expect(result.status).toBe(0); + expect(tree(remote)).toEqual(['.nojekyll', '404.html', 'CNAME', 'index.html']); + }); + + it('should exit with 1 when the deploy fails', () => { + const dist = path.join(tempDir, 'dist'); + fs.mkdirSync(dist); + fs.writeFileSync(path.join(dist, 'index.html'), '

cli

'); + + const result = runCliRaw([`--dir=${dist}`, `--repo=${path.join(tempDir, 'missing.git')}`], tempDir); + + expect(result.status).toBe(1); + expect(result.output).toContain('An error occurred when trying to deploy'); + expect(result.output).not.toContain('Successfully published'); + }); + }); }); diff --git a/src/test-helpers/git.ts b/src/test-helpers/git.ts index f30c0db..a850e4e 100644 --- a/src/test-helpers/git.ts +++ b/src/test-helpers/git.ts @@ -7,6 +7,7 @@ import { ChildProcess, execFileSync } from 'child_process'; import { EventEmitter } from 'events'; import * as fs from 'fs/promises'; import * as path from 'path'; +import { pathToFileURL } from 'url'; /** * Run git in `cwd` and return its trimmed stdout. @@ -27,13 +28,21 @@ export function commitCount(repo: string, branch = 'gh-pages'): number { } /** - * Create a bare repository that serves as the remote. + * Create a bare repository that serves as the remote (default branch: main). */ export function createBareRepo(dir: string): string { - git(path.dirname(dir), 'init', '--bare', dir); + git(path.dirname(dir), 'init', '--bare', '--initial-branch=main', dir); return dir; } +/** + * file:// URL of a local repository: unlike a plain path, git honors --depth + * for it, so clones in tests are really shallow. + */ +export function fileUrl(repo: string): string { + return pathToFileURL(repo).href; +} + /** * Push a branch with the given files (and optional submodule gitlinks) to `remote`. */ diff --git a/src/test-helpers/vitest-setup.ts b/src/test-helpers/vitest-setup.ts new file mode 100644 index 0000000..86c5a8d --- /dev/null +++ b/src/test-helpers/vitest-setup.ts @@ -0,0 +1,26 @@ +/** + * Runs before every spec file (vitest.config.mts setupFiles). + * + * Specs run real git commands; the developer's environment must not change + * their results: no global/system git config (commit.gpgsign, global + * gitignore, hooks), no repository variables of a surrounding git process, + * no tokens or CI variables that the engine would pick up. + */ + +import { mkdtempSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +const emptyGitConfig = join(mkdtempSync(join(tmpdir(), 'angular-cli-ghpages-test-gitconfig-')), 'gitconfig'); +writeFileSync(emptyGitConfig, ''); + +process.env.GIT_CONFIG_GLOBAL = emptyGitConfig; +process.env.GIT_CONFIG_NOSYSTEM = '1'; + +for (const name of [ + 'GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY', 'GIT_COMMON_DIR', + 'GH_TOKEN', 'PERSONAL_TOKEN', 'GITHUB_TOKEN', + 'TRAVIS', 'CIRCLECI', 'GITHUB_ACTIONS', 'CACHE_DIR' +]) { + delete process.env[name]; +} diff --git a/src/vitest.config.mts b/src/vitest.config.mts index 414d975..e50974b 100644 --- a/src/vitest.config.mts +++ b/src/vitest.config.mts @@ -3,6 +3,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { globals: true, - environment: 'node' + environment: 'node', + setupFiles: ['./test-helpers/vitest-setup.ts'] } }); From 2024cb84d91ad8d838864b394f3d3c89fac20da5 Mon Sep 17 00:00:00 2001 From: Johannes Hoppe Date: Mon, 12 Oct 2026 21:56:43 +0200 Subject: [PATCH 14/17] fix: write 404.html into the deployment instead of the build folder publish() copies index.html to 404.html in the clone before copying the build, so a 404.html of the build wins and the build folder is never modified. A 404.html generated by an earlier deploy can no longer go stale in a reused dist folder or be published despite --no-notfound. --- src/engine/engine.ts | 49 ++++------------------------------ src/gh-pages-fork/lib/index.ts | 17 ++++++++++-- 2 files changed, 20 insertions(+), 46 deletions(-) diff --git a/src/engine/engine.ts b/src/engine/engine.ts index b56fa6f..52bac8d 100644 --- a/src/engine/engine.ts +++ b/src/engine/engine.ts @@ -1,7 +1,4 @@ import {logging} from '@angular-devkit/core'; -import {constants as fsConstants} from 'fs'; -import * as fs from 'fs/promises'; -import * as path from 'path'; import {Schema} from '../deploy/schema'; import * as ghpages from '../gh-pages-fork/lib'; @@ -24,7 +21,6 @@ export async function run( const prepared = await prepareOptions(options, logger); await checkIfDistFolderExists(dir); - await createNotFoundFile(dir, prepared, logger); await publishViaGhPages(dir, prepared, logger); if (!prepared.dryRun) { @@ -87,45 +83,6 @@ async function checkIfDistFolderExists(dir: string) { } } -async function createNotFoundFile( - dir: string, - options: { - notfound: boolean, - dryRun?: boolean - }, - logger: logging.LoggerApi -) { - if (!options.notfound) { - return; - } - - if (options.dryRun) { - logger.info('Dry-run / SKIPPED: copying of index.html to 404.html'); - return; - } - - // Note: - // There is no guarantee that there will be an index.html file, - // as we may may specify a custom index file or a different folder is going to be deployed. - const indexHtml = path.join(dir, 'index.html'); - const notFoundFile = path.join(dir, '404.html'); - - if (await pathExists(notFoundFile)) { - logger.info('404.html already exists in the dist folder and is kept.'); - return; - } - - try { - await fs.copyFile(indexHtml, notFoundFile, fsConstants.COPYFILE_EXCL); - logger.info('404.html file created'); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - logger.info('index.html could not be copied to 404.html. Proceeding without it.'); - logger.debug('Diagnostic info: ' + message); - return; - } -} - async function publishViaGhPages( dir: string, options: PreparedOptions, @@ -133,6 +90,9 @@ async function publishViaGhPages( ) { if (options.dryRun) { // options.repo may contain an injected token, so its credentials are hidden + if (options.notfound) { + logger.info('Dry-run / SKIPPED: copying of index.html to 404.html'); + } logger.info( `Dry-run / SKIPPED: publishing folder '${dir}' with the following options: ` + JSON.stringify( @@ -171,7 +131,8 @@ async function publishViaGhPages( dotfiles: options.dotfiles, user: options.user, cname: options.cname, - nojekyll: options.nojekyll + nojekyll: options.nojekyll, + notfound: options.notfound }, (message) => logger.info(message) ); diff --git a/src/gh-pages-fork/lib/index.ts b/src/gh-pages-fork/lib/index.ts index 7d67bac..9e165d6 100644 --- a/src/gh-pages-fork/lib/index.ts +++ b/src/gh-pages-fork/lib/index.ts @@ -23,6 +23,7 @@ export interface PublishOptions { add?: boolean; dotfiles?: boolean; nojekyll?: boolean; + notfound?: boolean; cname?: string; user?: User; } @@ -41,7 +42,8 @@ export const defaults = { branch: 'gh-pages', remote: 'origin', message: 'Auto-generated commit', - nojekyll: true + nojekyll: true, + notfound: true }; /** Prefix of temporary clones in the os temp directory */ @@ -198,7 +200,8 @@ export function redact(repo: string): string { * Publish the files in `basePath` to a branch of a git repository. * * Prepares the branch in a fresh temporary directory without checking out its - * files, writes the files from `basePath` (plus .nojekyll and CNAME), stages + * files, writes the files from `basePath` (plus .nojekyll, CNAME and a 404.html + * copy of index.html), stages * them as the complete new content of the branch (with `add`: on top of the * existing files), commits and pushes. The temporary clone is removed when * publish() finishes; a token in the repository URL is only passed on the @@ -248,6 +251,16 @@ export async function publish(basePath: string, config: PublishOptions, log: Log await fs.writeFile(path.join(git.cwd, 'CNAME'), options.cname); } + // 404.html for SPA routing on GitHub Pages; a 404.html of basePath overwrites it below + if (options.notfound) { + if (files.includes('index.html')) { + await fs.copyFile(path.join(basePath, 'index.html'), path.join(git.cwd, '404.html')); + log(files.includes('404.html') ? '404.html of the directory is kept' : '404.html file created'); + } else { + log('index.html could not be copied to 404.html. Proceeding without it.'); + } + } + log('Copying files'); await copy(files, basePath, git.cwd); From 70991ffa9d5a7cdf07bed99cfe4aac6faa6a0ec0 Mon Sep 17 00:00:00 2001 From: Johannes Hoppe Date: Mon, 12 Oct 2026 21:56:43 +0200 Subject: [PATCH 15/17] docs: describe gh-pages-fork v4 behavior; release 4.0.0 - fork README: structure, kept/added/removed parts and every deviation from upstream - CLAUDE.md: deployment flow, standalone CLI, output directory resolution, watching upstream gh-pages - README and standalone docs: dotfiles and ignore rules, 404.html, --add, --dir - contributor docs: compiling programmatic usage examples, watching the forks, version list - tests for 404.html in the deployment, remove an unused spy --- CLAUDE.md | 36 ++++-- README.md | 12 +- docs/README_contributors.md | 35 +++--- docs/README_standalone.md | 7 +- src/engine/engine-filesystem.spec.ts | 117 ++++-------------- .../engine.gh-pages-integration.spec.ts | 17 +-- .../engine.prepare-options-helpers.spec.ts | 3 - src/gh-pages-fork/README.md | 58 +++++---- .../test/cleanup-failure.spec.ts | 4 +- src/gh-pages-fork/test/publish.spec.ts | 56 +++++++-- src/package-lock.json | 4 +- src/package.json | 2 +- .../builder-integration.spec.ts | 19 ++- 13 files changed, 180 insertions(+), 190 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 799483f..342cfb5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,8 +102,9 @@ npm dist-tag add angular-cli-ghpages@X.X.X-rc.X next - Adds deploy target to `angular.json` 3. **Standalone CLI** (`src/angular-cli-ghpages`): - - Bash script for non-Angular CLI usage - - Uses `commander` for CLI parsing + - Node.js script for non-Angular CLI usage (`npx angular-cli-ghpages`) + - Parses options with `src/commander-fork` (frozen subset of commander v3.0.2, see its `README.md`) + - Rejects positional arguments; boolean flags accept `=true` / `=false` 4. **Core Engine** (`src/engine/`): - `engine.ts` - Core deployment logic (publishes via gh-pages-fork) @@ -125,14 +126,20 @@ actions.ts (deploy function) β”‚ Uses BuilderContext.scheduleTarget() └─→ engine.run() β”œβ”€β†’ Prepare options (tokens, CI env vars) - β”œβ”€β†’ Create 404.html in dist (copy of index.html for SPAs) - └─→ gh-pages-fork publish() - β”œβ”€β†’ Clone the target branch into a fresh temporary directory (removed afterwards) - β”‚ or create it as an empty orphan branch - β”œβ”€β†’ Remove the branch content (skipped with --add) - β”œβ”€β†’ Write .nojekyll (bypasses Jekyll on GitHub) and CNAME (if custom domain) - β”œβ”€β†’ Copy dist, git add, commit (only if something changed) - └─→ Push + └─→ gh-pages-fork publish() (never modifies dist) + β”œβ”€β†’ Remove stale temporary clones of interrupted deploys + β”œβ”€β†’ git ls-remote: does the target branch exist? + β”‚ yes β†’ clone only that branch without checkout into a fresh temporary directory + β”‚ no β†’ empty repository with HEAD on the new branch + β”œβ”€β†’ --add: index = tree of the branch (read-tree HEAD) + β”œβ”€β†’ Write .nojekyll (bypasses Jekyll on GitHub), CNAME (if custom domain) and + β”‚ 404.html (copy of index.html for SPAs), then copy dist (its own 404.html wins) + β”œβ”€β†’ git add --force (--all, or --ignore-removal with --add), commit if something changed + β”œβ”€β†’ Push + └─→ Remove the temporary clone + +All git commands for the clone run with GIT_DIR/GIT_WORK_TREE pinned to it, +without repository variables inherited from a surrounding git process. ``` ### Build Target Resolution @@ -147,10 +154,11 @@ actions.ts (deploy function) - Final target: `prerenderTarget || staticBuildTarget` (see `src/deploy/builder.ts`) Output directory resolution: -- Checks `angular.json` for `outputPath` +- Checks `angular.json` for `outputPath` (resolved against the workspace root) +- If undefined: `dist//browser` (Angular 20+ default) - If string: appends `/browser` (modern Angular convention) - If object: uses `${base}/${browser}` properties -- Can be overridden with `--dir` option +- Can be overridden with `--dir` option (relative to the current working directory, or absolute) ### Token Injection @@ -183,7 +191,7 @@ The engine appends CI metadata to commit messages when running on: 1. **No Server-Side Rendering**: GitHub Pages only supports static files. SSR/Universal build targets are not supported. -2. **404.html Handling**: +2. **404.html Handling** (written by gh-pages-fork into the deployment, a `404.html` of the build wins): - **GitHub Pages**: Requires `404.html` workaround (only way to get SPA routing, but returns HTTP 404 status) - **Cloudflare Pages**: MUST NOT have `404.html` - its presence disables native SPA mode - **Future**: Consider changing default or auto-detecting deployment target @@ -229,6 +237,8 @@ For sync considerations with AngularFire, monitor: - https://github.com/angular/angularfire/blob/master/src/schematics/deploy/builder.ts - https://github.com/angular/angularfire/blob/master/src/schematics/deploy/actions.ts +For `src/gh-pages-fork`, monitor upstream https://github.com/tschaub/gh-pages (releases, security advisories, `lib/`). npm audit and Dependabot can't report problems in forked code; port relevant fixes file by file and update the base commit in `src/gh-pages-fork/README.md`. + ## GitHub CLI Usage When performing GitHub operations (creating issues, PRs, etc.), use the `gh` CLI tool instead of web requests to avoid rate limiting and authentication issues. diff --git a/README.md b/README.md index bf972b8..763d55b 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ A detailed changelog is available in the [releases](https://github.com/angular-schule/angular-cli-ghpages/releases) section. -`angular-cli-ghpages` v3 supports Angular 18 to 22. +`angular-cli-ghpages` v4 supports Angular 18 to 22. For previous versions of Angular, use v1 or v2. ## ⚠️ Prerequisites @@ -302,6 +302,8 @@ In this case, provide **both** `name` and `email` string values to identify the The command includes dotfiles by default (e.g. `.htaccess` will be committed). With `--no-dotfiles` files starting with `.` are ignored. +Files and folders named `.git` are never published. +Ignore rules (a global gitignore or a `.gitignore` inside the build) don't apply: every file of the build is published. #### --no-notfound @@ -312,6 +314,8 @@ With `--no-dotfiles` files starting with `.` are ignored. - `ng deploy --no-notfound` – No `404.html` file is created. By default, a `404.html` file is created, because this is the only known workaround for SPA routing on GitHub Pages (note: GitHub still returns HTTP 404 status, which may affect Brave browser and social previews). +The `404.html` is added to the deployment only; your build folder isn't modified. +If the build already contains a `404.html` (e.g. a custom error page), it is kept. > [!IMPORTANT] > **Cloudflare Pages users:** You **must** use `--no-notfound` to enable native SPA routing. Cloudflare Pages only activates SPA mode when no `404.html` file exists. See [#178](https://github.com/angular-schule/angular-cli-ghpages/issues/178) @@ -353,16 +357,16 @@ A CNAME file will be created enabling you to use a custom domain. - `ng deploy --add=true` If it is set to `true`, it will only add, and never remove existing files. -By default, existing files in the target branch are removed before adding new ones. +By default, the build replaces the complete content of the target branch. #### --dir - **optional** -- Default: `undefined` (string) – Conventions will be used to guess the correct directory in your `dist` folder. +- Default: `undefined` (string) – The output directory of the build target in `angular.json` is used (relative to the workspace root). - Example: - `ng deploy --dir=dist/completely-different-folder/en` -Overrides the directory for all published sources, relative to the current working directory. +Overrides the directory for all published sources, relative to the current working directory (absolute paths work as well). #### --dry-run diff --git a/docs/README_contributors.md b/docs/README_contributors.md index 4b5d3a0..3b4d08e 100644 --- a/docs/README_contributors.md +++ b/docs/README_contributors.md @@ -42,8 +42,9 @@ Follow the instructions for [checking and updating the Angular CLI version](#ang This builder requires the method `getTargetOptions()` from the Angular DevKit which was introduced [here](https://github.com/angular/angular-cli/pull/13825/files). **Version compatibility:** -- **v3.x:** Supports Angular 18 and higher (current version) -- **v2.x:** Supported Angular 18 (previous major version) +- **v4.x:** Supports Angular 18 and higher (current version) +- **v3.x:** Supported Angular 18 to 22 (previous major version) +- **v2.x:** Supported Angular 18 - **v1.x:** Supported Angular 9-17 (deprecated) Execute the next three steps to update your test project to the latest Angular version. @@ -214,12 +215,11 @@ npm dist-tag add angular-cli-ghpages@X.X.X-rc.X next For advanced use cases, `angular-cli-ghpages` can be used programmatically in Node.js scripts: ```typescript -import { deployToGHPages, defaults, Schema } from 'angular-cli-ghpages'; +import { logging } from '@angular-devkit/core'; +import { deployToGHPages, Schema } from 'angular-cli-ghpages'; -// Deploy with custom options +// Options not set here fall back to the defaults (exported as `defaults`) const options: Schema = { - ...defaults, - dir: 'dist/my-app/browser', repo: 'https://github.com/user/repo.git', message: 'Custom deploy message', branch: 'gh-pages', @@ -227,14 +227,8 @@ const options: Schema = { email: 'bot@example.com' }; -// Simple logger implementation -const logger = { - info: (msg: string) => console.log(msg), - warn: (msg: string) => console.warn(msg), - error: (msg: string) => console.error(msg), - debug: (msg: string) => console.debug(msg), - fatal: (msg: string) => console.error(msg) -}; +const logger = new logging.Logger('deploy'); +logger.subscribe((entry) => console.log(entry.message)); try { await deployToGHPages('dist/my-app/browser', options, logger); @@ -258,10 +252,13 @@ The package exports these TypeScript types for programmatic usage: For custom Angular builders: ```typescript -import { angularDeploy } from 'angular-cli-ghpages'; +import { BuilderContext } from '@angular-devkit/architect'; +import { angularDeploy, deployToGHPages, Schema } from 'angular-cli-ghpages'; -// Inside your custom builder -const result = await angularDeploy(context, builderConfig, 'your-project-name'); +// Inside your custom builder: build the target, then deploy its output +async function deploy(context: BuilderContext, options: Schema) { + await angularDeploy({ run: deployToGHPages }, context, { name: 'my-app:build:production' }, options); +} ``` **Note:** The CLI (`ng deploy`) remains the primary and recommended way to use this tool. Programmatic usage is considered advanced/experimental and may change between versions. @@ -308,3 +305,7 @@ To stay in sync with the stuff the Angular team is doing, you might want to keep - [builder.ts](https://github.com/angular/angularfire/blob/master/src/schematics/deploy/builder.ts) - [actions.ts](https://github.com/angular/angularfire/blob/master/src/schematics/deploy/actions.ts) + +`src/gh-pages-fork` follows [tschaub/gh-pages](https://github.com/tschaub/gh-pages). npm audit and Dependabot can't report problems in forked code, so watch the upstream repository (releases, security advisories, `lib/`) and port relevant fixes file by file (see `src/gh-pages-fork/README.md`). + +`src/commander-fork` is a frozen subset of [commander v3.0.2](https://github.com/tj/commander.js), see `src/commander-fork/README.md`. diff --git a/docs/README_standalone.md b/docs/README_standalone.md index 542fc6d..c82be54 100644 --- a/docs/README_standalone.md +++ b/docs/README_standalone.md @@ -170,7 +170,8 @@ In this case provide both `name` and `email` string values to identify the commi - **optional** - Default: `dist` -Directory for all published sources, relative to the current working directory. +Directory for all published sources, relative to the current working directory (absolute paths work as well). +Pass it as an option (`--dir=dist/my-app/browser`); positional arguments are rejected. **Starting with Angular CLI 6, the build artifacts will be put in a subfolder under `dist`. Please take a look at the `dist` folder to see whether there is a subfolder with your project's name or not.** @@ -212,10 +213,10 @@ A CNAME file will be created enabling you to use a custom domain. [More informat - **optional** - Default: `false` (boolean) – The existing files will be removed from the branch you'll be pushing to as expected. - Example: - - `npx angular-cli-ghpages --add=true` + - `npx angular-cli-ghpages --add` (or `--add=true`; `--add=false` keeps the default) If is set to `true`, it will only add, and never remove existing files. -By default, existing files in the target branch are removed before adding the ones. +By default, the published directory replaces the complete content of the target branch. #### --no-notfound diff --git a/src/engine/engine-filesystem.spec.ts b/src/engine/engine-filesystem.spec.ts index ebc06a3..82bd30a 100644 --- a/src/engine/engine-filesystem.spec.ts +++ b/src/engine/engine-filesystem.spec.ts @@ -2,12 +2,11 @@ * Real filesystem integration tests for file creation * * These tests use actual temporary directories to verify that: - * - 404.html is copied from index.html (handled by angular-cli-ghpages) - * - Dry-run mode prevents file creation - * - Error handling works as expected + * - the engine never modifies the dist folder + * - dry-run mode prevents file creation * - * CNAME and .nojekyll are written by the publish step of gh-pages-fork; - * the "publish options" tests below verify that the options reach it. + * 404.html, CNAME and .nojekyll are written by the publish step of + * gh-pages-fork; the tests below verify that the options reach it. */ import { logging } from '@angular-devkit/core'; @@ -53,100 +52,36 @@ describe('engine - real filesystem tests', () => { loggerInfoSpy.mockRestore(); }); - describe('404.html file creation', () => { - it('should create 404.html as exact copy of index.html when notfound is true', async () => { - // First create an index.html file - const indexPath = path.join(testDir, 'index.html'); - const indexContent = 'Test

Test App

'; - await fs.writeFile(indexPath, indexContent); - - - const options = { - notfound: true, - nojekyll: false, - dotfiles: true - }; - - await engine.run(testDir, options, logger); - - const notFoundPath = path.join(testDir, '404.html'); - const exists = await pathExists(notFoundPath); - expect(exists).toBe(true); - - const notFoundContent = await fs.readFile(notFoundPath, 'utf-8'); - expect(notFoundContent).toBe(indexContent); - }); - - it('should NOT create 404.html when notfound is false', async () => { - const indexPath = path.join(testDir, 'index.html'); - await fs.writeFile(indexPath, 'Test'); - - - const options = { - notfound: false, - nojekyll: false, - dotfiles: true - }; - - await engine.run(testDir, options, logger); - - const notFoundPath = path.join(testDir, '404.html'); - const exists = await pathExists(notFoundPath); - expect(exists).toBe(false); - }); - - it('should gracefully continue when index.html does not exist (not throw error)', async () => { - // No index.html created - directory is empty - - - const options = { - notfound: true, - nojekyll: false, - dotfiles: true - }; - - // Should NOT throw - this is the critical test for graceful handling - await expect( - engine.run(testDir, options, logger) - ).resolves.toBeUndefined(); + /** + * publish() writes 404.html (a copy of index.html) into its clone; the + * engine only passes the notfound option and never modifies dist. + */ + describe('404.html', () => { + it('should pass notfound to publish() and leave dist untouched', async () => { + await fs.writeFile(path.join(testDir, 'index.html'), 'Test'); - // Should log a warning message - expect(loggerInfoSpy).toHaveBeenCalledWith( - 'index.html could not be copied to 404.html. Proceeding without it.' - ); + await engine.run(testDir, { notfound: true, nojekyll: false, dotfiles: true }, logger); - const notFoundPath = path.join(testDir, '404.html'); - const exists = await pathExists(notFoundPath); - expect(exists).toBe(false); + expect(vi.mocked(ghpages.publish).mock.calls[0][1].notfound).toBe(true); + expect(await fs.readdir(testDir)).toEqual(['index.html']); }); - it('should keep an existing 404.html', async () => { - const notFoundContent = 'Custom 404'; - await fs.writeFile(path.join(testDir, 'index.html'), 'Index'); - await fs.writeFile(path.join(testDir, '404.html'), notFoundContent); + it('should pass notfound: false to publish()', async () => { + await fs.writeFile(path.join(testDir, 'index.html'), 'Test'); - await engine.run(testDir, { notfound: true, nojekyll: false, dotfiles: true }, logger); + await engine.run(testDir, { notfound: false, nojekyll: false, dotfiles: true }, logger); - expect(await fs.readFile(path.join(testDir, '404.html'), 'utf-8')).toBe(notFoundContent); - expect(loggerInfoSpy).toHaveBeenCalledWith('404.html already exists in the dist folder and is kept.'); + expect(vi.mocked(ghpages.publish).mock.calls[0][1].notfound).toBe(false); }); it('should NOT create 404.html when dry-run is true', async () => { - const indexPath = path.join(testDir, 'index.html'); - await fs.writeFile(indexPath, 'Test'); - - const options = { - notfound: true, - nojekyll: false, - dotfiles: true, - dryRun: true - }; + await fs.writeFile(path.join(testDir, 'index.html'), 'Test'); - await engine.run(testDir, options, logger); + await engine.run(testDir, { notfound: true, nojekyll: false, dotfiles: true, dryRun: true }, logger); - const notFoundPath = path.join(testDir, '404.html'); - const exists = await pathExists(notFoundPath); - expect(exists).toBe(false); + expect(await pathExists(path.join(testDir, '404.html'))).toBe(false); + expect(ghpages.publish).not.toHaveBeenCalled(); + expect(loggerInfoSpy).toHaveBeenCalledWith('Dry-run / SKIPPED: copying of index.html to 404.html'); }); }); @@ -200,7 +135,6 @@ describe('engine - real filesystem tests', () => { const indexPath = path.join(testDir, 'index.html'); await fs.writeFile(indexPath, 'test'); - const testDomain = 'test.example.com'; const options = { cname: testDomain, @@ -215,10 +149,7 @@ describe('engine - real filesystem tests', () => { const capturedOptions = vi.mocked(ghpages.publish).mock.calls[0][1]; expect(capturedOptions.cname).toBe(testDomain); expect(capturedOptions.nojekyll).toBe(true); - - // 404.html is created by the engine in dist, not by publish() - const notFoundPath = path.join(testDir, '404.html'); - expect(await pathExists(notFoundPath)).toBe(true); + expect(capturedOptions.notfound).toBe(true); }); it('should NOT pass cname when not provided (undefined)', async () => { diff --git a/src/engine/engine.gh-pages-integration.spec.ts b/src/engine/engine.gh-pages-integration.spec.ts index 40b1d22..b8de835 100644 --- a/src/engine/engine.gh-pages-integration.spec.ts +++ b/src/engine/engine.gh-pages-integration.spec.ts @@ -288,19 +288,15 @@ describe('engine - gh-pages-fork integration', () => { expect(actualOptions.noNojekyll).toBeUndefined(); }); - it('should NOT pass internal notfound option to publish()', async () => { + it('should pass the notfound option to publish() (it writes 404.html)', async () => { const testDir = '/test/dist'; - const options = { - dotfiles: true, - notfound: true, // Internal only, used for 404.html creation - nojekyll: true - }; + const notfound = true; + const options = { dotfiles: true, notfound, nojekyll: true }; await engine.run(testDir, options, logger); const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; - // notfound stays internal - the engine creates 404.html in dist - expect(actualOptions.notfound).toBeUndefined(); + expect(actualOptions.notfound).toBe(notfound); }); it('should pass nojekyll option to publish()', async () => { @@ -438,7 +434,7 @@ describe('engine - gh-pages-fork integration', () => { expect(actualOptions.dotfiles).toBe(true); }); - it('should NOT pass transformed noNotfound/notfound to publish()', async () => { + it('should pass transformed noNotfound: true as notfound: false to publish()', async () => { const testDir = '/test/dist'; const options = { noNotfound: true, @@ -450,8 +446,7 @@ describe('engine - gh-pages-fork integration', () => { await engine.run(testDir, options, logger); const actualOptions = ghpagesPublishSpy.mock.calls[0][1] as Record; - // notfound stays internal - the engine creates 404.html in dist - expect(actualOptions.notfound).toBeUndefined(); + expect(actualOptions.notfound).toBe(false); expect(actualOptions.noNotfound).toBeUndefined(); }); diff --git a/src/engine/engine.prepare-options-helpers.spec.ts b/src/engine/engine.prepare-options-helpers.spec.ts index 3aca5b1..675faec 100644 --- a/src/engine/engine.prepare-options-helpers.spec.ts +++ b/src/engine/engine.prepare-options-helpers.spec.ts @@ -17,13 +17,11 @@ import { Schema } from '../deploy/schema'; describe('prepareOptions helpers - intensive tests', () => { let testLogger: logging.Logger; - let infoSpy: MockInstance; let warnSpy: MockInstance; const originalEnv = process.env; beforeEach(() => { testLogger = new logging.Logger('test'); - infoSpy = vi.spyOn(testLogger, 'info'); warnSpy = vi.spyOn(testLogger, 'warn'); // Create fresh copy of environment for each test // This preserves PATH, HOME, etc. needed by git @@ -50,7 +48,6 @@ describe('prepareOptions helpers - intensive tests', () => { }); afterEach(() => { - infoSpy.mockRestore(); warnSpy.mockRestore(); }); diff --git a/src/gh-pages-fork/README.md b/src/gh-pages-fork/README.md index 118c849..d912d27 100644 --- a/src/gh-pages-fork/README.md +++ b/src/gh-pages-fork/README.md @@ -24,46 +24,58 @@ This fork keeps only the publish flow that angular-cli-ghpages uses. It has no C The file structure and function names follow upstream, so upstream changes can be compared and ported file by file: -| Upstream | Fork | Contents | -| -------------- | -------------- | ---------------------------------------------------------------------- | -| `lib/index.js` | `lib/index.ts` | `publish()`, `getCacheDir()`, `getRepo()`, `defaults` | -| `lib/git.js` | `lib/git.ts` | `Git` class, `ProcessError`, `spawn()` | -| `lib/util.js` | `lib/util.ts` | `copy()`, `getUser()`, plus `listFiles()` instead of a glob library | +| Upstream | Fork | Contents | +| -------------- | -------------- | -------------------------------------------------------------------------------------------- | +| `lib/index.js` | `lib/index.ts` | `publish()`, `getCacheDir()`, `getRepo()`, `defaults`, plus `redact()` | +| `lib/git.js` | `lib/git.ts` | `Git` class, `ProcessError`, `spawn()` | +| `lib/util.js` | `lib/util.ts` | `copy()`, `getUser()`, plus `listFiles()` instead of a glob library | ## What This Fork Contains ### Features Kept βœ… - `publish(basePath, options, log?)` β€” async, rejects on any error -- `getCacheDir()` and `getRepo()` +- `getCacheDir(cwd?)` and `getRepo()` - Options: `repo`, `branch`, `remote`, `message`, `git`, `depth`, `add`, `dotfiles`, `nojekyll`, `cname`, `user` -- `Git`: `clone`, `exec`, `clean`, `reset`, `checkout`, `rm`, `removeAll`, `add`, `commit`, `push`, `getRemoteUrl` +- `Git`: `clone`, `exec`, `commit`, `push`, `getRemoteUrl` + +### Added πŸ†• + +- Option `notfound`: 404.html as a copy of index.html (a 404.html of `basePath` wins) +- `Git.branchExists()`, `Git.keepExistingFiles()`, `Git.addAll()` +- `redact()` for logging repository URLs ### Features Removed ❌ - The `gh-pages` / `gh-pages-clean` CLI and `clean()` - The callback API of `publish()` - Options `src`, `remove`, `only`, `dest`, `tag`, `history`, `push`, `silent`, `beforeAdd` -- `Git`: `init`, `fetch`, `tag`, `deleteRef` +- `Git`: `init`, `clean`, `reset`, `fetch`, `checkout`, `rm`, `add`, `tag`, `deleteRef` - All dependencies: `async`, `commander`, `email-addresses`, `filenamify`, `find-cache-dir`, `fs-extra`, `globby`/`tinyglobby` ## Deviations from Upstream -| Topic | Upstream | Fork | -| ----------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| Clone | one cached clone per repository URL, reused across runs, `gh-pages-clean` to reset it | a fresh temporary clone per `publish()` below the cache directory, removed afterwards; concurrent deploys never share a clone, and no token stays in `.git/config` | -| Cache directory | `find-cache-dir` | same lookup (`$CACHE_DIR`, closest `package.json`) plus an os temp dir fallback, also used when the cache directory isn't writable | -| Repository check | "Remote url mismatch" check against the cached clone | none (every clone is fresh), so relative repository paths work | -| Git environment | inherited | `GIT_CEILING_DIRECTORIES` for all commands in the clone, so git never falls through to a repository above it; `-c credential.helper=` when the URL carries credentials, so no credential helper stores them | -| Process results | resolves on a falsy exit code (also for a process killed by a signal); stdout and stderr in one buffer | rejects unless the exit code is 0 (a killed process rejects); `output` is stdout only, stderr only goes into error messages | -| Logging | `util.debuglog('gh-pages')` | `log` callback parameter of `publish()`; credentials in the repository URL are replaced with `***` | -| Removing files | glob `remove` pattern, then `git rm` in batches | `removeAll()`: `git rm` of `.gitmodules`, then of `.` (all tracked files, including dotfiles and submodule gitlinks) | -| New branch | full clone of the default branch; `checkout --orphan` keeps its files (they get published with `add`) | shallow clone of the default branch without a checkout (full clone as the last resort); the orphan branch starts empty | -| `checkout()` | `main` only runs `ls-remote` for an existing branch | `checkout`, `clean -f -d`, `reset --hard remote/branch` (the behavior of v6.3.0) | -| File list | glob `src` pattern via tinyglobby | `listFiles()`: all files below `basePath`, follows symlinks, skips broken links and cycles, honors `dotfiles` | -| Copying | `fs-extra` copy (keeps the file mode) | streams, files created with mode 0666 minus umask (no executable bit, like gh-pages v6.3.0); limited concurrency | -| `getUser()` | always runs `git` | uses the configured `git` executable | -| Empty `basePath` | `The pattern in the "src" property didn't match any files.` | `The directory "" doesn't contain any files to publish.` | +| Topic | Upstream | Fork | +| -------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| Clone | one cached clone per repository URL, reused across runs; full checkout of the branch | `git ls-remote` decides: an existing branch is cloned alone, without checkout, into a fresh temporary directory (shallow, then in full); a new branch starts as an empty repository (`git init`, `remote add`, `symbolic-ref`). The temporary clone is removed when `publish()` finishes | +| Staging | `git rm` of the `remove` glob, copy, `git add .` | copy into the empty work tree, `git add --force --all` (the index starts empty); with `add`: `read-tree HEAD`, then `git add --force --ignore-removal`. Ignore rules, `.gitmodules` and symlinks on the branch don't affect the result | +| New branch | full clone of the default branch, `checkout --orphan` keeps its files | empty repository, nothing of the default branch is cloned or checked out | +| Cache directory | `find-cache-dir` (`$CACHE_DIR`, closest `package.json`); `getCacheDir(optPath)` returns a per-repository subdirectory | same lookup, `getCacheDir(cwd)` returns the directory for temporary clones or `undefined`; then (and when it isn't writable) clones go to a private `angular-cli-ghpages-clone-*` directory in the os temp dir | +| Stale clones | `gh-pages-clean` | before each deploy, `clone-*` directories older than 6 hours and all other entries of the cache directory are removed | +| Repository check | "Remote url mismatch" check against the cached clone | none (every clone is fresh); relative local paths are resolved before cloning | +| Git environment | inherited | `GIT_DIR`/`GIT_WORK_TREE` pinned to the clone, inherited repository variables removed, `safe.directory` for the clone; `-c credential.helper=` when the URL carries a password | +| Credentials | the token stays in the clone's `.git/config` | the remote URL in `.git/config` has no credentials; the URL with the token is only passed to `push` | +| Commit | `git commit -m` | `git commit --no-verify -m` (hooks of the user don't apply) | +| Process results | resolves on a falsy exit code (also for a process killed by a signal); stdout and stderr in one buffer; crashes on a missing git executable | rejects unless the exit code is 0 (the signal is added to the message); `output` is stdout only; a missing executable rejects with ENOENT | +| `getRemoteUrl()` | every failure becomes "Failed to get remote.X.url" | only a missing key (exit code 1) does; other git errors are reported with git's message | +| Logging | `util.debuglog('gh-pages')` | `log` callback parameter of `publish()`; credentials in the repository URL are replaced with `***` | +| File list | glob `src` pattern via tinyglobby | `listFiles()`: all files below `basePath`, follows symlinks, skips broken links, cycles and `.git`, honors `dotfiles` | +| Copying | `fs-extra` copy (keeps the file mode), one file after another | streams into new files (mode 0666 minus umask, git records 100644); directories once; limited concurrency | +| Cleanup errors | β€” | a failing removal of the temporary clone is logged, it never changes the result | +| `getUser()` | always runs `git` | uses the configured `git` executable | +| 404.html | β€” | `notfound` writes a copy of index.html into the clone before copying `basePath` | +| `defaults` | `dotfiles: false`, `message: 'Updates'`, no `nojekyll`/`notfound` | same values as angular-cli-ghpages (`dotfiles: true`, `nojekyll: true`, `notfound: true`, `message: 'Auto-generated commit'`) | +| Empty `basePath` | `The pattern in the "src" property didn't match any files.` | `The directory "" doesn't contain any files to publish.` | ## Maintenance diff --git a/src/gh-pages-fork/test/cleanup-failure.spec.ts b/src/gh-pages-fork/test/cleanup-failure.spec.ts index 6734358..aafee06 100644 --- a/src/gh-pages-fork/test/cleanup-failure.spec.ts +++ b/src/gh-pages-fork/test/cleanup-failure.spec.ts @@ -52,7 +52,7 @@ describe('gh-pages-fork publish() with a failing cleanup', () => { it('resolves after a successful push and logs the cleanup error', async () => { const messages: string[] = []; - await publish(dist, { repo: fileUrl(remote), user, nojekyll: false }, (message) => messages.push(message)); + await publish(dist, { repo: fileUrl(remote), user, nojekyll: false, notfound: false }, (message) => messages.push(message)); expect(tree(remote)).toEqual(['index.html']); expect(messages.at(-1)).toMatch(/^Could not remove the temporary clone .*clone-.*: EBUSY: resource busy or locked/); @@ -63,6 +63,6 @@ describe('gh-pages-fork publish() with a failing cleanup', () => { await fs.writeFile(hook, '#!/bin/sh\necho "rejected by hook" >&2\nexit 1\n'); await fs.chmod(hook, 0o755); - await expect(publish(dist, { repo: fileUrl(remote), user, nojekyll: false })).rejects.toThrow('rejected by hook'); + await expect(publish(dist, { repo: fileUrl(remote), user, nojekyll: false, notfound: false })).rejects.toThrow('rejected by hook'); }); }); diff --git a/src/gh-pages-fork/test/publish.spec.ts b/src/gh-pages-fork/test/publish.spec.ts index 0527001..74c00e8 100644 --- a/src/gh-pages-fork/test/publish.spec.ts +++ b/src/gh-pages-fork/test/publish.spec.ts @@ -20,7 +20,7 @@ describe('gh-pages-fork publish()', () => { // dotfiles/nojekyll off by default here, so the expected trees only show what a test enables function deploy(options: PublishOptions = {}, log?: (message: string) => void) { // file:// URL: git honors --depth only for real URLs, so clones are really shallow - return publish(dist, { repo: fileUrl(remote), user, message: 'deploy', dotfiles: false, nojekyll: false, ...options }, log); + return publish(dist, { repo: fileUrl(remote), user, message: 'deploy', dotfiles: false, nojekyll: false, notfound: false, ...options }, log); } beforeEach(async () => { @@ -79,7 +79,7 @@ describe('gh-pages-fork publish()', () => { it('removes .nojekyll when nojekyll is disabled', async () => { await deploy({ nojekyll: true }); - await deploy({ nojekyll: false }); + await deploy({ nojekyll: false, notfound: false }); expect(tree(remote)).toEqual(['assets/app.js', 'index.html']); }); @@ -207,7 +207,7 @@ describe('gh-pages-fork publish()', () => { process.chdir(tempDir); try { - await publish(dist, { repo: './remote.git', user, message: 'deploy', dotfiles: false, nojekyll: false }); + await publish(dist, { repo: './remote.git', user, message: 'deploy', dotfiles: false, nojekyll: false, notfound: false }); } finally { process.chdir(cwd); } @@ -222,7 +222,7 @@ describe('gh-pages-fork publish()', () => { await Promise.all([ deploy({ branch: 'pages-a' }), - publish(other, { repo: remote, user, branch: 'pages-b', message: 'deploy', nojekyll: false }) + publish(other, { repo: remote, user, branch: 'pages-b', message: 'deploy', nojekyll: false, notfound: false }) ]); expect(tree(remote, 'pages-a')).toEqual(['assets/app.js', 'index.html']); @@ -315,8 +315,8 @@ describe('gh-pages-fork publish()', () => { process.chdir(app); try { - await publish(dist, { remote: 'upstream', user, message: 'deploy', dotfiles: false, nojekyll: false }); - await publish(dist, { remote: 'upstream', user, message: 'redeploy', dotfiles: true, nojekyll: false }); + await publish(dist, { remote: 'upstream', user, message: 'deploy', dotfiles: false, nojekyll: false, notfound: false }); + await publish(dist, { remote: 'upstream', user, message: 'redeploy', dotfiles: true, nojekyll: false, notfound: false }); } finally { process.chdir(cwd); } @@ -359,7 +359,7 @@ describe('gh-pages-fork publish()', () => { process.env.GIT_AUTHOR_EMAIL = process.env.GIT_COMMITTER_EMAIL = 'env@example.org'; try { - await publish(dist, { repo: fileUrl(remote), message: 'deploy', nojekyll: false }); + await publish(dist, { repo: fileUrl(remote), message: 'deploy', nojekyll: false, notfound: false }); } finally { process.chdir(cwd); } @@ -381,4 +381,46 @@ describe('gh-pages-fork publish()', () => { expect(tree(remote)).toEqual(['.htaccess', 'assets/app.js', 'assets/app.js.map', 'index.html']); }); + + describe('404.html', () => { + it('publishes a copy of index.html as 404.html without touching dist', async () => { + await deploy({ notfound: true }); + + expect(tree(remote)).toEqual(['404.html', 'assets/app.js', 'index.html']); + expect(git(remote, 'show', 'gh-pages:404.html')).toBe(git(remote, 'show', 'gh-pages:index.html')); + expect((await fs.readdir(dist)).sort()).toEqual(['.htaccess', 'assets', 'index.html']); + }); + + it('keeps a 404.html of the directory', async () => { + await fs.writeFile(path.join(dist, '404.html'), 'custom not found page'); + + await deploy({ notfound: true }); + + expect(git(remote, 'show', 'gh-pages:404.html')).toBe('custom not found page'); + }); + + it('follows index.html on every deploy', async () => { + await deploy({ notfound: true }); + await fs.writeFile(path.join(dist, 'index.html'), '

v2

'); + await deploy({ notfound: true }); + + expect(git(remote, 'show', 'gh-pages:404.html')).toBe('

v2

'); + }); + + it('creates no 404.html without index.html and still deploys', async () => { + const messages: string[] = []; + await fs.rm(path.join(dist, 'index.html')); + + await deploy({ notfound: true }, (message) => messages.push(message)); + + expect(tree(remote)).toEqual(['assets/app.js']); + expect(messages).toContain('index.html could not be copied to 404.html. Proceeding without it.'); + }); + + it('creates no 404.html with notfound: false', async () => { + await deploy({ notfound: false }); + + expect(tree(remote)).toEqual(['assets/app.js', 'index.html']); + }); + }); }); diff --git a/src/package-lock.json b/src/package-lock.json index 843f43f..9cab3ee 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "angular-cli-ghpages", - "version": "3.1.2", + "version": "4.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "angular-cli-ghpages", - "version": "3.1.2", + "version": "4.0.0", "license": "MIT", "dependencies": { "@angular-devkit/architect": ">=0.1800.0 <0.2300.0", diff --git a/src/package.json b/src/package.json index 134b970..8549ea1 100644 --- a/src/package.json +++ b/src/package.json @@ -1,6 +1,6 @@ { "name": "angular-cli-ghpages", - "version": "3.1.2", + "version": "4.0.0", "description": "Deploy your Angular app to GitHub Pages or Cloudflare Pages directly from the Angular CLI (ng deploy)", "main": "index.js", "types": "index.d.ts", diff --git a/src/parameter-tests/builder-integration.spec.ts b/src/parameter-tests/builder-integration.spec.ts index 5a95cde..42cf31b 100644 --- a/src/parameter-tests/builder-integration.spec.ts +++ b/src/parameter-tests/builder-integration.spec.ts @@ -104,9 +104,8 @@ describe('Angular Builder Integration Tests', () => { await deploy(engine, context, BUILD_TARGET, options); expect(capturedOptions.value).not.toBeNull(); - // notfound is handled by the engine (404.html), NOT passed to publish() - // (notfound controls 404.html creation which we do ourselves) - expect(capturedOptions.value!.notfound).toBeUndefined(); + // publish() writes 404.html when notfound is set + expect(capturedOptions.value!.notfound).toBe(false); expect(capturedOptions.value!.noNotfound).toBeUndefined(); }); @@ -138,10 +137,9 @@ describe('Angular Builder Integration Tests', () => { expect(capturedOptions.value).not.toBeNull(); expect(capturedOptions.value!.dotfiles).toBe(false); - // nojekyll IS passed to publish() + // nojekyll and notfound ARE passed to publish() expect(capturedOptions.value!.nojekyll).toBe(false); - // notfound is internal (404.html creation by angular-cli-ghpages) - expect(capturedOptions.value!.notfound).toBeUndefined(); + expect(capturedOptions.value!.notfound).toBe(false); // negated options should NOT be passed expect(capturedOptions.value!.noDotfiles).toBeUndefined(); expect(capturedOptions.value!.noNotfound).toBeUndefined(); @@ -158,10 +156,9 @@ describe('Angular Builder Integration Tests', () => { expect(capturedOptions.value).not.toBeNull(); expect(capturedOptions.value!.dotfiles).toBe(true); - // nojekyll IS passed to publish() + // nojekyll and notfound ARE passed to publish() expect(capturedOptions.value!.nojekyll).toBe(true); - // notfound is internal (404.html creation by angular-cli-ghpages) - expect(capturedOptions.value!.notfound).toBeUndefined(); + expect(capturedOptions.value!.notfound).toBe(true); }); }); @@ -291,8 +288,8 @@ describe('Angular Builder Integration Tests', () => { expect(capturedOptions.value!.cname).toBe(cname); expect(capturedOptions.value!.nojekyll).toBe(false); - // notfound is internal (404.html creation by angular-cli-ghpages) - expect(capturedOptions.value!.notfound).toBeUndefined(); + // publish() writes 404.html when notfound is set + expect(capturedOptions.value!.notfound).toBe(false); // Negated options should NOT be passed expect(capturedOptions.value!.noDotfiles).toBeUndefined(); From 4f4918a0ef80e48f3359ff070f781680a96d5487 Mon Sep 17 00:00:00 2001 From: Johannes Hoppe Date: Wed, 14 Oct 2026 11:46:22 +0200 Subject: [PATCH 16/17] fix: keep the standalone CLI and commit behavior compatible - deploy commits run git hooks again (no --no-verify): there is no reported problem it would fix - commander-fork: --flag=false leaves a boolean flag unset; every other value sets it, as before - standalone CLI: positional arguments are ignored with a warning instead of failing the deploy - Git.exec() resolves with stdout; the mutable output field is gone --- CLAUDE.md | 2 +- docs/README_standalone.md | 2 +- src/angular-cli-ghpages | 4 +-- src/commander-fork/README.md | 2 +- src/commander-fork/index.js | 12 +++---- .../test/test.fork-fixes.spec.ts | 14 ++++---- src/engine/engine.gh-pages-behavior.spec.ts | 4 +-- src/gh-pages-fork/README.md | 3 +- src/gh-pages-fork/lib/git.ts | 32 ++++++++----------- src/gh-pages-fork/lib/index.ts | 4 +-- src/gh-pages-fork/lib/util.ts | 8 ++--- src/gh-pages-fork/test/git-commands.spec.ts | 6 ++-- src/parameter-tests/cli-e2e.spec.ts | 7 ++-- 13 files changed, 42 insertions(+), 58 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 342cfb5..60e6eb7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,7 +104,7 @@ npm dist-tag add angular-cli-ghpages@X.X.X-rc.X next 3. **Standalone CLI** (`src/angular-cli-ghpages`): - Node.js script for non-Angular CLI usage (`npx angular-cli-ghpages`) - Parses options with `src/commander-fork` (frozen subset of commander v3.0.2, see its `README.md`) - - Rejects positional arguments; boolean flags accept `=true` / `=false` + - Positional arguments are ignored with a warning; `--flag=false` leaves a boolean flag unset 4. **Core Engine** (`src/engine/`): - `engine.ts` - Core deployment logic (publishes via gh-pages-fork) diff --git a/docs/README_standalone.md b/docs/README_standalone.md index c82be54..a977e6b 100644 --- a/docs/README_standalone.md +++ b/docs/README_standalone.md @@ -171,7 +171,7 @@ In this case provide both `name` and `email` string values to identify the commi - Default: `dist` Directory for all published sources, relative to the current working directory (absolute paths work as well). -Pass it as an option (`--dir=dist/my-app/browser`); positional arguments are rejected. +Pass it as an option (`--dir=dist/my-app/browser`); positional arguments are ignored (with a warning). **Starting with Angular CLI 6, the build artifacts will be put in a subfolder under `dist`. Please take a look at the `dist` folder to see whether there is a subfolder with your project's name or not.** diff --git a/src/angular-cli-ghpages b/src/angular-cli-ghpages index 51e0a5d..c7dcf0b 100755 --- a/src/angular-cli-ghpages +++ b/src/angular-cli-ghpages @@ -101,9 +101,7 @@ var consoleLogger = { // --no-nojekyll becomes nojekyll: false if (commander.args.length > 0) { - consoleLogger.error('❌ Unexpected argument(s): ' + commander.args.join(' ')); - consoleLogger.error(' Use --dir= to choose the directory to deploy.'); - process.exit(1); + consoleLogger.warn('⚠️ Ignoring argument(s): ' + commander.args.join(' ') + ' – use --dir= to choose the directory to deploy.'); } // absolute paths (also Windows drive paths) stay as they are diff --git a/src/commander-fork/README.md b/src/commander-fork/README.md index 3e47d7e..249660f 100644 --- a/src/commander-fork/README.md +++ b/src/commander-fork/README.md @@ -47,7 +47,7 @@ This fork intentionally removes features (subcommands, actions, etc.) and preser 1. **Negate detection**: only real `--no-` flags count as negated options 2. **Version flags**: short-only (`-v`) and custom version flags work -3. **Boolean flags with a value**: `--add=true` sets the flag, `--add=false` leaves it unset, any other value is an error (commander v3.0.2 turned the value into a positional argument and set the flag) +3. **Boolean flags with `=false`**: `--add=false` leaves the flag unset; any other value sets it, as in commander v3.0.2 (which also set the flag for `=false`) ## Compatibility Matrix diff --git a/src/commander-fork/index.js b/src/commander-fork/index.js index a6bc0c7..a632e4e 100644 --- a/src/commander-fork/index.js +++ b/src/commander-fork/index.js @@ -25,7 +25,7 @@ * Fixes on top of commander v3.0.2 (marked FORK FIX 1/3 to 3/3 below): * 1. Tightened negate detection for --no- flags * 2. Short-only and custom version flags - * 3. Boolean flags accept "=true" / "=false" + * 3. "--flag=false" leaves a boolean flag unset */ /** @@ -339,18 +339,14 @@ Command.prototype.normalize = function(args) { }); } } else if (/^--/.test(arg) && ~(index = arg.indexOf('='))) { - // FORK FIX 3/3: Boolean flags accept "=true" / "=false" + // FORK FIX 3/3: "--flag=false" leaves a boolean flag unset // Original: "--add=false" was split into "--add" plus a positional "false", // so the flag was set although the user asked for the opposite. - // Fix: "=true" sets the flag, "=false" leaves it unset, any other value is an error. + // Fix: "=false" leaves the flag unset; any other value sets it (as before). opt = this.optionFor(arg.slice(0, index)); if (opt && !opt.required && !opt.optional) { - var value = arg.slice(index + 1); - if (value === 'true') { + if (arg.slice(index + 1) !== 'false') { ret.push(arg.slice(0, index)); - } else if (value !== 'false') { - console.error("error: option '%s' doesn't take a value (use '%s' or '%s=false')", opt.flags, arg.slice(0, index), arg.slice(0, index)); - process.exit(1); } } else { ret.push(arg.slice(0, index), arg.slice(index + 1)); diff --git a/src/commander-fork/test/test.fork-fixes.spec.ts b/src/commander-fork/test/test.fork-fixes.spec.ts index 4e4f6d3..919d2ba 100644 --- a/src/commander-fork/test/test.fork-fixes.spec.ts +++ b/src/commander-fork/test/test.fork-fixes.spec.ts @@ -6,7 +6,7 @@ * * FIX 1/3: Tightened negate detection regex * FIX 2/3: Support for short-only and custom version flags - * FIX 3/3: Boolean flags accept "=true" / "=false" + * FIX 3/3: "--flag=false" leaves a boolean flag unset * * These tests do NOT exist in the original commander v3.0.2. */ @@ -236,20 +236,17 @@ describe('Fork Fix 2: Version short-only and custom flags', () => { export {}; -describe('Fork Fix 3: Boolean flags with a value', () => { +describe('Fork Fix 3: Boolean flags with =false', () => { let exitSpy: MockInstance; - let errorSpy: MockInstance; beforeEach(() => { exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit'); }); - errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); }); afterEach(() => { exitSpy.mockRestore(); - errorSpy.mockRestore(); }); function parse(...args: string[]) { @@ -277,9 +274,10 @@ describe('Fork Fix 3: Boolean flags with a value', () => { expect(parse('--no-dotfiles=false').dotfiles).toBe(true); }); - it('rejects other values', () => { - expect(() => parse('--add=yes')).toThrow('process.exit'); - expect(errorSpy.mock.calls[0][0]).toContain("doesn't take a value"); + it('sets the flag for any other value, as commander v3.0.2 does', () => { + const program = parse('--add=yes'); + expect(program.add).toBe(true); + expect(program.args).toEqual([]); }); it('keeps splitting options with an argument', () => { diff --git a/src/engine/engine.gh-pages-behavior.spec.ts b/src/engine/engine.gh-pages-behavior.spec.ts index ada991f..bc17590 100644 --- a/src/engine/engine.gh-pages-behavior.spec.ts +++ b/src/engine/engine.gh-pages-behavior.spec.ts @@ -199,9 +199,7 @@ describe('gh-pages-fork publish() - behavioral snapshot', () => { await ghPages.publish(basePath, { repo, message }); const commitArgs = gitCalls('commit')[0]; - expect(commitArgs[commitArgs.indexOf('-m') + 1]).toBe(message); - // hooks of the user don't apply to deploy commits - expect(commitArgs).toContain('--no-verify'); + expect(commitArgs).toEqual(['commit', '-m', message]); }); }); diff --git a/src/gh-pages-fork/README.md b/src/gh-pages-fork/README.md index d912d27..597b2b5 100644 --- a/src/gh-pages-fork/README.md +++ b/src/gh-pages-fork/README.md @@ -65,8 +65,7 @@ The file structure and function names follow upstream, so upstream changes can b | Repository check | "Remote url mismatch" check against the cached clone | none (every clone is fresh); relative local paths are resolved before cloning | | Git environment | inherited | `GIT_DIR`/`GIT_WORK_TREE` pinned to the clone, inherited repository variables removed, `safe.directory` for the clone; `-c credential.helper=` when the URL carries a password | | Credentials | the token stays in the clone's `.git/config` | the remote URL in `.git/config` has no credentials; the URL with the token is only passed to `push` | -| Commit | `git commit -m` | `git commit --no-verify -m` (hooks of the user don't apply) | -| Process results | resolves on a falsy exit code (also for a process killed by a signal); stdout and stderr in one buffer; crashes on a missing git executable | rejects unless the exit code is 0 (the signal is added to the message); `output` is stdout only; a missing executable rejects with ENOENT | +| Process results | resolves on a falsy exit code (also for a process killed by a signal); stdout and stderr in one buffer; crashes on a missing git executable | rejects unless the exit code is 0 (the signal is added to the message); `exec()` resolves with stdout only (no `output` field); a missing executable rejects with ENOENT | | `getRemoteUrl()` | every failure becomes "Failed to get remote.X.url" | only a missing key (exit code 1) does; other git errors are reported with git's message | | Logging | `util.debuglog('gh-pages')` | `log` callback parameter of `publish()`; credentials in the repository URL are replaced with `***` | | File list | glob `src` pattern via tinyglobby | `listFiles()`: all files below `basePath`, follows symlinks, skips broken links, cycles and `.git`, honors `dotfiles` | diff --git a/src/gh-pages-fork/lib/git.ts b/src/gh-pages-fork/lib/git.ts index 021c949..4b3b8de 100644 --- a/src/gh-pages-fork/lib/git.ts +++ b/src/gh-pages-fork/lib/git.ts @@ -91,10 +91,8 @@ export interface CloneOptions { /** * Executes git commands in one repository directory. - * `output` holds the stdout of the last executed command. */ export class Git { - output = ''; /** * @param cwd Repository directory @@ -170,19 +168,18 @@ export class Git { } /** - * Execute an arbitrary git command. + * Execute an arbitrary git command and resolve with its stdout. */ - async exec(...args: string[]): Promise { - this.output = await spawn(this.cmd, [...this.args, ...args], { cwd: this.cwd, env: this.env }); - return this; + exec(...args: string[]): Promise { + return spawn(this.cmd, [...this.args, ...args], { cwd: this.cwd, env: this.env }); } /** * Fill the index with the tree of HEAD (the clone has no checkout), so that * a commit keeps the existing files of the branch. */ - keepExistingFiles(): Promise { - return this.exec('read-tree', 'HEAD'); + async keepExistingFiles(): Promise { + await this.exec('read-tree', 'HEAD'); } /** @@ -190,26 +187,26 @@ export class Git { * (the user's global gitignore or a .gitignore in dist must not drop files). * With `keepRemoved`, files missing from the working tree stay in the index. */ - addAll(keepRemoved: boolean): Promise { - return this.exec('add', '--force', keepRemoved ? '--ignore-removal' : '--all', '--', '.'); + async addAll(keepRemoved: boolean): Promise { + await this.exec('add', '--force', keepRemoved ? '--ignore-removal' : '--all', '--', '.'); } /** - * Commit (if there are any changes). Hooks of the user don't apply to deploy commits. + * Commit (if there are any changes). */ - async commit(message: string): Promise { + async commit(message: string): Promise { try { - return await this.exec('diff-index', '--quiet', 'HEAD'); + await this.exec('diff-index', '--quiet', 'HEAD'); } catch { - return this.exec('commit', '--no-verify', '-m', message); + await this.exec('commit', '-m', message); } } /** * Push a branch to a remote name or URL. */ - push(remote: string, branch: string): Promise { - return this.exec('push', '--tags', remote, branch); + async push(remote: string, branch: string): Promise { + await this.exec('push', '--tags', remote, branch); } /** @@ -218,8 +215,7 @@ export class Git { async getRemoteUrl(remote: string): Promise { let repo: string | undefined; try { - await this.exec('config', '--get', 'remote.' + remote + '.url'); - repo = this.output.split(/[\n\r]/).shift(); + repo = (await this.exec('config', '--get', 'remote.' + remote + '.url')).split(/[\n\r]/).shift(); } catch (error) { if (!(error instanceof ProcessError)) { throw error; diff --git a/src/gh-pages-fork/lib/index.ts b/src/gh-pages-fork/lib/index.ts index 9e165d6..a18757e 100644 --- a/src/gh-pages-fork/lib/index.ts +++ b/src/gh-pages-fork/lib/index.ts @@ -154,8 +154,8 @@ export async function getRepo(options: { repo?: string; git?: string; remote?: s if (!isRelativePath(repo)) { return repo; } - await git.exec('rev-parse', '--show-toplevel'); - return path.resolve(git.output.trim(), repo); + const topLevel = await git.exec('rev-parse', '--show-toplevel'); + return path.resolve(topLevel.trim(), repo); } /** diff --git a/src/gh-pages-fork/lib/util.ts b/src/gh-pages-fork/lib/util.ts index ca2def5..d39c7ca 100644 --- a/src/gh-pages-fork/lib/util.ts +++ b/src/gh-pages-fork/lib/util.ts @@ -98,11 +98,9 @@ export async function copy(files: string[], base: string, dest: string): Promise */ export async function getUser(cwd = process.cwd(), cmd = 'git'): Promise { try { - const [name, email] = await Promise.all([ - new Git(cwd, cmd).exec('config', 'user.name'), - new Git(cwd, cmd).exec('config', 'user.email') - ]); - return { name: name.output.trim(), email: email.output.trim() }; + const git = new Git(cwd, cmd); + const [name, email] = await Promise.all([git.exec('config', 'user.name'), git.exec('config', 'user.email')]); + return { name: name.trim(), email: email.trim() }; } catch { return null; } diff --git a/src/gh-pages-fork/test/git-commands.spec.ts b/src/gh-pages-fork/test/git-commands.spec.ts index 1e73f10..ca7153f 100644 --- a/src/gh-pages-fork/test/git-commands.spec.ts +++ b/src/gh-pages-fork/test/git-commands.spec.ts @@ -75,7 +75,7 @@ describe('gh-pages-fork git commands', () => { `git config user.email ${user.email}`, `git config user.name ${user.name}`, 'git diff-index --quiet HEAD', - 'git commit --no-verify -m Deploy', + 'git commit -m Deploy', 'git push --tags origin gh-pages' ]); }); @@ -95,7 +95,7 @@ describe('gh-pages-fork git commands', () => { `git config user.email ${user.email}`, `git config user.name ${user.name}`, 'git diff-index --quiet HEAD', - 'git commit --no-verify -m Deploy', + 'git commit -m Deploy', 'git push --tags origin gh-pages' ]); }); @@ -203,7 +203,7 @@ describe('gh-pages-fork git commands', () => { expect(calls.every((call) => call.args[0] === '-c' && call.args[1] === 'credential.helper=')).toBe(true); expect(commands()).toContain('git remote set-url origin https://github.com/user/repo.git'); - expect(commands()).toContain('git commit --no-verify -m Deploy'); + expect(commands()).toContain('git commit -m Deploy'); expect(commands().at(-1)).toBe(`git push --tags ${repoWithToken} gh-pages`); }); diff --git a/src/parameter-tests/cli-e2e.spec.ts b/src/parameter-tests/cli-e2e.spec.ts index 212c56b..4e3e701 100644 --- a/src/parameter-tests/cli-e2e.spec.ts +++ b/src/parameter-tests/cli-e2e.spec.ts @@ -408,11 +408,12 @@ describe('CLI End-to-End Tests', () => { }); describe('Argument validation', () => { - it('should reject positional arguments', () => { + it('should warn about positional arguments and ignore them, as before', () => { const result = runCliRaw(['dist/my-app/browser', '--dry-run'], path.resolve(__dirname, '..')); - expect(result.status).toBe(1); - expect(result.output).toContain('Unexpected argument(s): dist/my-app/browser'); + expect(result.status).toBe(0); + expect(result.output).toContain('Ignoring argument(s): dist/my-app/browser – use --dir= to choose the directory to deploy.'); + expect(parseJsonFromCliOutput(result.output).dir).toBe(path.resolve(__dirname, '..', 'dist')); }); it('should treat --add=false as add disabled', () => { From 73ccca1f59a242a60a861005e8ad785054471b34 Mon Sep 17 00:00:00 2001 From: Johannes Hoppe Date: Wed, 14 Oct 2026 12:03:41 +0200 Subject: [PATCH 17/17] chore: release 3.2.0-rc.0 No user-facing breaking changes, so the fork ships as a minor version: everyone on ^3 gets the dependency-free publish step and its fixes. --- README.md | 2 +- docs/README_contributors.md | 5 ++--- src/package-lock.json | 4 ++-- src/package.json | 2 +- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 763d55b..1418401 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ A detailed changelog is available in the [releases](https://github.com/angular-schule/angular-cli-ghpages/releases) section. -`angular-cli-ghpages` v4 supports Angular 18 to 22. +`angular-cli-ghpages` v3 supports Angular 18 to 22. For previous versions of Angular, use v1 or v2. ## ⚠️ Prerequisites diff --git a/docs/README_contributors.md b/docs/README_contributors.md index 3b4d08e..afba806 100644 --- a/docs/README_contributors.md +++ b/docs/README_contributors.md @@ -42,9 +42,8 @@ Follow the instructions for [checking and updating the Angular CLI version](#ang This builder requires the method `getTargetOptions()` from the Angular DevKit which was introduced [here](https://github.com/angular/angular-cli/pull/13825/files). **Version compatibility:** -- **v4.x:** Supports Angular 18 and higher (current version) -- **v3.x:** Supported Angular 18 to 22 (previous major version) -- **v2.x:** Supported Angular 18 +- **v3.x:** Supports Angular 18 and higher (current version) +- **v2.x:** Supported Angular 18 (previous major version) - **v1.x:** Supported Angular 9-17 (deprecated) Execute the next three steps to update your test project to the latest Angular version. diff --git a/src/package-lock.json b/src/package-lock.json index 9cab3ee..28f4dca 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "angular-cli-ghpages", - "version": "4.0.0", + "version": "3.2.0-rc.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "angular-cli-ghpages", - "version": "4.0.0", + "version": "3.2.0-rc.0", "license": "MIT", "dependencies": { "@angular-devkit/architect": ">=0.1800.0 <0.2300.0", diff --git a/src/package.json b/src/package.json index 8549ea1..47e58b0 100644 --- a/src/package.json +++ b/src/package.json @@ -1,6 +1,6 @@ { "name": "angular-cli-ghpages", - "version": "4.0.0", + "version": "3.2.0-rc.0", "description": "Deploy your Angular app to GitHub Pages or Cloudflare Pages directly from the Angular CLI (ng deploy)", "main": "index.js", "types": "index.d.ts",