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/modelvariant 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_pathgeneration 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
readywith stageup_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:
- Requires write access
- Blocks deletion if the variant is currently generating (
409) - Resolves the target variant with ownership/access rules
- Deletes DB record
- 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 (forceoptional)GET /api/projects/{name}: list all variants for a project nameGET /api/projects/{name}/{provider}/{model}: get one variantPOST /api/projects/{name}/{provider}/{model}/abort: stop active generation for one variantDELETE /api/projects/{name}/{provider}/{model}: safely delete one variantGET /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"]]