Skip to content

Fix site name and git info when --path points to docs subfolder - #3789

Open
Mpdreamz wants to merge 6 commits into
mainfrom
fix/path-unknown-docs-name
Open

Fix site name and git info when --path points to docs subfolder#3789
Mpdreamz wants to merge 6 commits into
mainfrom
fix/path-unknown-docs-name

Conversation

@Mpdreamz

@Mpdreamz Mpdreamz commented Aug 6, 2026

Copy link
Copy Markdown
Member

Why

Running docs-builder serve --path ~/project/docs/ showed unknown-docs as the site title and an empty branch/commit panel instead of the repository name and branch.

Root cause (original bug): Paths.FindGitRoot(DocumentationSourceDirectory, ceiling: rootFolder). When --path points to the docs subfolder, rootFolder == docsPath so the ceiling locked the git-root walk inside that folder and could never see .git one level up — CheckoutDirectory was null, git info was Unavailable, and the name fell back to unknown-docs.

Root cause (design): FileSystemFactory exposed ambient statics (RealRead, RealWrite, AppData) that any code could reach for without declaring the dependency in its signature. The documentation scope depends on --path and --output, which are per-invocation — a singleton can never supply it correctly. Every command that called FileSystemFactory.RealRead silently coupled itself to the process CWD, making the scope invisible and untestable.

The statics also made the read/write distinction unenforceable. BuildContext.ReadFileSystem and BuildContext.WriteFileSystem were both typed ScopedFileSystem — a type that says "something scoped me" and nothing more. Nothing in the type system prevented passing a read scope as a write scope, and several test sites did exactly that. Git checkout resolution was scattered across three divergent implementations that could disagree on where the checkout was.

What

Strongly-typed, intent-carrying filesystems

The core idea: a type that accepts ScopedFileSystem accepts any scope. A caller looking at a ScopedFileSystem parameter has no idea whether it expects a read scope, a write scope, or whether .git access is allowed. The name in the variable — readFs, writeFs — is the only signal, and names aren't checked by the compiler.

Named subclasses fix this. A parameter typed DocumentationWriteFileSystem rejects a read scope at compile time. A parameter typed CheckoutsFileSystem can't be satisfied by a DocumentationFileSystem for a single docs set. The type carries the intent the bare ScopedFileSystem could not.

Call-site types — what consumers construct and pass around:

Class Where Use case
DocumentationFileSystem src/Elastic.Documentation.Tooling/FileSystems/ The entry point. Constructed via DocumentationFileSystem.Resolve(invocation, options). Runs the six-step bootstrap (invocation → docset anchor → checkout → git dirs → git info → output), exposes the result as .Paths, and is the read scope. Callers use it as fs.Read and fs.Write.Read is the instance itself; .Write is a DocumentationWriteFileSystem produced during construction, never separately instantiated.
DocumentationWriteFileSystem src/Elastic.Documentation/FileSystems/ An artifact of constructing DocumentationFileSystem, accessed via .Write. Same checkout roots as .Read but .git is excluded from the allowlist — writing into .git is structurally impossible. Because both scopes come from the same ResolvedDocumentationPaths, read and write can never disagree about the checkout or output path.
CheckoutsFileSystem src/Elastic.Documentation.Tooling/FileSystems/ Scope over a directory of cloned repositories. Used by the assembler, which operates on a tree of clones rather than a single documentation set. No docset anchoring, no git resolution. Exposes .Read (itself) and .Write following the same pattern.
ApplicationDataFileSystem src/Elastic.Documentation.Tooling/FileSystems/ Scope over the OS application-data directory. Used for the codex link-index clone. No per-invocation inputs.

Bootstrap helpers — instantiated internally by DocumentationPathsResolver, not by callers:

Class Where Role
DocsetScanFileSystem src/Elastic.Documentation.Tooling/FileSystems/ Rooted at the invocation path, nothing above it. Locates docset.yml/_docset.yml in steps 1–2 without escaping the invocation root. Discarded once the anchor is found.
GitResolveFileSystem src/Elastic.Documentation.Tooling/FileSystems/ Rooted maxParents above the docset anchor (not the invocation). Walks up to .git and reads config/HEAD from resolved git directories, including worktree commondir targets outside the anchor's ancestry, in steps 3–5. Discarded once git info is resolved.

Statics removed

FileSystemFactory.RealRead, RealWrite, and AppData are gone. Statics made the filesystem dependency invisible in signatures — a type that reached for FileSystemFactory.RealRead declared no dependency and could not be tested with a mock. Because the documentation scope is per-invocation (depends on --path and --output), a singleton was always wrong: it hardcoded the process CWD as the checkout root, which is exactly the bug that caused --path repo/docs to show unknown-docs.

With the statics gone, every consumer that wants a filesystem must declare it. The dependency is visible, the scope is per-invocation, and the type tells the reader what the scope is for.

Missing .git is now a hard error with a remedy

Previously, failing to find .git silently produced a null CheckoutDirectory, which then caused the name fallback to unknown-docs and empty git info downstream — the symptom was observable only at render time with no pointer to the cause.

Now DocumentationPathsResolver throws DocumentationPathException immediately:

No .git found at '/path/to/docs' or within 1 parent directory(ies).
Pass --git-dir to point at the repository's .git directory explicitly.

The message names the flag that fixes it. --git-dir accepts the path to the .git directory; its parent becomes the checkout. Worktrees are handled automatically through commondir--git-dir should point at the worktree's own .git file's location, not the shared object store.

Ordered six-step bootstrap (DocumentationPathsResolver)

Previously scattered across BuildContext's constructor, FileSystemFactory, and three separate git-resolution paths. Now a single static class with one method per step, each creating its own minimal scope and discarding it:

  1. Invocation path — the --path argument or CWD.
  2. Docset anchor — scan from invocation for docset.yml/_docset.yml (skipped when ConfigurationFile is pre-supplied). Uses DocsetScanFileSystem.
  3. Checkout--git-dir?.Parent ?? FindGitRoot(anchor, maxParents) ?? hard error naming --git-dir. Uses GitResolveFileSystem.
  4. Git directories — the real .git path(s) needed in scope. For worktrees: checkout/.git (pointer file) + the commondir target resolved via the unscoped inner filesystem.
  5. Git infoGitCheckoutInformationFactory.Create through a scope widened by step 4. Or DocumentationScopeOptions.Git if the caller pre-computed it (Assembler, Codex).
  6. Output--output argument or checkout/.artifacts/docs/html (anchored at the checkout, never the invocation, so --path /repo and --path /repo/docs both write to /repo/.artifacts).

BuildContext simplification

Stores one DocumentationFileSystem and computes ReadFileSystem, WriteFileSystem, DocumentationSourceDirectory, DocumentationCheckoutDirectory, ConfigurationPath, OutputDirectory, and Git as projections of .Paths. Nothing is copied independently, so nothing can drift out of agreement. The legacy ScopedFileSystem pair constructor is deleted; all seven src/ callers are migrated to DocumentationFileSystem.Resolve.

Worktree resolution fix

ResolveGitDirectories now uses the unscoped inner filesystem for commondir traversal, because the main .git directory lies outside the gitScope root by design. Explicit --git-dir injects its path into gitDirectories directly so the factory scope covers it.

Test plan

  • DocumentationPathsResolverTests — 21 new tests:
    • Normal repo: --path /repo and --path /repo/docs → same CheckoutDirectory and SourceDirectory
    • Regular repo git info (branch, ref, repository name)
    • Worktree: checkout = worktree root, GitDirectories includes the .git pointer path and resolved commondir target, git info resolved from main .git
    • Worktree + --path /worktree/docs → same checkout as --path /worktree
    • Explicit --git-dir: checkout = gitDir.Parent, git info from overridden dir
    • Mock FS without .git: graceful fallback, checkout = source, Git override preserved
    • Output defaults to checkout/.artifacts (regression guard: same for --path /repo and --path /repo/docs)
    • Explicit --output override respected
    • Pre-discovered ConfigurationFile skips docset scan
    • No docset → DocumentationPathException
    • DocumentationFileSystem.Resolve integration for regular repo and worktree
  • GitCheckoutResolutionTests — regular repo, detached HEAD, worktree absolute/relative gitdir, commondir, missing gitdir, canned-fallback guard
  • FindGitRootTests — maxParents cases, worktree pointer
  • BuildContextDocumentationCheckoutDirectoryTests--path /repo and --path /repo/docs converge
  • ./build.sh unit-test — 4332 tests pass (0 failures)
  • ./build.sh lint — clean
  • serve --path ~/Projects/docs-eng-team/docs — header shows repo name, branch/commit populated
  • serve --path ~/Projects/docs-eng-team/ — identical to above
  • serve from inside the repo — identical
  • Repeat above in a git worktree checkout

🤖 Generated with Claude Code
https://claude.ai/code/session_01PC5GiyZKhreYSqf2grz7pi

When running `docs-builder serve --path ~/Projects/docs-eng-team/docs/`,
FindGitRoot ceiling correctly stops at rootFolder (the docs folder), so
DocumentationCheckoutDirectory is null for local direct-path invocations.

