diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1f7dddf..0ca8eb9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -86,7 +86,11 @@ jobs: npm link angular-cli-ghpages npx ng add angular-cli-ghpages npx ng deploy + echo second > dist/test-app-18/browser/second.txt + npx ng deploy --no-build --message="second deploy" cd /tmp/remote-18.git && git branch | grep gh-pages + test "$(git rev-list --count gh-pages)" = 2 + git cat-file -e gh-pages:.nojekyll echo "Angular 18: build + deploy successful" - name: Test Angular 19 @@ -99,7 +103,11 @@ jobs: npm link angular-cli-ghpages npx ng add angular-cli-ghpages npx ng deploy + echo second > dist/test-app-19/browser/second.txt + npx ng deploy --no-build --message="second deploy" cd /tmp/remote-19.git && git branch | grep gh-pages + test "$(git rev-list --count gh-pages)" = 2 + git cat-file -e gh-pages:.nojekyll echo "Angular 19: build + deploy successful" - name: Test Angular 20 @@ -112,7 +120,11 @@ jobs: npm link angular-cli-ghpages npx ng add angular-cli-ghpages npx ng deploy + echo second > dist/test-app-20/browser/second.txt + npx ng deploy --no-build --message="second deploy" cd /tmp/remote-20.git && git branch | grep gh-pages + test "$(git rev-list --count gh-pages)" = 2 + git cat-file -e gh-pages:.nojekyll echo "Angular 20: build + deploy successful" - name: Test Angular 21 @@ -125,7 +137,11 @@ jobs: npm link angular-cli-ghpages npx ng add angular-cli-ghpages npx ng deploy + echo second > dist/test-app-21/browser/second.txt + npx ng deploy --no-build --message="second deploy" cd /tmp/remote-21.git && git branch | grep gh-pages + test "$(git rev-list --count gh-pages)" = 2 + git cat-file -e gh-pages:.nojekyll echo "Angular 21: build + deploy successful" - name: Test Angular 22 @@ -138,5 +154,9 @@ jobs: npm link angular-cli-ghpages npx ng add angular-cli-ghpages npx ng deploy + echo second > dist/test-app-22/browser/second.txt + npx ng deploy --no-build --message="second deploy" cd /tmp/remote-22.git && git branch | grep gh-pages + test "$(git rev-list --count gh-pages)" = 2 + git cat-file -e gh-pages:.nojekyll echo "Angular 22: build + deploy successful" diff --git a/src/engine/engine.gh-pages-cleanup.spec.ts b/src/engine/engine.gh-pages-cleanup.spec.ts index c734023..d355162 100644 --- a/src/engine/engine.gh-pages-cleanup.spec.ts +++ b/src/engine/engine.gh-pages-cleanup.spec.ts @@ -153,6 +153,62 @@ describe('end-to-end cleanup regression (issue #204, real git)', () => { 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. diff --git a/src/engine/engine.prepare-options-helpers.spec.ts b/src/engine/engine.prepare-options-helpers.spec.ts index a876d6d..444c75a 100644 --- a/src/engine/engine.prepare-options-helpers.spec.ts +++ b/src/engine/engine.prepare-options-helpers.spec.ts @@ -721,4 +721,47 @@ describe('prepareOptions helpers - intensive tests', () => { } }); }); + + 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 46c8910..793d357 100644 --- a/src/engine/engine.prepare-options-helpers.ts +++ b/src/engine/engine.prepare-options-helpers.ts @@ -346,6 +346,10 @@ export async function collectDistFiles( * 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. @@ -353,13 +357,14 @@ export async function collectDistFiles( export function createCleanupBeforeAddHook( distDir: string, dotfiles: boolean, - logger: logging.LoggerApi + 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)); + const toRemove = tracked.filter((f) => !distFiles.has(f) && !generatedFiles.includes(f)); if (toRemove.length === 0) { return; } diff --git a/src/engine/engine.ts b/src/engine/engine.ts index 4fde957..67f8d57 100644 --- a/src/engine/engine.ts +++ b/src/engine/engine.ts @@ -49,7 +49,7 @@ export async function run( await checkIfDistFolderExists(dir); await createNotFoundFile(dir, prepared, logger); - // Note: CNAME and .nojekyll files are now created by gh-pages v6+ via options + // gh-pages writes CNAME and .nojekyll into its clone (cname/nojekyll options) await publishViaGhPages(ghpages, dir, prepared, logger); if (!prepared.dryRun) { @@ -150,8 +150,21 @@ async function createNotFoundFile( } } -// CNAME and .nojekyll files are now handled by gh-pages v6+ via the cname/nojekyll options -// Previously we created these files ourselves before publishing, but gh-pages PR #533 added native support +/** + * 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, @@ -206,7 +219,7 @@ async function publishViaGhPages( // 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) + beforeAdd: options.add ? undefined : createCleanupBeforeAddHook(dir, options.dotfiles, logger, generatedFiles(options)) }; // gh-pages@6 silently absorbs errors via its internal .then(_, onRejected) diff --git a/src/package-lock.json b/src/package-lock.json index 8e6d7e9..f1c6656 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "angular-cli-ghpages", - "version": "3.1.1", + "version": "3.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "angular-cli-ghpages", - "version": "3.1.1", + "version": "3.1.2", "license": "MIT", "dependencies": { "@angular-devkit/architect": ">=0.1800.0 <0.2300.0", diff --git a/src/package.json b/src/package.json index 8662572..ff12760 100644 --- a/src/package.json +++ b/src/package.json @@ -1,6 +1,6 @@ { "name": "angular-cli-ghpages", - "version": "3.1.1", + "version": "3.1.2", "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",