mirror of https://github.com/theNewDynamic/gohugo-theme-ananke.git

Patrick Kollitsch
yesterday 1b7a988ed4dab2d0db3152bacd893cb67eb50cab
fix: test local working tree in quickstart test (#938)

The quickstart test installed the theme via `git submodule add` from the
published remote, so CI (which checks out the PR) and the pre-push hook
never exercised the local code under test — a broken change could pass.

Install the local working tree as the theme by default (via `git ls-files`,
including uncommitted/untracked changes while skipping ignored paths), and
keep the documented published-submodule flow behind `--use-submodule`.

- scripts: add `--theme-path` / `--use-submodule`, default to local mode
- ci: run local mode on every push/PR, submodule mode post-merge on main
- package.json: add `test:quickstart:submodule`

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3 files modified
299 ■■■■ changed files
.github/workflows/quickstart.yml 12 ●●●●● patch | view | raw | blame | history
package.json 1 ●●●● patch | view | raw | blame | history
scripts/test-hugo-quickstart.ts 286 ●●●● patch | view | raw | blame | history
.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
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": {
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");