Managing Variants

A variant in docsfy is a generated documentation build for a specific combination of:

  • project name
  • AI provider
  • AI model
  • owner (user scope)

Variants are first-class objects across API, storage, UI, and docs serving routes.

Variant identity and storage model

The projects table keys variants by (name, ai_provider, ai_model, owner):

await db.execute("""
    CREATE TABLE IF NOT EXISTS projects (
        name TEXT NOT NULL,
        ai_provider TEXT NOT NULL DEFAULT '',
        ai_model TEXT NOT NULL DEFAULT '',
        owner TEXT NOT NULL DEFAULT '',
        repo_url TEXT NOT NULL,
        status TEXT NOT NULL DEFAULT 'generating',
        current_stage TEXT,
        last_commit_sha TEXT,
        last_generated TEXT,
        page_count INTEGER DEFAULT 0,
        error_message TEXT,
        plan_json TEXT,
        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
        updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
        PRIMARY KEY (name, ai_provider, ai_model, owner)
    )
""")

Variant artifacts are also stored in owner-scoped filesystem paths:

def get_project_dir(
    name: str, ai_provider: str = "", ai_model: str = "", owner: str = ""
) -> Path:
    if not ai_provider or not ai_model:
        msg = "ai_provider and ai_model are required for project directory paths"
        raise ValueError(msg)
    # Sanitize path segments to prevent traversal
    for segment_name, segment in [("ai_provider", ai_provider), ("ai_model", ai_model)]:
        if (
            "/" in segment
            or "\\" in segment
            or ".." in segment
            or segment.startswith(".")
        ):
            msg = f"Invalid {segment_name}: '{segment}'"
            raise ValueError(msg)
    safe_owner = _validate_owner(owner)
    return PROJECTS_DIR / safe_owner / _validate_name(name) / ai_provider / ai_model

Note: Owner scoping means two users can have the same name/provider/model variant without clobbering each other.

Configure default provider/model

docsfy defaults come from environment-backed settings:

# .env.example
AI_PROVIDER=claude
# [1m] = 1 million token context window, this is a valid model identifier
AI_MODEL=claude-opus-4-6[1m]
AI_CLI_TIMEOUT=60
class Settings(BaseSettings):
    ...
    ai_provider: str = "claude"
    ai_model: str = "claude-opus-4-6[1m]"
    ai_cli_timeout: int = Field(default=60, gt=0)
    data_dir: str = "/data"

At runtime, request values override defaults:

settings = get_settings()
ai_provider = gen_request.ai_provider or settings.ai_provider
ai_model = gen_request.ai_model or settings.ai_model
project_name = gen_request.project_name
owner = request.state.username

If you run with Docker Compose, generated variants persist under ./data:

services:
  docsfy:
    ...
    env_file: .env
    volumes:
      - ./data:/data

Create a variant

Creation and regeneration both use POST /api/generate.

Request schema:

class GenerateRequest(BaseModel):
    repo_url: str | None = Field(
        default=None, description="Git repository URL (HTTPS or SSH)"
    )
    repo_path: str | None = Field(default=None, description="Local git repository path")
    ai_provider: Literal["claude", "gemini", "cursor"] | None = None
    ai_model: str | None = None
    ai_cli_timeout: int | None = Field(default=None, gt=0)
    force: bool = Field(
        default=False, description="Force full regeneration, ignoring cache"
    )

    @model_validator(mode="after")
    def validate_source(self) -> GenerateRequest:
        if not self.repo_url and not self.repo_path:
            msg = "Either 'repo_url' or 'repo_path' must be provided"
            raise ValueError(msg)
        if self.repo_url and self.repo_path:
            msg = "Provide either 'repo_url' or 'repo_path', not both"
            raise ValueError(msg)
        return self

Example from tests:

response = await client.post(
    "/api/generate",
    json={"repo_url": "https://github.com/org/repo.git", "force": True},
)
assert response.status_code == 202

When generation starts, docsfy stores the variant row and starts a background task:

gen_key = f"{owner}/{project_name}/{ai_provider}/{ai_model}"
async with _gen_lock:
    if gen_key in _generating:
        raise HTTPException(
            status_code=409,
            detail=f"Variant '{project_name}/{ai_provider}/{ai_model}' is already being generated",
        )

    await save_project(
        name=project_name,
        repo_url=gen_request.repo_url or gen_request.repo_path or "",
        status="generating",
        ai_provider=ai_provider,
        ai_model=ai_model,
        owner=owner,
    )
    ...

Warning: repo_path generation is admin-only, and viewers cannot create variants.

  • Local repo path access requires admin privileges (403)
  • Write access required. for viewer role (403)

Regenerate a variant

UI flow (dashboard + status page)

The dashboard renders per-variant controls with a Force checkbox:

<label class="form-checkbox-sm"><input type="checkbox" data-regen-force="{{ repo_name }}"> Force</label>
<button class="btn btn-primary btn-sm" data-regenerate-variant="{{ repo_name }}" data-repo-url="{{ variant.repo_url }}">Regenerate</button>

Regenerate action sends a new POST /api/generate request:

var body = {
    repo_url: repoUrl,
    ai_provider: provider,
    force: force
};
if (model) body.ai_model = model;

fetch('/api/generate', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    credentials: 'same-origin',
    redirect: 'manual',
    body: JSON.stringify(body)
})

Non-force regeneration (force=false)

docsfy tries to avoid unnecessary full rebuilds:

  • if commit SHA is unchanged, it marks variant ready with stage up_to_date
  • if commits differ, it can run incremental planning and selectively invalidate cached pages
  • page generation uses cache when appropriate
if existing and existing.get("last_generated"):
    old_sha = (
        str(existing["last_commit_sha"])
        if existing.get("last_commit_sha")
        else None
    )
    if old_sha == commit_sha:
        ...
        await update_project_status(
            project_name,
            ai_provider,
            ai_model,
            status="ready",
            owner=owner,
            current_stage="up_to_date",
        )
        return
...
if old_sha and old_sha != commit_sha and not force and existing:
    changed_files = get_changed_files(repo_dir, old_sha, commit_sha)
    ...
pages = await generate_all_pages(
    repo_path=repo_dir,
    plan=plan,
    cache_dir=cache_dir,
    ai_provider=ai_provider,
    ai_model=ai_model,
    ai_cli_timeout=ai_cli_timeout,
    use_cache=use_cache if use_cache else not force,
    project_name=project_name,
    owner=owner,
)

Force regeneration (force=true)

Force mode clears the variant page cache and resets page count during regeneration:

if force:
    cache_dir = get_project_cache_dir(project_name, ai_provider, ai_model, owner)
    if cache_dir.exists():
        shutil.rmtree(cache_dir)
        logger.info(f"[{project_name}] Cleared cache (force=True)")
    # Reset page count so API shows 0 during regeneration
    await update_project_status(
        project_name,
        ai_provider,
        ai_model,
        status="generating",
        owner=owner,
        page_count=0,
    )

Tip: Use Force when you want a guaranteed clean rebuild (for example after major doc structure/model changes), not just incremental page updates.

Delete variants safely

Variant deletion endpoint:

  • DELETE /api/projects/{name}/{provider}/{model}

Safety behavior in backend:

  1. Requires write access
  2. Blocks deletion if the variant is currently generating (409)
  3. Resolves the target variant with ownership/access rules
  4. Deletes DB record
  5. Deletes variant directory from disk
for key in _generating:
    parts = key.split("/", 3)
    if (
        len(parts) == 4
        and parts[1] == name
        and parts[2] == provider
        and parts[3] == model
    ):
        raise HTTPException(
            status_code=409,
            detail=f"Cannot delete '{name}/{provider}/{model}' while generation is in progress. Abort first.",
        )

project = await _resolve_project(
    request, name, ai_provider=provider, ai_model=model
)

project_owner = str(project.get("owner", ""))
deleted = await delete_project(
    name, ai_provider=provider, ai_model=model, owner=project_owner
)
...
project_dir = get_project_dir(name, provider, model, project_owner)
if project_dir.exists():
    shutil.rmtree(project_dir)

The dashboard also forces an explicit confirmation:

var confirmed = await modalConfirm('Delete Variant', 'Are you sure you want to delete "' + variantPath + '"? This will remove the generated documentation for this variant and cannot be undone.', true);
if (!confirmed) return;
...
fetch('/api/projects/' + encodeURIComponent(name) + '/' + encodeURIComponent(provider) + '/' + encodeURIComponent(model), {
    method: 'DELETE',
    credentials: 'same-origin',
    redirect: 'manual'
})

If the deleted variant was the last one for that project/owner pair, access grants are cleaned up:

# Clean up project_access if no more variants remain for this name+owner
if cursor.rowcount > 0 and owner is not None:
    remaining = await db.execute(
        "SELECT COUNT(*) FROM projects WHERE name = ? AND owner = ?",
        (name, owner),
    )
    row = await remaining.fetchone()
    if row and row[0] == 0:
        await db.execute(
            "DELETE FROM project_access WHERE project_name = ? AND project_owner = ?",
            (name, owner),
        )

Warning: You cannot delete an actively generating variant. Abort it first via POST /api/projects/{name}/{provider}/{model}/abort, then delete.

Variant management endpoints (quick reference)

  • POST /api/generate: create or regenerate a variant (force optional)
  • GET /api/projects/{name}: list all variants for a project name
  • GET /api/projects/{name}/{provider}/{model}: get one variant
  • POST /api/projects/{name}/{provider}/{model}/abort: stop active generation for one variant
  • DELETE /api/projects/{name}/{provider}/{model}: safely delete one variant
  • GET /docs/{project}/{provider}/{model}/{path:path}: serve docs for one exact variant

Behavior verification in tests

Variant lifecycle behavior is covered in tests, including force creation, duplicate protection, role restrictions, and delete flow:

# tests/test_main.py
response = await client.post(
    "/api/generate",
    json={
        "repo_url": "https://github.com/org/repo.git",
        "ai_provider": "claude",
        "ai_model": "opus",
    },
)
assert response.status_code == 409
# tests/test_auth.py
response = await ac.delete("/api/projects/proj-del/claude/opus")
assert response.status_code == 403
# tests/test_integration.py
response = await client.delete("/api/projects/test-repo/claude/opus")
assert response.status_code == 200

Repository-level automated test command configuration:

# tox.toml
[env.unittests]
deps = ["uv"]
commands = [["uv", "run", "--extra", "dev", "pytest", "-n", "auto", "tests"]]