Previously the Name fallback was `unknown-{sourceDir.Name}` (e.g. unknown-docs).
The fix: fall back to the parent directory name, which is the repository clone
directory — giving the expected name (e.g. docs-eng-team) without touching
the intentional null-ceiling behavior that tests validate.

Double-chevron: island entries in the parent nav show >> (icon-chevron-double-down
in the SVG sprite, rotated right by nav-chevron CSS) to signal they open a
sub-navigation rather than expand an inline subtree.

NavigationRenderNodeKind: add Island as a dedicated kind so the template has a
clean three-way branch (Leaf / Island / Node) instead of a Node kind with a
separate IsIslandListing boolean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…stable git checkout

Replace the broken ceiling-based git-root walk with a maxParents bound anchored at
the docset directory, making --path repo/ and --path repo/docs/ resolve to the same
DocumentationCheckoutDirectory. Fix GitCheckoutInformationFactory to stop escaping
ScopedFileSystem, use TryReadGitDirPointer (handling commondir), and never emit a
random GUID as a git ref. Move the #if DEBUG *.slnx relaxation into FindGitRoot
where the depth policy belongs.

Changes:
- Paths.FindGitRoot(IDirectoryInfo, int maxParents=1): replace ceiling param with
  maxParents so the walk is always bounded, docset-anchored, and the DEBUG *.slnx
  relaxation lives in one place
- Paths.TryReadGitDirPointer: single implementation of gitdir parsing that handles
  StartsWith("gitdir:"), relative paths resolved against the .git file directory
  (not CWD), and commondir following for nested worktrees
- GitCheckoutInformationFactory: use TryReadGitDirPointer, route all probes through
  guarded fileSystem.File/Directory.Exists members, eliminate fakeRef (failed HEAD
  -> Unavailable), invert mock shortcircuit (attempt real resolution first; canned
  fallback only when .git is absent or a bare dir with no config)
- BuildContext: FindGitRoot(DocumentationSourceDirectory, maxParents:1) instead of
  ceiling:rootFolder; removes gitRoot ?? .Parent fallback added in previous commit
- FindGitRootTests: port ceiling: cases to maxParents:; add WorktreeGitFile_OneLevel
- BuildContextDocumentationCheckoutDirectoryTests: SourceAsDocsSubtreeOnly now
  asserts CheckoutDirectory == repoPath (was null); add PathAndDocsSubfolder_ResolveIdenticalCheckout
- GitCheckoutResolutionTests: new suite covering regular repo, detached HEAD,
  worktree with absolute/relative gitdir, commondir, missing gitdir, canned fallback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@Mpdreamz Mpdreamz changed the title Fix site name showing "unknown-docs" when --path points to docs folder Fix site name and git info when --path points to docs subfolder Aug 6, 2026
Mpdreamz and others added 3 commits August 6, 2026 18:19
Documents the six-step bootstrap resolution order as the record's <summary>:
  1. <path> = --path ?? cwd
  2. SourceDirectory = docset scan (the anchor)
  3. CheckoutDirectory = --git-dir?.Parent ?? FindGitRoot(maxParents) ?? error (required)
  4. GitDirectories = real .git dirs for scope widening (worktrees)
  5. Git = GitCheckoutInformation resolved once, never re-derived
  6. OutputDirectory = --output ?? <path>/.artifacts/docs/html

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Delete Paths.FindGitRoot(string startPath): raw System.IO bootstrap overload
  is no longer needed; the docset anchor is always resolved first through
  IFileSystem, so the IDirectoryInfo overload covers every call site.

- FileSystemFactory.InMemoryForPath / RealGitRootForPath / RealGitRootForPathWrite:
  switch from the deleted string overload to new FileSystem().DirectoryInfo.New()
  + FindGitRoot(IDirectoryInfo, maxParents). RealGitRootForPath also replaces the
  ad-hoc worktree resolution (Replace/Path.Join('..','..')) with TryReadGitDirPointer,
  which handles relative gitdir paths and commondir correctly.

- Codex commands (CodexCommands x3, CodexIndexCommand, CodexUpdateRedirectsCommand):
  replace Paths.FindGitRoot(config.FullName) with the IDirectoryInfo overload via
  plain.DirectoryInfo.New(config.DirectoryName!).

- GitCheckoutInformationFactory: extract the mock fallback condition into
  IsLegacyTestWithoutGitLayout() with a xmldoc summary that explains in clear human
  terms that this exists for test setups predating testable git resolution.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
## Why

