Aplicación Django reutilizable con la que feincms3 estructura y sirve contenido editorial guardado en archivos planos (Markdown + Front Matter). Un repositorio Git externo es la autoridad persistente y el sistema de versionado.
Remote Git Repository
↓ clone/fetch/pull/push
GitRepository repository/
↓
Local Working Tree FILECONTENT["ROOT"]
↓
ContentStore storage/ (lectura/escritura segura y atómica)
├─ Document → FileContent → feincms3 content/, feincms/
└─ SyncEngine → ContentIndex → feincms3 sync/, models.py
| Capa | Rol |
|---|---|
| Repositorio Git remoto | Autoridad persistente y versionada |
| Working tree local | Copia operacional |
ContentStore |
Única pieza que lee y escribe archivos |
ContentIndex (BD) |
Proyección reconstruible; se puede borrar y regenerar |
| feincms3 | Árbol de páginas, routing, regiones, templates y composición |
- Git no participa en el hot path HTTP: el render sólo lee el filesystem
(con caché en proceso invalidada por
mtime/tamaño). - feincms3 no ejecuta Git; el plugin
FileContentsólo guardacontent_path. - Los conflictos nunca se resuelven en silencio: se lanzan como excepciones tipadas que nombran los archivos afectados.
INSTALLED_APPS = [
...,
"feincms3",
"content_editor",
"feincms3_filecontent",
]
FILECONTENT = {
"ROOT": BASE_DIR / "content", # working tree local
"REMOTE_URL": os.environ.get(
"FILECONTENT_REMOTE_URL"
), # SSH o HTTPS sin credenciales
"BRANCH": "main",
}./manage.py migrate
./manage.py filecontent_sync # clona si falta, fast-forward, indexaIntegración en el modelo de páginas del proyecto:
# pages/models.py
from feincms3_filecontent.feincms.models import FileContent as AbstractFileContent
class FileContent(AbstractFileContent, PagePlugin):
pass
# pages/renderer.py
from feincms3_filecontent.feincms.renderer import render_filecontent
renderer.register(models.FileContent, render_filecontent)
# pages/admin.py
from feincms3_filecontent.feincms.plugins import FileContentInline
inlines = [..., FileContentInline.create(model=models.FileContent)]El HTML se renderiza con feincms3_filecontent/plugins/filecontent.html
(document, html, plugin en el contexto); sobrescríbelo en el proyecto
para mostrar título, fecha, etc.
---
title: Sobre nosotros
slug: sobre-nosotros # opcional; por defecto el nombre del archivo
locale: es # opcional; también "es/about.md" o "about.es.md"
modified_at: 2026-09-30 # opcional; si no, mtime del archivo
---
# Sobre nosotros
...Document normaliza: path, content_type, locale, slug, title, metadata, body, content_hash, modified_at. Otros formatos se añaden registrando un
parser (subclase de DocumentParser) por extensión en FILECONTENT["PARSERS"].
La validación (DocumentValidator) comprueba ruta segura, tipo soportado,
tamaño, UTF-8, YAML válido, campos obligatorios (REQUIRED_FIELDS), formato
del slug, locale conocido (settings.LANGUAGES) y que el parser pueda
renderizar. Devuelve todos los errores a la vez.
from feincms3_filecontent.workflow.commits import ContentWriter, author_for
result = ContentWriter.default().save(
"about.md",
text,
message="Actualiza Sobre nosotros",
author=author_for(request.user),
expected_hash=hash_cargado_por_el_editor, # detecta edición concurrente
)
result.sha, result.pushed, result.push_error, result.syncSecuencia: validar → lock → comprobar estado → escritura atómica → git add
→ commit (sólo esas rutas) → sync incremental del índice → push.
- Cualquier fallo antes del commit restaura el archivo y el índice de Git.
- Después del commit el cambio es durable; un push rechazado se devuelve en
result.push_errory el repo queda enAHEAD/DIVERGED, sin merges automáticos. ContentWriter.synchronize()reconcilia explícitamente: fast-forward siBEHIND, push siAHEAD, y siDIVERGEDhace rebase sólo cuando ningún archivo fue tocado en ambos lados (CONFLICT_POLICY="strict", por defecto). Si no, lanzaDivergedError(paths)sin tocar nada.
También: delete(), move().
from feincms3_filecontent.workflow.proposals import propose_change, merge_proposal
proposal = propose_change("about.md", text, message="Nueva versión de About")
proposal.branch # "content/update-about"
proposal.pull_request # si hay GitProvider configuradoLa rama se prepara en un git worktree temporal (WORKTREES_DIR), así el
working tree servido nunca cambia de rama. Convenciones de nombres:
content_branch("update", "about"), translation_branch("fr", "article", 123).
from feincms3_filecontent.workflow import rollback
rollback.history("about.md") # [HistoryEntry(sha, author, date, subject…)]
rollback.version_diff("about.md", sha) # patch de ese commit
rollback.restore("about.md", sha) # NUEVO commit con el contenido antiguoNunca se hace reset ni push --force.
RepositoryState guarda branch, last_indexed_sha e indexed_at. Entre
ese SHA y HEAD, Git entrega added/modified/deleted/renamed y sólo esos
documentos se reprocesan. Hay rebuild completo cuando no hay estado previo, el
SHA ya no existe (force-push, otro clon) o se pide explícitamente. Sin
repositorio Git (directorio plano, despliegues read-only) cada sync es un
rebuild. Los documentos inválidos se indexan con status="invalid" y su error.
| Comando | Qué hace |
|---|---|
filecontent_sync [--no-fetch] [--no-push] [--full] |
Clona si falta, fetch + fast-forward/rebase seguro, push pendiente, sync incremental |
filecontent_rebuild |
Borra y reconstruye ContentIndex completo |
filecontent_repository_status [--json] [--fetch] |
Estado (CLEAN/DIRTY/AHEAD/BEHIND/DIVERGED/CONFLICTED), SHA, índice |
filecontent_check [--fix] [--abort-operation] |
Configuración, temporales huérfanos, merges a medias, valida todos los documentos |
filecontent_history PATH [--diff SHA] |
Historial de un documento |
filecontent_rollback PATH SHA [--author "N <e>"] |
Restaura una versión con un commit nuevo |
Códigos de salida: 2 error, 3 conflicto.
Documents (ContentIndex) es de sólo lectura y muestra el estado del
repositorio. Desde ahí: editor (validar y commitear o proponer en rama),
historial con diff y Restaurar esta versión, botones Sync y Rebuild.
Permisos: feincms3_filecontent.edit_document y
feincms3_filecontent.manage_repository.
FILECONTENT["PROVIDER"] = {
"CLASS": "feincms3_filecontent.providers.github.GitHubProvider",
"OPTIONS": {"repository": "owner/name", "token_env": "GITHUB_TOKEN"},
}GitHubProvider, GitLabProvider (project=), BitbucketProvider:
crear/buscar/consultar/mergear PRs y estado de CI. El núcleo sólo necesita Git.
- Protección contra path traversal: rutas absolutas,
.., backslashes, NUL, symlinks que salen del root,.gity nombres ocultos se rechazan. - Credenciales fuera de documentos y commits: Git usa SSH/credential helper,
los tokens de proveedores se leen de variables de entorno, las URLs con
credenciales se redactan en errores y generan el warning
feincms3_filecontent.W001. - Git se ejecuta sin prompts (
GIT_TERMINAL_PROMPT=0,BatchMode=yes) y con timeout. - El HTML de Markdown se considera contenido editorial de confianza; para
sanearlo, configura
SANITIZER(p. ej. un wrapper denh3.clean).
Todas las mutaciones (working tree, índice Git, ContentIndex) se serializan
con un flock en el directorio Git común (filecontent.lock), re-entrante
por hilo y compartido entre procesos del mismo host. Las lecturas HTTP no
toman el lock: la sustitución atómica de archivos les basta. Con varios hosts,
cada uno tiene su working tree y se coordinan a través del remoto (push
rechazado → synchronize()).
| Clave | Por defecto |
|---|---|
ROOT |
— (obligatoria) |
REMOTE_URL, REMOTE_NAME, BRANCH |
None, "origin", "main" |
READ_ONLY |
False |
AUTO_PUSH |
True |
COMMIT_AUTHOR_NAME/EMAIL |
identidad del committer y autor por defecto |
WORKTREES_DIR |
<ROOT>.worktrees (debe estar fuera de ROOT) |
PARSERS |
.md, .markdown → MarkdownParser |
MARKDOWN_EXTENSIONS(_CONFIGS) |
extra, sane_lists, toc |
SANITIZER |
None |
REQUIRED_FIELDS |
["title"] |
MAX_DOCUMENT_SIZE |
1 MiB |
EXCLUDE |
.git, .github, .gitlab, node_modules |
CONFLICT_POLICY |
"strict" (o "git") |
GIT_BINARY, GIT_TIMEOUT, LOCK_TIMEOUT |
"git", 120 s, 30 s |
PROVIDER |
None |
SHOW_ERRORS |
settings.DEBUG |
Se ejecutan contra repositorios Git locales (un repo bare hace de remoto), sin red ni proveedor:
cd packages/feincms3-filecontent
pip install -e '.[tests]'
pytestrestore()sobre un commit anterior a un rename falla conDocumentNotFound(la ruta histórica era otra).- El lock usa
fcntl: POSIX únicamente. ContentIndex.git_shaes el commit en el que se indexó, no el último commit que tocó el archivo (usahistory()para eso).