Skip to content

Repository files navigation

cadvcs — Control de versiones tipo Git para archivos CAD

Versionado para archivos CAD (DXF/DWG) con modelo Git completo: DAG de commits multi-archivo, branches, tags, merge a tres vías con resolución a nivel de entidad DXF, blame por entidad, y pessimistic locking para binarios no mergeables.

Arquitectura de producción

Arquitectura de producción

Detalle completo en ARCHITECTURE.md. Las specs de cada funcionalidad implementada y el backlog de mejoras están en docs/.

Arquitectura del código

cadvcs/
├── storage.py   Blob store content-addressed (SHA-256, layout objects/ab/cd...)
├── db.py        Esquema tipo Git: commits (DAG), commit_entries (tree plano),
│                branches/tags (refs), locks (TTL), entities (índice por blob)
├── semdiff.py   Extracción y diff semántico DXF: identidad por handle
├── merge.py     Merge a 3 vías por entidad: clasifica cambios vs merge-base,
│                auto-fusiona lo que no colisiona, reporta conflictos reales
├── repo.py      API: add/status/commit/log/branch/switch/tag/diff/merge/blame
├── cli.py       CLI con comandos tipo Git + push/pull/remote/clone
├── client.py    Cliente HTTP para sincronización con servidores remotos
├── remote.py    Configuración de remotes (almacenada en .cadvcs/config.json)
└── auth_store.py  Almacén de credenciales (tokens JWT por servidor)

Capacidades tipo Git

cadvcs init                          # crea rama main
cadvcs add plano.dxf                 # tracking
cadvcs status                        # A/M/D vs HEAD
cadvcs commit --user ero -m "..."    # changeset multi-archivo, nodo del DAG
cadvcs log                           # first-parent, con decoraciones (ramas, tags)
cadvcs branch variante-b             # refs baratas (puntero a commit)
cadvcs switch variante-b             # materializa el árbol destino en el workdir
cadvcs diff main variante-b          # tree-level + semántico por entidad en DXF
cadvcs merge variante-b --user ero   # 3 vías con merge-base (LCA del DAG)
cadvcs tag v1.0
cadvcs blame plano.dxf               # último commit que tocó cada entidad
cadvcs lock plano.dxf --user ero     # pesimista, para binarios no mergeables
cadvcs checkout plano.dxf --ref v1.0 --out plano_v1.dxf

El comando tiene un alias corto cad (cad commit ...). Tras cad login —que guarda tu token de sesión— --user es opcional: la identidad sale del token (o de CADVCS_USER). Ver la sección "Sesión y alias del CLI" más abajo.

Sincronización remota (push/pull/clone)

Flujo distribuido tipo Git para trabajar con un servidor cadvcs remoto:

# Clonar un repo existente del servidor
cadvcs clone http://servidor:8000 proyecto-1

# Trabajar localmente
cd proyecto-1
cadvcs add plano.dxf
cadvcs commit -m "Planta baja actualizada"

# Subir cambios al servidor
cadvcs push                            # push rama actual a origin

# Bajar cambios del servidor
cadvcs pull                            # pull rama actual desde origin

Gestión de remotes

Similar a git remote, cada repo local puede configurar múltiples servidores:

cadvcs remote add origin http://prod.example.com:8000              # nombre de repo = dir local
cadvcs remote add origin http://prod.example.com:8000 --repo-name nave  # nombre explícito
cadvcs remote add staging http://staging:8000
cadvcs remote list
cadvcs remote remove staging

La configuración se guarda en .cadvcs/config.json dentro del repo. Al usar clone, el remote origin se configura automáticamente.

Protocolo de sincronización

El push/pull transfiere solo lo necesario:

  1. Negociación de refs: el cliente obtiene los heads de las ramas remotas para saber qué commits faltan.
  2. Transferencia de blobs: antes de enviar commits, el cliente pregunta qué blobs tiene el servidor (sync/blobs/check) y solo sube los que faltan — deduplicación por SHA-256 igual que en el store local.
  3. Pack de commits: los commits se envían en orden topológico (padres primero) con sus entries; el servidor los aplica atómicamente y actualiza la ref de la rama.
  4. Optimistic lock: el push verifica que la rama remota no haya avanzado desde la última vez que se consultó. Si avanzó, se rechaza con 409 y hay que hacer pull primero.
cadvcs push origin main                # push explícito de una rama
cadvcs pull origin feature-x           # pull de otra rama

El token de autenticación se toma del almacén de credenciales (cadvcs login --server <url>). Si el repo remoto no existe aún, push lo crea automáticamente.

El merge a nivel de entidad

La pieza diferencial. Con base = merge-base(ours, theirs), cada handle DXF se clasifica en cada lado como unchanged/modified/added/deleted:

  • Cambios en entidades distintas se fusionan automáticamente (maria movió la columna en su rama, ero añadió una puerta en main → el merge produce un DXF con ambas cosas).
  • Colisiones reales se reportan como conflictos estructurados sin auto-resolver: modify/modify sobre el mismo handle, modify/delete, y add/add con el mismo handle (los handles DXF son por-archivo y dos ramas pueden asignar el mismo a entidades nuevas distintas).
  • Fast-forward y already-up-to-date se detectan igual que en Git.
merge variante-b → main: merged c4
  plano.dxf: auto-merge: ~1 +0 -0

merge propuesta-x → main:
  CONFLICTO plano.dxf: modify/modify CIRCLE handle=31
    ours:   center=(90.0, 40.0, 0.0)
    theirs: center=(10.0, 10.0, 0.0)

Limitación documentada: las entidades importadas desde theirs reciben handle nuevo en el doc fusionado (identidad histórica reiniciada). Los PDM comerciales lo resuelven con GUIDs propios por entidad.

Decisiones de diseño

  1. Tree plano por commit en vez de objetos tree jerárquicos: cada commit lista (repo_path, blob_sha). Con content-addressing, los archivos sin cambios reutilizan el mismo blob — snapshot completo barato, modelo mental simple.
  2. Índice semántico por blob, no por revisión: la tabla entities se indexa una vez por SHA único. diff/merge/blame entre versiones históricas nunca re-parsean DXF.
  3. Locking pesimista como complemento, no sustituto: el merge por entidad cubre DXF; para DWG/binarios divergentes el merge se rechaza y el lock es la protección.
  4. blame por fingerprint: recorre la cadena first-parent y atribuye cada entidad al commit donde su fingerprint cambió respecto al padre.

Uso por API

from cadvcs.repo import Repo, MergeConflictError

repo = Repo.init("/proyectos/nave")
repo.add(path)
repo.commit("ero", "Planta inicial")
repo.branch_create("variante-b"); repo.switch("variante-b")
# ... editar y commitear ...
repo.switch("main")
try:
    info = repo.merge("variante-b", author="ero")
except MergeConflictError as e:
    for path, conflicts in e.details.items(): ...

Demo completa (python demo.py): dos usuarios, dos ramas, merge automático verificado con asserts, conflicto modify/modify, restauración del workdir, log y blame.

Producción

Ver ARCHITECTURE.md: API REST + PostgreSQL + S3/OCI con presigned URLs, workers Kafka (indexado, conversión DWG→DXF con ODA, render para diff visual), transactional outbox, locking con heartbeat desde el plugin AutoCAD, grafo de XREFs, multi-tenancy con RLS y gc de blobs estilo git gc.

API REST (FastAPI)

La API expone el mismo repo.py por HTTP, con working copy server-side por repositorio bajo CADVCS_DATA. Endpoints síncronos en threadpool + un lock por repo serializando mutaciones (SQLite WAL con busy_timeout); en producción ese lock se sustituye por transacciones PostgreSQL.

pip install -r requirements.txt
uvicorn cadvcs.api.main:app --reload     # docs en http://localhost:8000/docs
POST   /repos                          crear repo            → 201
GET    /repos | /repos/{name}          listar / info
PUT    /repos/{n}/files/{path}         subir a working copy (octet-stream)
GET    /repos/{n}/files/{path}?ref=    descargar blob de cualquier ref
GET    /repos/{n}/status               A/M/D vs HEAD
POST   /repos/{n}/commits              {author, message}     → 201 | 422 | 423
GET    /repos/{n}/commits?ref=&limit=  log decorado
POST   /repos/{n}/branches | GET       crear / listar ramas
POST   /repos/{n}/switch               {branch, force}
POST   /repos/{n}/tags | GET           crear / listar tags
GET    /repos/{n}/diff?ref_a=&ref_b=   tree-level + semántico por entidad
POST   /repos/{n}/merge                {branch, author}      → 200 | 409
GET    /repos/{n}/blame/{path}?ref=    atribución por entidad
POST   /repos/{n}/locks | GET          adquirir / listar     → 201 | 423
DELETE /repos/{n}/locks/{path}?owner=  liberar               → 204

# Sincronización (push/pull)
GET    /repos/{n}/sync/refs            refs de ramas y tags
POST   /repos/{n}/sync/negotiate       intercambio de commit IDs
POST   /repos/{n}/sync/push            push pack de commits  → 200 | 409 | 422
GET    /repos/{n}/sync/pull?branch=    pull commits desde un punto
PUT    /repos/{n}/sync/blobs/{sha}     subir blob por SHA
GET    /repos/{n}/sync/blobs/{sha}     descargar blob por SHA
POST   /repos/{n}/sync/blobs/check     verificar qué blobs tiene el server

Semántica HTTP: 409 para conflictos de merge con payload estructurado (handle, razón, ours/theirs), 423 Locked para locks ajenos, 422 para errores de dominio (commit vacío, ref inexistente). El conflicto 409 devuelve exactamente lo que una UI de resolución necesita:

{
  "detail": "Merge con conflictos en 1 archivo(s)",
  "conflicts": {
    "plano.dxf": [{
      "handle": "31", "dxftype": "CIRCLE", "reason": "modify/modify",
      "ours":   {"attrs": {"center": [90.0, 40.0, 0.0]}},
      "theirs": {"attrs": {"center": [10.0, 10.0, 0.0]}}
    }]
  }
}

Seguridad MVP: validación de slug de repo, guard anti path-traversal en rutas de archivo (incluida la forma URL-encoded), y la dir .cadvcs inaccesible vía API. Test end-to-end en tests/test_api.py (24 checks: flujo completo de ramas y merge vía HTTP, 409 estructurado, 423 de locks, descarga histórica).

Resolución interactiva de conflictos

El ciclo del 409 se cierra de forma stateless: POST /repos/{n}/merge/resolve recalcula el merge a tres vías desde las refs aplicando elecciones por handle, así que no hay sesión de merge en servidor ni estado intermedio que limpiar.

POST /repos/nave/merge/resolve
{
  "branch": "propuesta",
  "resolutions": {
    "plano.dxf": { "31": "theirs", "2F": "ours" },
    "render.png": { "__file__": "theirs" }
  }
}

Semántica por tipo de conflicto al elegir theirs: en modify/modify se aplican los atributos de theirs; en modify/delete se re-importa la entidad borrada (o se borra la modificada, según la dirección); en add/add se sustituye el contenido (mismo dxftype → atributos; distinto → reemplazo de entidad). La clave __file__ resuelve binarios divergentes completos. La resolución parcial devuelve 409 solo con los conflictos restantes, lo que permite a una UI resolver de forma incremental. En CLI: cadvcs merge rama --user ero --resolve plano.dxf:31=theirs (repetible).

Auth OIDC

Toda la API requiere JWT Bearer RS256 validado contra el JWKS del identity provider (descubierto vía /.well-known/openid-configuration, con override por CADVCS_OIDC_JWKS_URL o fichero local para tests/air-gapped). El author de commits y merges y el owner de locks salen de preferred_username del token — el cliente ya no puede suplantar identidad por body.

export CADVCS_OIDC_ISSUER=https://idp.example.com/realms/cad
export CADVCS_OIDC_AUDIENCE=cadvcs
uvicorn cadvcs.api.main:app

Sin issuer configurado la API arranca en modo dev sin auth (principal dev) con warning explícito. El test suite genera su propio par RSA y JWKS, firma tokens reales para dos usuarios y cubre los 401 (sin token, firma ajena, expirado, audience incorrecta) además del flujo completo de resolución.

Blob store en S3/OCI

Los blobs pueden vivir en object storage S3-compatible definiendo CADVCS_BLOB_URL=s3://bucket/prefijo (credenciales por la cadena estándar de AWS; CADVCS_S3_ENDPOINT para MinIO, OCI Object Storage en modo S3-compat o LocalStack). La interfaz es idéntica al backend local —misma clave SHA-256 con sharding objects/ab/cdef...— así que repo.py no cambia. El bucket es global: la deduplicación funciona también entre repositorios (dos repos con el mismo plano comparten blob). La descarga por la API pasa a streaming desde el store, sin tocar disco intermedio. Suite específica en tests/test_s3.py (moto), también en CI.

Producción

La metadata puede vivir en PostgreSQL definiendo CADVCS_DB_URL (formato postgresql://user:pass@host/db): un schema por repositorio mantiene el SQL idéntico entre backends y aísla repos entre sí. Sin la variable, cada repo usa su SQLite local como siempre. El wrapper de conexión normaliza paramstyle, INSERT OR IGNORE/ON CONFLICT, transacciones e ids autogenerados; los timestamps son TEXT UTC en ambos para que la expiración de locks compare igual. Ambas suites corren contra los dos backends en CI.

Despliegue con Docker:

cp .env.example .env   # definir POSTGRES_PASSWORD y el issuer OIDC
docker compose up -d   # PostgreSQL 16 + API con healthchecks
curl localhost:8000/health

GET /health (sin token: es la sonda de Kubernetes/LB) verifica conectividad de metadata y escritura en storage, y reporta el backend activo. La imagen corre como usuario no privilegiado con HEALTHCHECK integrado.

Tests

Las suites de script (demo.py, tests/test_api.py, tests/test_s3.py, tests/test_async.py, tests/test_presigned.py, tests/test_ui.py, tests/test_infra.py, tests/test_cli_auth.py) cubren integración end-to-end y corren en CI sobre ambos backends (SQLite y PostgreSQL). Además, tests/ contiene property-based tests del motor de merge con hypothesis: generan cientos de configuraciones aleatorias de cambios base/ours/theirs y verifican invariantes (sin pérdida espuria, cambios de un lado preservados, convergencia, detección de conflictos, totalidad de la resolución, determinismo). pytest es el runner unificado (python -m pytest); el adaptador tests/test_script_suites.py ejecuta las suites de script para una migración incremental.

Indexado asíncrono

El parseo de entidades DXF sale del path de commit mediante transactional outbox: el commit escribe un evento pending en index_outbox en su misma transacción, y python -m cadvcs.worker lo drena en segundo plano (multi-repo sobre CADVCS_DATA, --once para CI o polling con backoff para despliegue). La correctitud no depende del worker: si un diff/merge/blame toca un blob aún no indexado, _entities_for_blob lo indexa bajo demanda y cierra el evento, así que el worker solo reduce latencia. docker-compose.yml incluye el servicio worker. Suite en tests/test_async.py, en CI sobre ambos backends.

Subida y descarga directas a object storage (presigned)

Con backend S3, el servidor puede salir del path de bytes para archivos grandes (modelo Git-LFS). Flujo de subida dedup-aware:

  1. El cliente calcula el SHA-256 en local y pide POST /repos/{n}/blobs/{sha}/upload-url. Si el blob ya existe → {exists:true} sin URL (cero transferencia). Si no → {exists:false, upload_url} (PUT presigned).
  2. El cliente hace PUT directo a object storage con esa URL — los bytes nunca pasan por la API.
  3. PUT /repos/{n}/staged/{path} con {sha256,size} registra el blob por referencia; el commit siguiente lo incluye sin leer bytes.

Descarga: GET /files/{path}?presigned=true devuelve un 307 a una URL GET presigned (la descarga sale directa de S3). La subida/descarga por la API (PUT/GET /files) siguen disponibles para el backend local y archivos pequeños. Suite en tests/test_presigned.py (moto server por HTTP real), en CI sobre ambos backends.

Web UI

Interfaz visual servida por la propia API en /ui/ (redirect desde /). Cubre todo el sistema —historial, comparación con diff visual, fusión, autoría y bloqueos— y su pieza central es el resolutor de conflictos: ante un 409 de fusión, muestra cada entidad en discordia con sus dos lados y permite elegir ours/theirs por entidad, enviando las elecciones a merge/resolve. SPA en vanilla JS sin build; el shell es público y las llamadas de datos llevan el token. El lenguaje visual es la mesa de dibujo: papel blanco, azul de cianotipo y rojo de revisión semántico, con monoespaciada para todo dato (SHAs, handles, coordenadas). Verificado con un test de contrato UI↔API que comprueba que cada ruta invocada por el front existe en la API.

Infraestructura: conversión DWG, Kafka, Redis

El sistema soporta tres servicios de producción, todos opcionales con degradación a no-op:

  • Conversión DWG→DXF (CADVCS_DWG_CONVERTER): un .dwg commiteado encola un evento convert; el worker genera su DXF espejo (registrado en dwg_mirrors), sobre el que operan diff, blame y render. Backend pluggable: aspose (Aspose.CAD, requiere licencia), oda (ODA File Converter vía CADVCS_ODA_BIN) o none (DWG como binario opaco). Spec 16.
  • Eventos sobre Kafka (CADVCS_KAFKA_BROKERS): el outbox (fuente de verdad transaccional) se publica en Kafka por un relay y lo consumen workers en un consumer group que escala horizontalmente. python -m cadvcs.worker --mode relay|consume. Sin Kafka, el worker de polling sigue funcionando. Spec 17.
  • Cache de renders en Redis (CADVCS_REDIS_URL): los SVG de render y diff visual son inmutables (dependen solo de los SHAs de contenido), así que se cachean por clave de SHAs sin invalidación. Sin Redis, se recomputa. Spec 18.

docker-compose.yml levanta el stack completo (PostgreSQL, Redis, Kafka en KRaft, API, relay, consumer). /health reporta el estado de cada pieza. Suite en tests/test_infra.py (Redis real, bus Kafka en memoria, stub converter), en CI sobre ambos backends.

Sesión y alias del CLI

El comando es cadvcs (instalado por pip install -e .), con alias corto cad. Para no pegar el token a mano en cada uso:

cad login --token <JWT>           # pegar una vez; se guarda en ~/.config/cadvcs
cad login --user ana              # o password grant contra el IdP OIDC (pide contraseña)
cad whoami                        # muestra usuario, roles y caducidad del token
cad token                         # imprime el JWT (p.ej. curl -H "Authorization: Bearer $(cad token)")
cad logout                        # borra la sesión

El token se guarda por servidor (--server, o CADVCS_SERVER) con permisos 0600, así que puedes tener sesiones contra varios despliegues. Además, tras login los comandos que registran autoría (commit, merge, lock...) ya no necesitan --user: lo toman del token (o de CADVCS_USER). Solo lo pides si quieres commitear con otra identidad.

Despliegue en la nube

Una sola imagen Docker sirve los tres roles según el comando: API (uvicorn), relay (worker --mode relay) y consumer (worker --mode consume). Como el estado está externalizado —PostgreSQL para metadata, object storage para blobs, Redis y Kafka opcionales—, API y workers son stateless y escalan horizontalmente.

deploy/k8s/ contiene manifiestos listos para un clúster gestionado (EKS/GKE/AKS): API con réplicas + Service + Ingress TLS + HPA, relay (1 réplica) y consumer (HPA, reparto por consumer group de Kafka), con /health como probe. Los servicios pesados se delegan a gestionados (RDS/Cloud SQL, S3/GCS, ElastiCache, MSK); sin Kafka, la topología se reduce a un único worker --mode poll. La migración de esquema es automática al arrancar. Detalle en la spec 19 y en deploy/k8s/README.md. Para desarrollo, docker-compose.yml levanta el stack completo en local.

About

Git-like version control for CAD files (DXF/DWG) with entity-level three-way merge, per-entity blame, and a FastAPI REST layer

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages