From aa17ac7d7f51f4c0544af8229f6cabac543c6854 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 17 Feb 2026 16:11:16 +0100 Subject: [PATCH] Fix gh-pages publish leaving stale worktrees on commit failure (#14046) Validate git user identity (name/email) before creating the publish worktree, and use `--force` on worktree removal to handle dirty state. Also checks GIT_AUTHOR_*/GIT_COMMITTER_* environment variables as fallback, matching git's own identity resolution behavior. Co-Authored-By: Claude Opus 4.6 --- news/changelog-1.9.md | 4 ++++ src/core/git.ts | 29 +++++++++++++++++++++++++++++ src/publish/gh-pages/gh-pages.ts | 22 +++++++++++++++++++--- 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index 68b7a18251b..70f02f6c333 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -123,6 +123,10 @@ All changes included in 1.9: - ([#13414](https://github.com/quarto-dev/quarto-cli/issues/13414)): Be more forgiving when Confluence server returns malformed JSON response. (author: @m1no) +### `gh-pages` + +- ([#14046](https://github.com/quarto-dev/quarto-cli/issues/14046)): Fix `quarto publish gh-pages` leaving stale worktrees when `git commit` fails due to missing `user.name`/`user.email` configuration. Git identity is now validated before worktree creation, and worktree cleanup uses `--force` to handle modified/untracked files. + ## Lua API - ([#13762](https://github.com/quarto-dev/quarto-cli/issues/13762)): Add `quarto.paths.typst()` to Quarto's Lua API to resolve Typst binary path in Lua filters and extensions consistently with Quarto itself. (author: @mcanouil) diff --git a/src/core/git.ts b/src/core/git.ts index ca368ca2e51..79b2214412f 100644 --- a/src/core/git.ts +++ b/src/core/git.ts @@ -87,6 +87,35 @@ export async function gitBranchExists( return Promise.resolve(undefined); } +export async function gitUserIdentityConfigured( + dir: string, +): Promise { + const name = await execProcess({ + cmd: "git", + args: ["config", "user.name"], + cwd: dir, + stdout: "piped", + stderr: "piped", + }); + const email = await execProcess({ + cmd: "git", + args: ["config", "user.email"], + cwd: dir, + stdout: "piped", + stderr: "piped", + }); + + const hasName = (name.success && (name.stdout?.trim().length ?? 0) > 0) || + (Deno.env.get("GIT_AUTHOR_NAME")?.trim().length ?? 0) > 0 || + (Deno.env.get("GIT_COMMITTER_NAME")?.trim().length ?? 0) > 0; + + const hasEmail = (email.success && (email.stdout?.trim().length ?? 0) > 0) || + (Deno.env.get("GIT_AUTHOR_EMAIL")?.trim().length ?? 0) > 0 || + (Deno.env.get("GIT_COMMITTER_EMAIL")?.trim().length ?? 0) > 0; + + return hasName && hasEmail; +} + export async function gitCmdOutput( dir: string, args: string[], diff --git a/src/publish/gh-pages/gh-pages.ts b/src/publish/gh-pages/gh-pages.ts index 3da6209027b..622db73e2ba 100644 --- a/src/publish/gh-pages/gh-pages.ts +++ b/src/publish/gh-pages/gh-pages.ts @@ -27,12 +27,18 @@ import { joinUrl } from "../../core/url.ts"; import { completeMessage, withSpinner } from "../../core/console.ts"; import { renderForPublish } from "../common/publish.ts"; import { RenderFlags } from "../../command/render/types.ts"; -import { gitBranchExists, gitCmds, gitVersion } from "../../core/git.ts"; +import { + gitBranchExists, + gitCmds, + gitUserIdentityConfigured, + gitVersion, +} from "../../core/git.ts"; import { anonymousAccount, gitHubContextForPublish, verifyContext, } from "../common/git.ts"; +import { throwUnableToPublish } from "../common/errors.ts"; import { createTempContext } from "../../core/temp.ts"; import { projectScratchPath } from "../../project/project-scratch.ts"; @@ -116,6 +122,16 @@ async function publish( const ghContext = await gitHubContextForPublish(options.input); verifyContext(ghContext, "GitHub Pages"); + // verify git user identity is configured (needed for commits in worktree) + if (!await gitUserIdentityConfigured(input)) { + throwUnableToPublish( + "git user.name and/or user.email is not configured\n" + + "(run 'git config user.name \"Your Name\"' and " + + "'git config user.email \"you@example.com\"' to set them)", + "GitHub Pages", + ); + } + // create gh pages branch on remote and local if there is none yet const createGhPagesBranchRemote = !ghContext.ghPagesRemote; const createGhPagesBranchLocal = !ghContext.ghPagesLocal; @@ -211,7 +227,7 @@ async function publish( const worktreePath = join(projectScratchPath(input), entry.name); await execProcess({ cmd: "git", - args: ["worktree", "remove", worktreePath], + args: ["worktree", "remove", "--force", worktreePath], cwd: projectScratchPath(input), }); removeIfExists(worktreePath); @@ -423,7 +439,7 @@ async function withWorktree( } finally { await execProcess({ cmd: "git", - args: ["worktree", "remove", siteDir], + args: ["worktree", "remove", "--force", siteDir], cwd: dir, }); }