From 1b7a988ed4dab2d0db3152bacd893cb67eb50cab Mon Sep 17 00:00:00 2001
From: Patrick Kollitsch <davidsneighbourdev+gh@gmail.com>
Date: Thu, 04 Jun 2026 23:21:31 +0000
Subject: [PATCH] fix: test local working tree in quickstart test (#938)

---
 scripts/test-hugo-quickstart.ts  |  286 ++++++++++++++++++++++++++++++++++++++++++++++++++------
 package.json                     |    1 
 .github/workflows/quickstart.yml |   12 ++
 3 files changed, 264 insertions(+), 35 deletions(-)

diff --git a/.github/workflows/quickstart.yml b/.github/workflows/quickstart.yml
index a9ece81..7281781 100644
--- a/.github/workflows/quickstart.yml
+++ b/.github/workflows/quickstart.yml
@@ -46,6 +46,16 @@
       - name: Install dependencies
         run: npm ci
 
-      - name: Run quickstart test
+      # Default (local) mode installs the checked-out working tree as the theme,
+      # so this gate actually exercises the code on the current branch / PR.
+      - name: Run quickstart test (local working tree)
         run: |
           node scripts/test-hugo-quickstart.ts
+
+      # Additionally verify the documented submodule install against the published
+      # theme, but only after merge to a release branch to avoid testing stale
+      # published code on feature branches and PRs.
+      - name: Run quickstart test (published submodule)
+        if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master')
+        run: |
+          node scripts/test-hugo-quickstart.ts --use-submodule
diff --git a/package.json b/package.json
index 0a79640..81f83fb 100644
--- a/package.json
+++ b/package.json
@@ -62,6 +62,7 @@
 		"server": "hugo server --environment documentation",
 		"test": "node scripts/test-hugo-quickstart.ts",
 		"test:quickstart": "node scripts/test-hugo-quickstart.ts",
+		"test:quickstart:submodule": "node scripts/test-hugo-quickstart.ts --use-submodule",
 		"update:docs": "git add docs/ && (git diff --cached --quiet || git commit -m \"chore(git): update documentation submodule\")"
 	},
 	"cspell": {
diff --git a/scripts/test-hugo-quickstart.ts b/scripts/test-hugo-quickstart.ts
index 214b08e..0c83ad8 100644
--- a/scripts/test-hugo-quickstart.ts
+++ b/scripts/test-hugo-quickstart.ts
@@ -2,9 +2,25 @@
 
 import { spawn } from "node:child_process";
 import { constants } from "node:fs";
-import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import {
+	access,
+	copyFile,
+	cp,
+	mkdir,
+	mkdtemp,
+	readFile,
+	rm,
+	stat,
+	writeFile,
+} from "node:fs/promises";
 import { tmpdir } from "node:os";
-import { join } from "node:path";
+import { basename, dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+/**
+ * Absolute path to the theme repository root (the parent of `scripts/`).
+ */
+const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
 
 interface CommandResult {
 	code: number | null;
@@ -23,8 +39,22 @@
 	expectedFiles?: string[];
 }
 
+/**
+ * Where the theme under test comes from.
+ *
+ * - `local`: install the local working tree of this repository (default), so the
+ *   test exercises the actual code on the current branch, including uncommitted
+ *   changes. This is what makes the test meaningful as a pre-push / CI gate.
+ * - `submodule`: clone the published theme from its remote via `git submodule add`,
+ *   reproducing the documented quickstart install. Useful to verify the public
+ *   install path, but does **not** see local changes.
+ */
+type ThemeSource = "local" | "submodule";
+
 interface RoutineOptions {
 	projectName: string;
+	themeSource: ThemeSource;
+	themePath: string;
 	themeRepo: string;
 	themeDir: string;
 	themeName: string;
@@ -48,6 +78,8 @@
 
 const DEFAULT_OPTIONS: RoutineOptions = {
 	projectName: "quickstart",
+	themeSource: "local",
+	themePath: REPO_ROOT,
 	themeRepo: "https://github.com/gohugo-ananke/ananke.git",
 	themeDir: "themes/ananke",
 	themeName: "ananke",
@@ -68,7 +100,11 @@
 
 Options:
   --project-name=<name>         Hugo project folder name inside the temp directory
-  --theme-repo=<url>            Git URL for the theme submodule
+  --theme-path=<path>           Install the theme from this local directory (default: this repo).
+                                Implies local mode; tests the actual working tree.
+  --use-submodule               Install the published theme via "git submodule add" instead
+                                of the local working tree (verifies the documented quickstart).
+  --theme-repo=<url>            Git URL for the theme submodule (only used with --use-submodule)
   --theme-dir=<path>            Theme target directory inside the project
   --theme-name=<name>           Theme name written into hugo.toml
   --config-file=<file>          Hugo config file to update
@@ -111,6 +147,17 @@
 			continue;
 		}
 
+		if (arg === "--use-submodule") {
+			options.themeSource = "submodule";
+			continue;
+		}
+
+		if (arg.startsWith("--theme-path=")) {
+			options.themePath = resolve(arg.slice("--theme-path=".length));
+			options.themeSource = "local";
+			continue;
+		}
+
 		if (arg.startsWith("--project-name=")) {
 			options.projectName = arg.slice("--project-name=".length);
 			continue;
@@ -664,6 +711,194 @@
 }
 
 /**
+ * Determine whether a directory is the work tree of a Git repository.
+ *
+ * @param path Absolute directory path.
+ * @returns True when `path` is inside a Git work tree.
+ */
+async function isGitWorkTree(path: string): Promise<boolean> {
+	const result = await runCommand(
+		"git",
+		["-C", path, "rev-parse", "--is-inside-work-tree"],
+		path,
+	);
+
+	return result.code === 0 && result.stdout.trim() === "true";
+}
+
+/**
+ * Copy the local theme working tree into the project's theme directory.
+ *
+ * When the source is a Git work tree, the file list is derived from Git so that
+ * ignored paths (`node_modules`, `public`, generated resources, ...) are skipped
+ * automatically while uncommitted and untracked-but-not-ignored changes are still
+ * included. This makes the test reflect the exact state of the current branch.
+ *
+ * @param themePath Absolute path to the local theme source directory.
+ * @param destination Absolute path to the theme directory inside the project.
+ * @throws Error when the source contains no theme files.
+ */
+async function copyLocalTheme(
+	themePath: string,
+	destination: string,
+): Promise<void> {
+	if (await isGitWorkTree(themePath)) {
+		const listing = await runCommand(
+			"git",
+			[
+				"-C",
+				themePath,
+				"ls-files",
+				"-z",
+				"--cached",
+				"--others",
+				"--exclude-standard",
+			],
+			themePath,
+		);
+
+		if (listing.code !== 0) {
+			throw new Error(
+				`Failed to list theme files via git in ${themePath}:\n${listing.stderr}`,
+			);
+		}
+
+		const relativePaths = listing.stdout.split("\0").filter(Boolean);
+
+		if (relativePaths.length === 0) {
+			throw new Error(`No theme files found in ${themePath}`);
+		}
+
+		for (const relativePath of relativePaths) {
+			const source = join(themePath, relativePath);
+
+			try {
+				const stats = await stat(source);
+				if (!stats.isFile()) {
+					continue;
+				}
+			} catch {
+				// Tracked but deleted in the work tree: nothing to copy.
+				continue;
+			}
+
+			const target = join(destination, relativePath);
+			await mkdir(dirname(target), { recursive: true });
+			await copyFile(source, target);
+		}
+
+		return;
+	}
+
+	// Fallback for a non-Git source directory: copy recursively while excluding
+	// heavy or generated paths that would never ship with the theme.
+	const excludedNames = new Set(["node_modules", "public", ".git"]);
+	await cp(themePath, destination, {
+		recursive: true,
+		filter: (source: string): boolean => {
+			if (excludedNames.has(basename(source))) {
+				return false;
+			}
+
+			return !source.includes(join("resources", "_gen"));
+		},
+	});
+}
+
+/**
+ * Install the theme into the temporary project, either from the local working
+ * tree (default) or from the published remote via a Git submodule.
+ *
+ * @param options Runtime options.
+ * @param projectRoot Absolute path to the temporary quickstart project.
+ * @param reports Accumulated step reports (appended to in submodule mode).
+ * @throws Error when installation fails or the theme is incomplete.
+ */
+async function installTheme(
+	options: RoutineOptions,
+	projectRoot: string,
+	reports: StepReport[],
+): Promise<void> {
+	const destination = join(projectRoot, options.themeDir);
+
+	if (options.verbose) {
+		console.log(`\n[RUN] Install theme (source: ${options.themeSource})`);
+	}
+
+	if (options.themeSource === "submodule") {
+		const step: StepDefinition = {
+			name: "Add theme as Git submodule",
+			command: "git",
+			args: ["submodule", "add", options.themeRepo, options.themeDir],
+			cwd: projectRoot,
+			expectedFiles: [options.themeDir, ".gitmodules"],
+		};
+		const report = await executeStep(step);
+		reports.push(report);
+
+		if (options.verbose) {
+			console.log(
+				`[OK ] ${step.name} (${report.result.durationMs} ms, exit ${String(report.result.code)})`,
+			);
+		}
+
+		return;
+	}
+
+	const started = Date.now();
+	await copyLocalTheme(options.themePath, destination);
+
+	// Sanity check: a usable theme must at least expose theme.toml and layouts.
+	await assertFileExists(join(destination, "theme.toml"));
+	await assertFileExists(join(destination, "layouts"));
+
+	if (options.verbose) {
+		console.log(
+			`[OK ] Copied local theme from ${options.themePath} (${Date.now() - started} ms)`,
+		);
+	}
+}
+
+/**
+ * Run a list of command steps with consistent logging and reporting.
+ *
+ * @param steps Steps to execute in order.
+ * @param options Runtime options.
+ * @param projectRoot Absolute path to the temporary quickstart project.
+ * @param reports Accumulated step reports (appended to).
+ */
+async function runSteps(
+	steps: StepDefinition[],
+	options: RoutineOptions,
+	projectRoot: string,
+	reports: StepReport[],
+): Promise<void> {
+	for (const step of steps) {
+		if (options.verbose) {
+			console.log(`\n[RUN] ${step.name}`);
+			console.log(`      ${formatCommand(step.command, step.args)}`);
+		}
+
+		const report = isHugoBuildCommand(step)
+			? await executeHugoBuildStep(step, projectRoot)
+			: await executeStep(step);
+
+		reports.push(report);
+
+		if (options.verbose) {
+			console.log(
+				`[OK ] ${step.name} (${report.result.durationMs} ms, exit ${String(report.result.code)})`,
+			);
+
+			const trimmedOutput = report.result.combined.trim();
+			if (trimmedOutput) {
+				console.log(trimmedOutput);
+			}
+		}
+	}
+}
+
+/**
  * Run the full Hugo quickstart verification routine.
  *
  * @param options Runtime options.
@@ -675,7 +910,8 @@
 
 	const reports: StepReport[] = [];
 
-	const steps: StepDefinition[] = [
+	// Steps that prepare the project before the theme is installed.
+	const setupSteps: StepDefinition[] = [
 		{
 			name: "Create Hugo project",
 			command: "hugo",
@@ -690,13 +926,10 @@
 			cwd: projectRoot,
 			expectedFiles: [".git"],
 		},
-		{
-			name: "Add theme as Git submodule",
-			command: "git",
-			args: ["submodule", "add", options.themeRepo, options.themeDir],
-			cwd: projectRoot,
-			expectedFiles: [options.themeDir, ".gitmodules"],
-		},
+	];
+
+	// Steps that run once the theme is in place.
+	const buildSteps: StepDefinition[] = [
 		{
 			name: "Configure theme in Hugo config",
 			command: "bash",
@@ -719,30 +952,15 @@
 	try {
 		console.log(`Test root: ${sandboxRoot}`);
 		console.log(`Project root: ${projectRoot}`);
+		console.log(
+			options.themeSource === "submodule"
+				? `Theme source: submodule (${options.themeRepo})`
+				: `Theme source: local (${options.themePath})`,
+		);
 
-		for (const step of steps) {
-			if (options.verbose) {
-				console.log(`\n[RUN] ${step.name}`);
-				console.log(`      ${formatCommand(step.command, step.args)}`);
-			}
-
-			const report = isHugoBuildCommand(step)
-				? await executeHugoBuildStep(step, projectRoot)
-				: await executeStep(step);
-
-			reports.push(report);
-
-			if (options.verbose) {
-				console.log(
-					`[OK ] ${step.name} (${report.result.durationMs} ms, exit ${String(report.result.code)})`,
-				);
-
-				const trimmedOutput = report.result.combined.trim();
-				if (trimmedOutput) {
-					console.log(trimmedOutput);
-				}
-			}
-		}
+		await runSteps(setupSteps, options, projectRoot, reports);
+		await installTheme(options, projectRoot, reports);
+		await runSteps(buildSteps, options, projectRoot, reports);
 
 		const configPath = join(projectRoot, options.configFile);
 		const homepagePath = join(projectRoot, "public/index.html");

--
Gitblit v1.10.0