Skip to content

Commit c07a4e0

Browse files
committed
fix: clean up leftover gh-pages branch files (dotfiles, submodules)
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. Upstream fix tschaub/gh-pages#612 (merged 2025-08-09) adds both `remove: '**/*'` and `dot: true`, but is unreleased as of v6.3.0. We can't reach the globby call from options alone. Workaround: register a `beforeAdd` hook that runs after gh-pages' broken remove + our file copy. The hook asks git what it still has indexed (`git ls-files -z`), diffs against the set of files in our dist, and `git rm`s the leftovers. `git rm` handles submodule gitlinks correctly, so the `build` gitlink from the reporter's scenario is also cleaned up. Skipped when the user opts into `add: true` (additive mode explicitly preserves existing files). Adds a spawn-level regression test in engine.gh-pages-behavior.spec.ts that mocks `git ls-files` to return leftover dotfiles + a submodule gitlink, runs engine.run() end-to-end through real gh-pages, and asserts `git rm` targets the leftovers while leaving dist files alone. Fixes #204
1 parent ada6fa9 commit c07a4e0

6 files changed

Lines changed: 177 additions & 6 deletions

File tree

src/engine/engine.gh-pages-behavior.spec.ts

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ function createMockChildProcess(): Partial<ChildProcess> {
5353
* This is intentional - we want to know about any new git operations.
5454
*/
5555
const EXPECTED_GIT_COMMANDS = [
56-
'clone', 'clean', 'fetch', 'checkout', 'ls-remote', 'reset',
56+
'clone', 'clean', 'fetch', 'checkout', 'ls-remote', 'ls-files', 'reset',
5757
'rm', 'add', 'config', 'diff-index', 'commit', 'tag', 'push', 'update-ref'
5858
];
5959

@@ -1066,4 +1066,87 @@ describe('gh-pages v6.3.0 - behavioral snapshot', () => {
10661066
expect(spawnCalls[0]?.args[0]).toBe('clone');
10671067
});
10681068
});
1069+
1070+
/**
1071+
* End-to-end regression for issue #204: gh-pages@6.3.0's "Removing files" step
1072+
* skips dotfiles and submodule gitlinks. Our beforeAdd hook should catch them
1073+
* via `git ls-files` and `git rm` the leftovers while leaving dist files alone.
1074+
*/
1075+
describe('submodule / dotfile cleanup regression (issue #204)', () => {
1076+
const engine = require('./engine');
1077+
const { cleanupMonkeypatch } = require('./engine.prepare-options-helpers');
1078+
const { logging } = require('@angular-devkit/core');
1079+
1080+
beforeEach(() => {
1081+
cleanupMonkeypatch();
1082+
});
1083+
1084+
it('engine.run() should git rm leftover gh-pages branch files not present in dist', async () => {
1085+
const repo = 'https://github.com/owner/leftover-test.git';
1086+
currentTestContext.repo = repo;
1087+
1088+
const leftoverFiles = [
1089+
'.github/workflows/deploy.yml',
1090+
'.gitignore',
1091+
'.gitmodules',
1092+
'build' // submodule gitlink
1093+
];
1094+
1095+
// Reconfigure the default mockSpawn so `git ls-files` returns our leftover set.
1096+
// Other git commands keep the outer describe's default behavior.
1097+
mockSpawn.mockImplementation((cmd: string, args: string[] | undefined, opts: unknown) => {
1098+
const capturedArgs = args || [];
1099+
spawnCalls.push({ cmd, args: capturedArgs, options: opts });
1100+
1101+
if (cmd === 'git' && capturedArgs[0] && !EXPECTED_GIT_COMMANDS.includes(capturedArgs[0])) {
1102+
throw new Error(`Unexpected git command: ${capturedArgs[0]}. Add to whitelist if intentional.`);
1103+
}
1104+
1105+
const child = createMockChildProcess();
1106+
setImmediate(() => {
1107+
let output = '';
1108+
if (cmd === 'git' && capturedArgs[0] === 'ls-files') {
1109+
output = leftoverFiles.join('\0') + '\0';
1110+
} else if (cmd === 'git' && capturedArgs[0] === 'config' &&
1111+
capturedArgs[1] === '--get' && capturedArgs[2]?.startsWith('remote.')) {
1112+
output = repo;
1113+
} else if (cmd === 'git' && capturedArgs[0] === 'ls-remote') {
1114+
output = 'refs/heads/gh-pages';
1115+
} else if (cmd === 'git' && capturedArgs[0] === 'diff-index') {
1116+
child.emit!('close', 1);
1117+
return;
1118+
}
1119+
child.stdout!.emit('data', Buffer.from(output));
1120+
child.emit!('close', 0);
1121+
});
1122+
return child;
1123+
});
1124+
1125+
const options = {
1126+
repo,
1127+
branch: 'gh-pages',
1128+
dotfiles: true,
1129+
notfound: false,
1130+
nojekyll: false
1131+
};
1132+
1133+
await engine.run(basePath, options, new logging.NullLogger());
1134+
1135+
// Our hook should have issued `git ls-files -z`.
1136+
const lsFilesCall = spawnCalls.find(c => c.cmd === 'git' && c.args[0] === 'ls-files');
1137+
expect(lsFilesCall).toBeDefined();
1138+
expect(lsFilesCall!.args).toContain('-z');
1139+
1140+
// And a subsequent `git rm` that targets exactly the leftovers.
1141+
const rmCalls = spawnCalls.filter(c => c.cmd === 'git' && c.args[0] === 'rm');
1142+
const cleanupCall = rmCalls.find(c => leftoverFiles.every(f => c.args.includes(f)));
1143+
expect(cleanupCall).toBeDefined();
1144+
1145+
// Dist files (from the outer beforeAll: index.html, main.js, styles.css, .htaccess)
1146+
// must NOT be in the cleanup call — those are files we just copied.
1147+
for (const distFile of ['index.html', 'main.js', 'styles.css', '.htaccess']) {
1148+
expect(cleanupCall!.args).not.toContain(distFile);
1149+
}
1150+
});
1151+
});
10691152
});

src/engine/engine.prepare-options-helpers.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
*/
77

88
import { logging } from '@angular-devkit/core';
9+
import * as fs from 'fs/promises';
10+
import * as path from 'path';
911
import * as util from 'util';
1012

1113
import { Schema } from '../deploy/schema';
@@ -225,6 +227,86 @@ export async function injectTokenIntoRepoUrl(options: PreparedOptions): Promise<
225227
}
226228
}
227229

230+
/**
231+
* Minimal subset of the gh-pages internal Git class that our cleanup hook relies on.
232+
* See src/node_modules/gh-pages/lib/git.js — `Git.prototype.exec`, `Git.prototype.rm`, `cwd`, `output`.
233+
*/
234+
export interface GhPagesGit {
235+
cwd: string;
236+
output: string;
237+
exec(...args: string[]): Promise<GhPagesGit>;
238+
rm(files: string[]): Promise<GhPagesGit>;
239+
}
240+
241+
/**
242+
* Recursively walk a directory and return the set of relative file paths,
243+
* using POSIX separators so the output matches what `git ls-files` prints.
244+
* Honors the same dotfile-inclusion semantics as gh-pages' `options.dotfiles`.
245+
*/
246+
export async function collectDistFiles(
247+
baseDir: string,
248+
includeDotfiles: boolean
249+
): Promise<Set<string>> {
250+
const files = new Set<string>();
251+
252+
async function walk(relDir: string): Promise<void> {
253+
const fullDir = relDir ? path.join(baseDir, relDir) : baseDir;
254+
const entries = await fs.readdir(fullDir, { withFileTypes: true });
255+
for (const entry of entries) {
256+
if (!includeDotfiles && entry.name.startsWith('.')) {
257+
continue;
258+
}
259+
const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
260+
if (entry.isDirectory()) {
261+
await walk(relPath);
262+
} else if (entry.isFile()) {
263+
files.add(relPath);
264+
}
265+
}
266+
}
267+
268+
await walk('');
269+
return files;
270+
}
271+
272+
/**
273+
* Create a `beforeAdd` hook that removes leftover files from the gh-pages branch
274+
* before gh-pages stages our dist for commit.
275+
*
276+
* Why this exists (issue #204):
277+
* gh-pages@6.3.0's "Removing files" step calls globby without `dot: true`, so
278+
* dotfiles (.gitignore, .gitmodules, .github/…) and submodule gitlinks from the
279+
* gh-pages branch are NOT removed before our dist is copied on top. They then
280+
* get re-committed and leak into the deploy.
281+
*
282+
* Fix: after gh-pages' broken remove + our file copy, ask git what it still has
283+
* indexed (`git ls-files -z`), diff against the set of files in our dist, and
284+
* `git rm` the leftovers. `git rm` correctly handles submodule gitlinks too.
285+
*
286+
* Upstream fix: tschaub/gh-pages#612 (merged 2025-08-09, unreleased as of
287+
* gh-pages@6.3.0). When a release containing that PR lands, this hook becomes
288+
* redundant and can be removed.
289+
*/
290+
export function createCleanupBeforeAddHook(
291+
distDir: string,
292+
dotfiles: boolean,
293+
logger: logging.LoggerApi
294+
): (git: GhPagesGit) => Promise<void> {
295+
return async (git) => {
296+
const distFiles = await collectDistFiles(distDir, dotfiles);
297+
await git.exec('ls-files', '-z');
298+
const tracked = (git.output || '').split('\0').filter(Boolean);
299+
const toRemove = tracked.filter((f) => !distFiles.has(f));
300+
if (toRemove.length === 0) {
301+
return;
302+
}
303+
logger.info(
304+
`Removing ${toRemove.length} leftover file(s) from gh-pages branch not in dist: ${toRemove.join(', ')}`
305+
);
306+
await git.rm(toRemove);
307+
};
308+
}
309+
228310
/**
229311
* Get the remote URL from the git repository
230312
*

src/engine/engine.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ import {
1313
handleUserCredentials,
1414
warnDeprecatedParameters,
1515
appendCIMetadata,
16-
injectTokenIntoRepoUrl
16+
injectTokenIntoRepoUrl,
17+
createCleanupBeforeAddHook
1718
} from './engine.prepare-options-helpers';
1819

1920
export async function run(
@@ -194,7 +195,11 @@ async function publishViaGhPages(
194195
dotfiles: options.dotfiles,
195196
user: options.user,
196197
cname: options.cname,
197-
nojekyll: options.nojekyll
198+
nojekyll: options.nojekyll,
199+
// Workaround for gh-pages#612 (unreleased in v6.3.0): clean up leftover
200+
// gh-pages branch files (dotfiles, submodule gitlinks) that the broken
201+
// remove step misses. Skipped when the user opts into additive mode.
202+
beforeAdd: options.add ? undefined : createCleanupBeforeAddHook(dir, options.dotfiles, logger)
198203
};
199204

200205
// gh-pages@6 silently absorbs errors via its internal .then(_, onRejected)

src/interfaces.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export interface PublishOptions {
6565
cname?: string;
6666
add?: boolean;
6767
git?: string;
68+
beforeAdd?: (git: unknown) => void | Promise<void>;
6869
[key: string]: unknown; // Allow additional gh-pages options
6970
}
7071

src/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "angular-cli-ghpages",
3-
"version": "3.0.3",
3+
"version": "3.0.4",
44
"description": "Deploy your Angular app to GitHub Pages or Cloudflare Pages directly from the Angular CLI (ng deploy)",
55
"main": "index.js",
66
"types": "index.d.ts",

0 commit comments

Comments
 (0)