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