Every consumer previously received a non-descriptive ScopedFileSystem.
The type communicated nothing about what it scoped or whether it was a
read or write scope. BuildContext.ReadFileSystem and WriteFileSystem were
both typed ScopedFileSystem, so nothing prevented callers from passing a
read scope as a write scope — and several test sites did exactly that.

Root cause: the filesystem factory pattern (FileSystemFactory) encoded
scope policy as static helper methods and ambient statics (RealRead,
RealWrite, AppData). These statics made the dependency invisible in
signatures and impossible to stub per-invocation. The underlying bug
(serve --path repo/docs showing 'unknown-docs') was caused by checkout
resolution being scattered across three implementations that disagreed
on where to walk.

## New type hierarchy (src/Elastic.Documentation.Tooling/FileSystems/ and
   src/Elastic.Documentation/FileSystems/)

DocumentationFileSystem — read scope for a single documentation set.
  Constructed via DocumentationFileSystem.Resolve(invocation, options).
  Runs the six-step bootstrap (invocation → docset anchor → checkout →
  git dirs → git info → output) and exposes the result as .Paths.
  Owns .Write (a DocumentationWriteFileSystem) derived from the same
  resolved paths, so read and write can never disagree about the checkout.

DocumentationWriteFileSystem — write scope for a documentation build.
  Identical roots to the read scope but .git is excluded from the
  allow-list. Constructed from the same ResolvedDocumentationPaths.

CheckoutsFileSystem — read/write scope over a directory of clones.
  Used by the assembler, which operates on a tree of cloned repos rather
  than a single documentation set. No docset anchoring, no git resolution.

ApplicationDataFileSystem — scope over the OS application-data directory.
  Used for the codex link-index clone. No per-invocation inputs.

DocsetScanFileSystem — bootstrap-only scope rooted at the invocation
  path, nothing above it. Used by DocumentationPathsResolver step 1-2
  to locate docset.yml without being able to escape the invocation root.

GitResolveFileSystem — bootstrap-only scope rooted maxParents above the
  docset anchor (not the invocation). Used by steps 3-5 to walk up to
  .git and then read config/HEAD from resolved git directories (including
  worktree commondir targets that lie outside the anchor's ancestry).

## What changed

DocumentationPathsResolver.Resolve implements the ordered six steps that
were previously scattered across BuildContext's constructor, FileSystemFactory,
and three separate git-resolution paths. Each step creates its own minimal
bootstrap scope and discards it; the final DocumentationFileSystem carries
only the resolved paths.

BuildContext stores one DocumentationFileSystem and computes ReadFileSystem,
WriteFileSystem, DocumentationSourceDirectory, DocumentationCheckoutDirectory,
ConfigurationPath, OutputDirectory, and Git as projections of it. Nothing is
copied independently, so nothing can drift out of agreement.

The legacy ScopedFileSystem pair constructor on BuildContext is deleted.
All seven src/ callers are migrated to DocumentationFileSystem.Resolve.

DocumentationScopeOptions replaces seven separate constructor parameters:
  Output (--output), GitDir (--git-dir), ConfigurationFile (pre-discovered
  docset), Git (pre-computed override for Assembler/Codex), ExtraRoots
  (RUNNER_TEMP etc.), MaxParents, Inner (mock seam), InnerWrite (separate
  write mock for navigation tests).

Worktree resolution fix: ResolveGitDirectories now uses the unscoped inner
filesystem for commondir traversal, because the main .git directory lies
outside the gitScope root by design. Explicit --git-dir carries its path
into gitDirectories directly so the factory scope covers it.

## Tests

DocumentationPathsResolverTests — 21 new tests covering:
  - Normal repo: --path /repo and --path /repo/docs resolve same checkout
  - Git worktree: checkout = worktree root, gitDirectories includes both
    the .git pointer path and the resolved commondir target
  - Explicit --git-dir: checkout = gitDir.Parent, git info from override
  - Mock FS without .git: graceful fallback, checkout = source
  - Output defaults to checkout/.artifacts (not invocation), same for
    both --path /repo and --path /repo/docs (regression guard)

Nullean.ScopedFileSystem bumped to 0.4.1-canary.0.2 (local package) for
the TryValidateSymlinkAccess early-exit fix needed when docRoot == directory.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PC5GiyZKhreYSqf2grz7pi
… source

0.4.2 ships the TryValidateSymlinkAccess early-exit fix from
nullean/scoped-filesystem#12 as a proper release. The canary pin and
nuget.config local-dev source are no longer needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PC5GiyZKhreYSqf2grz7pi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants