Backend del panel estilo "Source Control" de VS Code: descubre los repositorios git que viven bajo las carpetas enlazadas de los proyectos del usuario (incluidos subrepos anidados) y expone su estado, historial, ramas, detalle de commit/diff, y las operaciones mutantes (checkout, crear rama, fetch/pull/push/sync, stage/unstage/discard, commit).
Implementación: src/git_panel.py (descubrimiento y ejecución de git) y
routes/git_routes.py (las rutas). Sin dependencias externas — solo git
en PATH.
repo_id = sha1(normcase(realpath(repo_root)))[:12], estable entre
llamadas. Es también el único mecanismo de resolución: un repo_id se
calcula SIEMPRE recorriendo las carpetas enlazadas del owner que hace la
llamada (GET /api/git/repos), así que un id que no salga de ese recorrido
— de otro owner, o inventado a partir de una ruta arbitraria — nunca
resuelve a nada, y cualquier ruta que lo reciba responde 404 sin necesidad de
una comprobación de propiedad aparte.
El descubrimiento recorre hasta profundidad 3 bajo cada carpeta enlazada
(workspace del proyecto, más cualquier enlace folder habilitado),
saltando node_modules, .venv, venv, __pycache__, dist, build y el
propio .git; un directorio es un repo si contiene .git (directorio o
fichero — worktree/submódulo). Los repos anidados se listan también, con
parent_repo_id apuntando al repo contenedor más cercano ya descubierto.
Tope global: 60 repos.
Cuando el mismo repo (misma ruta real, realpath+normcase) aparece bajo
más de un proyecto — dos proyectos enlazando la misma carpeta — sale UNA sola
vez: project_id/project_name se quedan con el PRIMER proyecto que lo
enlazó (compatibilidad con clientes que solo leen esos dos campos), y
"projects": [{"id","name"}, ...] lleva uno por cada proyecto que lo enlaza
(src/git_panel.py::_dedupe_repos).
GET /api/git/repos hace dos cosas para que 24 repos en Windows tarden
< 2 s en vez de los 9,5 s que tardaba:
- El RECORRIDO del filesystem se cachea 30 s por owner
(
git_panel._DISCOVERY_CACHE,_DISCOVERY_TTL) — lo caro en Windows es elos.listdirrecursivo bajo cada carpeta enlazada, no el estado git de cada repo. La caché queda invalidada de dos formas: automáticamente, si la lista de proyectos del owner cambia (_projects_fingerprintcomparaid/workspace/updated_at/context_revisionde cada proyecto en cada llamada — barato, es una lectura de JSON, no un recorrido de disco) — así que crear/renombrar/(des)enlazar un proyecto invalida la caché sin que nadie tenga que acordarse de llamarlo explícitamente; y explícitamente,POST /api/git/reposllamainvalidate_discovery_cache(owner)justo tras crear/clonar, para que el repo nuevo aparezca en el siguiente listado sin esperar los 30 s (una creación de repo no cambia ningún proyecto, así que el fingerprint no lo detectaría solo). Un listado conproject_idsiempre recorre fresco (un único proyecto es barato) — la caché es solo para el listado de todos los proyectos del owner. - El resumen de cada repo se calcula EN PARALELO
(
git_panel.repo_summaries,ThreadPoolExecutor(max_workers=8)) — cada resumen es su propio procesogit(spawn), así que solapar 8 a la vez en vez de esperarlos uno a uno es la otra mitad de la mejora. - Cada resumen hace el mínimo de llamadas
git: una solagit status --porcelain=v2 -z --branch --untracked-files=allda rama, detached, sha de HEAD, upstream, ahead/behind y los tres contadores dirty — todo lo que antes salía desymbolic-ref+rev-parse --abbrev-ref @{u}rev-list --count+status+rev-parse HEADcombinados (con-z, las cabeceras# branch.*de v2 también terminan en NUL, así que un únicosplit("\x00")las separa de las entradas de fichero:git_panel.parse_status_v2_branch). Eluser.name/user.emailefectivo sale de un únicogit config --get-regexp '^user\.(name|email)$'(antes, dosgit configsueltos). Con eso, un resumen completo hace 3 llamadasgit(status+branch, user,remote -v) en vez de las 8-9 de antes (las 8 del propio resumen, más unaremote -vrepetida queroutes/git_routes.pydisparaba aparte para calcularidentity— ahora reutiliza losremotesquerepo_summaryya trajo,git_identities.active_identity_for_repo(..., remotes=row["remotes"])).
GET /api/git/repos?light=1 (o el project_id-scoped) pasa light=True a
repo_summary: 1 sola llamada git por repo — omite remotes, user,
identity y policy, deja solo branch/detached/head_sha/upstream/
ahead/behind/dirty (más id/path/name/project_id/project_name/
projects/root_folder/parent_repo_id, que no cuestan git). Pensado
para el polling del panel: la UI hace ?light=1 cada pocos segundos y
conserva identity/policy del último listado completo.
- Lectura (
GET):require_user. - Mutación (
checkout,branches,fetch,pull,push,sync,stage,unstage,discard,commit):require_human— el token interno del propio modelo no abre estas rutas, igual queroutes/approvals_routes.py. - Todo está acotado a las carpetas enlazadas de los proyectos del owner que hace la llamada; un repo fuera de ese alcance responde 404.
gitausente en el host:503 {"detail": "...", "error_class": "dependency.missing"}.- Fallo de
gitsin una clasificación más específica:400 {"detail": <stderr recortado a 2000 chars>, "error_class": "git.command_failed", "stderr": <mismo texto>}. - Casos específicos, todos
409:git.dirty— uncheckout(o la creación de rama concheckout: true) pisaría cambios locales; el cuerpo incluye"dirty": [rutas].git.diverged—pull --ff-onlyno puede avanzar porque la rama local y su upstream divergieron; el cuerpo incluye"ahead"/"behind".git.rejected— el remoto rechazó elpush(non-fast-forward, hook de secret scanning, ...); el cuerpo incluye"stderr".git.no_identity— el repo no tieneuser.name/user.emailconfigurados para el commit.git.merge_conflict(Lote 89) — unmergeprodujo conflictos; el cuerpo incluye"conflicts": [rutas]y"aborted": bool(truecuando se abortó automáticamente -- el caso por defecto --,falsecuando se dejó el árbol en conflicto para resolver a mano, verPOST .../mergeabajo).git.branch_unmerged(Lote 89) —DELETE .../branches/{name}sinforce=1sobre una rama no fusionada del todo.git.branch_is_current(Lote 89) —DELETE .../branches/{name}sobre la rama actualmente activa; nunca se borra por esta API.
git.nothing_to_commit(400) — mensaje vacío, o nada en stage y no esamend.
Cualquier respuesta de una ruta de mutación añade además "repo": el mismo
objeto que devuelve GET /api/git/repos/{repo_id}, fresco, para que la UI
pueda refrescarse de una sola vez — también en las respuestas de error,
cuando el repo ya se pudo resolver.
git nunca se ejecuta con shell=True: cada llamada es un argv real con
cwd explícito, timeout (60 s por defecto; 120 s para fetch/pull/push/
sync), y las mismas flags de endurecimiento que ya usa
routes/workspace_routes.py (-c core.fsmonitor=, -c diff.external=,
--no-ext-diff en los comandos de diff) para que un .git/config de un
repo clonado o escrito por el agente no pueda ejecutar nada al abrir el
panel. Los commits usan siempre la identidad configurada en el propio
repo — nunca se inyecta GIT_AUTHOR_NAME/GIT_COMMITTER_NAME — así que un
repo sin identidad configurada falla con git.no_identity en vez de
committear silenciosamente como "Faustus" o similar.
Lista los repos bajo las carpetas enlazadas del owner (o de un único
proyecto, si se pasa project_id; un id que no exista o no sea del owner
responde 404).
Qué repositorios, de TODOS los que ve Faustus (carpetas enlazadas de los
proyectos del owner + las carpetas vigiladas globales), tienen trabajo que
no ha salido de la máquina. Un git status --porcelain=v2 --branch por
repo, en paralelo; remote -v sólo para los que no tienen upstream; log -1 sólo para los que requieren atención. Caché 20 s por owner
(refresh=1 la salta, y también la de descubrimiento). Una caché caducada
se sirve al instante con "stale": true mientras UN rescaneo de fondo por
owner la renueva: sesenta repos son ~10 s de git.exe en Windows y tres
sondeos (badge, Inicio, tira) lo piden — sólo la primera llamada sin caché
y el botón Rescan esperan.
{
"repos": [ /* todos, con `reasons`, los de atención primero */ ],
"attention": [{
"id": "a1b2c3d4e5f6", "name": "faustus", "path": "D:\\LocalAI\\faustus",
"projects": [{"id": "p1", "name": "Faustus"}], "watched": false, "root_folder": "D:\\LocalAI",
"branch": "master", "detached": false, "upstream": "origin/master", "ahead": 2, "behind": 0,
"dirty": {"staged": 0, "unstaged": 1, "untracked": 3}, "conflicts": 0,
"reasons": [{"kind": "uncommitted", "count": 4}, {"kind": "unpushed", "count": 2}],
"attention": true, "severity": 1,
"last_commit_at": 1758700000 // epoch; sólo en filas de atención
}],
"attention_count": 1,
"counts": {"conflicts": 0, "uncommitted": 1, "unpushed": 1, "no_upstream": 0, "local_only": 0, "behind": 0, "detached": 0},
"total": 24,
"watch_roots": ["C:\\Users\\luis\\Proyectos"],
"git_version": "2.43.0", "scanned_at": 1758701234.5, "stale": false,
"summary": "1 of 24 repositories need attention: faustus (master): 4 uncommitted, 2 unpushed"
}kind, por gravedad: conflicts (entradas sin fusionar), uncommitted
(ficheros staged/unstaged/untracked), unpushed (commits por delante del
upstream), no_upstream (rama sin tracking pero el repo tiene remoto: falta
un push -u), local_only (sin ningún remoto). behind y detached se
informan pero NO cuentan como atención: ir por detrás no es un olvido.
attention_count es lo que enseña el badge de la barra lateral y el bloque
«Pendiente de commit o push» de Inicio (que desaparece cuando es 0).
La primera llamada tras reiniciar sirve la instantánea que quedó en
DATA_DIR/git_radar/<owner>.json (también stale: true) y rescanea de
fondo. La misma clasificación la usa la herramienta del agente git_radar
(sólo lectura; only_attention, days, limit, refresh) y la acción
programable git_radar (src/watchers.py: {"days", "always"}, avisa
sólo cuando cambia el conjunto).
Las carpetas vigiladas globales de la instalación (ajuste git_watch_roots)
y las omitidas (git_scan_exclude). GET devuelve {"watch_roots": [normalizadas y existentes], "configured": [tal cual se guardaron], "exclude": [...]}. PUT {"watch_roots": [...], "exclude": [...]} (exclude
opcional: omitido = no se toca) valida cada raíz
(ruta absoluta a un directorio existente; la primera mala responde 400
git.bad_root con path y no guarda nada), deduplica por ruta real,
guarda e invalida las cachés de descubrimiento y del radar. Un repo bajo
una carpeta vigilada es un repo de pleno derecho: aparece en
GET /api/git/repos (con watched: true y projects: []) y todas las
rutas /repos/{id}/* funcionan sobre él. Si además lo enlaza un proyecto,
gana la entrada del proyecto.
Omisiones del escaneo. El recorrido (compartido por el panel y el radar)
salta siempre las carpetas que empiezan por punto (.worktrees, .cache…)
además de node_modules/venv/dist/build, y lo que diga
git_scan_exclude: un nombre pelado (_scratch) omite cualquier carpeta que
se llame así a cualquier profundidad; una ruta absoluta omite ese subárbol.
Una entrada relativa con separadores se rechaza (400). Sin esto, una carpeta
de clones desechables se comía los 200 repos del presupuesto antes de llegar
a las que importan.
El mismo objeto de arriba para un repo concreto, recalculado en el momento.
{
"branch": "master", "detached": false, "ahead": 0, "behind": 2, "upstream": "origin/master",
"staged": [{"path": "a.py", "status": "M"}],
"unstaged": [{"path": "b.py", "status": "M"}],
"untracked": [{"path": "c.py"}],
"conflicts": [{"path": "d.py"}]
}status de cada fichero en stage es A|M|D|R|C (con "old_path" cuando es
un rename/copy detectado); en working tree, M|D. Construido a partir de
git status --porcelain=v2 -z.
Paginado por cursor: next_cursor es el sha del último commit de la página
actual, o null cuando no hay más. ref=all recorre --all; por defecto,
HEAD. Orden --date-order.
{
"commits": [{
"sha": "abc123...", "short": "abc123",
"parents": ["def456..."],
"author": "Luis", "email": "luis@example.com", "date": "2026-09-10T12:00:00Z",
"message": "Fix the thing", "body": "Longer explanation.\n",
"refs": ["HEAD -> master", "origin/master", "tag: v1"]
}],
"next_cursor": "def456..."
}{
"current": "master",
"local": [{"name": "master", "sha": "abc123...", "upstream": "origin/master",
"ahead": 0, "behind": 2, "is_current": true}],
"remote": [{"name": "origin/dev", "sha": "789abc..."}]
}Detalle de un commit, con la lista de ficheros que tocó
(git diff-tree --numstat/--name-status -M -C). additions/deletions
son null para un fichero binario.
Ambas devuelven {"path", "diff", "truncated": bool, "binary": bool}; el
diff se recorta a 200 KB. staged=0 compara working tree vs. index (o, para
un fichero sin trackear, contra el dispositivo nulo de la plataforma, para
que se muestre como una adición completa); staged=1 compara index vs.
HEAD.
Un path que se salga del repo (absoluto, ..) responde 404 en cualquiera
de las rutas que lo reciben.
Todas devuelven, además de lo indicado, "repo" (ver arriba).
POST .../checkout {"branch", "create": false, "start_point"?}→{"ok": true, "branch"}, o 409git.dirtycon"dirty": [rutas].POST .../branches {"name", "start_point"?, "checkout": true}→{"ok": true, "branch"}(mismo 409git.dirtysicheckoutpisaría cambios).POST .../fetch {"remote"?, "prune": false}→{"ok": true, "output"}.POST .../pull {"remote"?, "branch"?}(--ff-only) →{"ok": true, "output"}, o 409git.diverged.POST .../push {"remote"?, "branch"?, "set_upstream": false, "force": false}→{"ok": true, "output"}, o 409git.rejected.force: truese rechaza con 400 antes de tocar git — no hay forma de hacer force-push por esta API.POST .../sync→ hacepull --ff-onlyy, solo si tuvo éxito,push;{"ok", "pull": {...}, "push": {...}|null, "repo"}. Si el pull falla, la respuesta lleva el código/error_classdel pull, con"push": null— el push nunca se intenta.POST .../stage {"paths": [...]}o{"all": true}.POST .../unstage {"paths": [...]}o{"all": true}.POST .../discard {"paths": [...], "confirm": true}— restaura ficheros trackeados al estado del index/HEAD. Destructivo: sin"confirm": trueresponde 400 sin tocar nada.POST .../commit {"message", "amend": false}→{"ok", "sha", "short", "message"}. 400git.nothing_to_commitsi el mensaje está vacío o no hay nada en stage (y no esamend); 409git.no_identitysi el repo no tieneuser.name/user.emailconfigurados. Usa siempre la identidad del repo, nunca la sobrescribe.POST .../merge {"branch", "ff": "auto"|"only"|"no", "message"?, "keep_conflicts": false}(Lote 89 -- Luis: "prueba también a mergear la rama desde ahí, no solo crearla") →{"ok": true, "sha", "fast_forward": bool, "conflicts": []}.ff:"auto"(por defecto -- fast-forward cuando se puede, si no un commit de merge, el comportamiento nativo degit merge),"only"(--ff-only, falla si no se puede avanzar rápido),"no"(--no-ff, siempre crea un commit de merge aunque el fast-forward fuera posible). Sinmessagey conffdistinto de"only"se añade--no-edit, para que nunca se bloquee esperando un editor sin tty. 409git.dirtysi hay cambios sin commitear que el merge pisaría (el mismo aviso nativo de git "would be overwritten by merge", capturado por el mismo_parse_would_be_overwrittenque ya usacheckout). Con conflictos: por defecto (keep_conflictsausente ofalse) el merge se ABORTA automáticamente (git merge --abort) y responde 409git.merge_conflictcon"aborted": true; con"keep_conflicts": trueel árbol se deja tal cual lo dejó git -- para resolver a mano en el editor -- y responde el mismo 409git.merge_conflictpero con"aborted": false(elstatusya expone esos mismos conflictos desde entonces, verGET .../statusarriba).POST .../merge/abort→{"ok": true}. Revierte un merge en curso (keep_conflicts: truede la ruta anterior, o cualquier otro motivo) a su estado previo. 400git.command_failedsi no hay ningún merge en curso (el propio rechazo de git, sin clasificar más).DELETE .../branches/{name}?force=0|1&remote=0|1(Lote 89) →{"ok": true, "deleted": name, "repo", "remote_deleted"?: bool, "remote_error"?: str}.{name}acepta/(ruta:pathde FastAPI, p. ej.feature/xtanto literal como%2F-escapado). Nunca borra la rama que está activa (409git.branch_is_current, comprobado ANTES de lanzar ningún procesogit). Sinforce=1, una rama no fusionada del todo responde 409git.branch_unmerged(git branch -d's propio rechazo);force=1usa-D.remote=1además borra la rama enorigin(git push origin --delete <name>) DESPUÉS de que el borrado local haya tenido éxito -- un fallo ahí (p. ej. la rama nunca se empujó) no deshace el borrado local ya hecho: queda como"remote_deleted": false+"remote_error", nunca como un error de la respuesta entera.
El objeto repo de GET /api/git/repos (y de cualquier respuesta de
mutación) gana además dos campos, calculados por routes/git_routes.py
(src/git_identities.py, src/agent_git_policy.py):
{
// ...los campos ya descritos arriba, más:
"identity": {"id": "sshcfg:...", "label": "Luissalet", "github_login": "Luissalet"},
// null si el remoto `origin` es https, o no coincide con ningún alias conocido
"policy": {"effective": {"use_branch": false, "branch_prefix": "faustus/",
"commit": false, "commit_message_prefix": "faustus: ",
"push": false, "push_set_upstream": true},
"overridden": false}
}identity es solo un match por alias -- nunca prueba ssh (eso es
POST /api/git/identities/{id}/probe, aparte), así que listar repos nunca
se bloquea por la red; github_login es el que ya esté en caché, o null.
Implementación: src/git_identities.py. En la máquina real de Luis,
"cuenta" no es un login -- es un alias de ~/.ssh/config emparejado con una
clave (Host Luissalet → id_ed25519_bookhoard, junto a github-lsaletec y
github-mlgpigeon, los tres apuntando a github.com). Cambiar de cuenta en
un repo es reescribir el host del remoto a otro alias; ssh elige la clave
correcta solo con el Host que hace match -- no hay un "login" que gestionar
aparte.
Una identidad: {id, label, ssh_host (alias, o null), hostname, identity_file, git_user_name?, git_user_email?, github_login? (descubierto, o null), source: "ssh_config"|"manual"|"gh"}.
source: "ssh_config": descubiertas, nunca escritas por esta API salvo conwrite_ssh_config(ver abajo). Dos formas:- un bloque
Host <alias>de~/.ssh/configcon un único alias sin comodín (Host *y cualquier alias con*/?se ignoran -- no hay un alias concreto al que reescribir un remoto); - una clave suelta
~/.ssh/*.pubcuya clave privada no es ya elIdentityFilede ningún bloque -- sin alias (ssh_host: null), label = nombre del fichero.
- un bloque
source: "manual": las que un humano da de alta porPOST /api/git/identities, guardadas enDATA_DIR/git_identities.json(owner-scoped) -- nunca la clave privada en sí, solo su ruta.source: "gh"(Lote 84): una por cada cuenta quegh auth statusreporta ya logueada (src/git_github.py) --id: "gh:<login>",identity_file: null,ssh_host: null(no hay alias que deducir: el contrato es explícito en que inferirlo víagh api user/keyses sobre-ingeniería),github_login= el login (siempre relleno, no viene de una prueba ssh), másprotocol("ssh"|"https", el quegh auth statusreporta para esa cuenta) yactive(si es la cuenta activa degh).gh_accounts()se cachea 15 s, así que listar identidades no disparagh auth statusen cada llamada. Ver "GitHub víagh" más abajo.github_login: parassh_config/manual, descubierto, no configurado.POST .../probecorressh -T -o BatchMode=yes -o StrictHostKeyChecking=accept-new git@<alias>(o-i <identity_file> git@<hostname>para una clave suelta sin alias) y parsea"Hi <login>! You've successfully authenticated"de stderr (rc 1 es la respuesta normal de GitHub a-T-- no da shell a propósito). Timeout 8 s, cacheado 10 min por id;GET /api/git/identitiesnunca prueba la red por sí sola, solo devuelve lo que ya esté en caché -- peroGET /repos/{id}/identitysí, ver abajo. Una identidadghnunca se prueba por ssh: su login ya se conoce directamente.
GET /api/git/identities
→ {"identities": [...], "ssh_config_path": "/home/luis/.ssh/config", "ssh_dir": "/home/luis/.ssh"}
POST /api/git/identities
{"label", "ssh_host"?, "hostname"="github.com", "identity_file",
"git_user_name"?, "git_user_email"?, "write_ssh_config": false}
→ 201 {"identity": {...}} (source: "manual").
400 git.identity_file_missing si identity_file no existe.
Si `ssh_host` no está ya en ~/.ssh/config y `write_ssh_config: true`,
AÑADE un bloque Host al final del fichero (nunca edita uno existente;
hace una copia `.bak` primero).
DELETE /api/git/identities/{id}
→ {"ok": true}. 400 git.identity_not_manual si el id viene de
ssh_config (descubierta, no se puede borrar); 404 si no existe.
POST /api/git/identities/{id}/probe
→ {"github_login": str|null, "ok": bool, "detail": str} -- siempre
fuerza una prueba nueva (ignora la caché de lectura, la reescribe).
GET /api/git/repos/{repo_id}/identity?remote=origin
→ {"active": identidad|null, "remote": "origin", "remote_url",
"git_user": {"name", "email", "scope": "local"|"global"|"none"}}
require_user. Si la identidad activa no es `source: "gh"` y su
`github_login` no está cacheado, esta ruta PRUEBA por su cuenta
(Lote 84) -- timeout 8 s (`probe_identity`, sin `force`, así que un
resultado ya cacheado -- incluso uno fallido -- no vuelve a probar) --
para que el chip pueda pintar "(login: X)" sin que un humano tenga que
pulsar el botón de prueba aparte. Nunca bloquea más de esos 8 s.
PUT /api/git/repos/{repo_id}/identity
{"identity_id", "remote"="origin", "set_git_user": true}
→ reescribe la URL del remoto y, si `set_git_user`, ajusta la identidad
git local del repo. require_human. Para una identidad `ssh_config`/
`manual`: `git@<alias>:<owner>/<repo>.git` (entiende ssh scp-like,
`ssh://` y `https://` de entrada) y, si trae `git_user_name`/
`git_user_email`, los fija como `user.name`/`user.email` LOCAL (nunca
global) -- SIEMPRE los sobrescribe. 400 `git.no_alias` si la identidad
no tiene alias ssh (una clave suelta).
Para una identidad `source: "gh"` (Lote 84, sin `ssh_host`): reescribe
a `git@github.com:<owner>/<repo>.git` (o `https://github.com/<owner>/
<repo>.git` si el `protocol` de esa cuenta de `gh` es https) -- nunca
400 `git.no_alias`, esta fuente no lo necesita -- y, si `set_git_user`,
fija `user.name` = `github_login` SOLO cuando el repo no tiene ya un
nombre efectivo (local o global); nunca toca `user.email` (una cuenta
`gh` no trae uno).
400 `git.unrecognized_url` si la URL del remoto no se pudo parsear;
400 `git.no_remote` si el repo no tiene ese remoto. Devuelve
`{"remote", "remote_url_before", "remote_url_after", "git_user", "repo"}`.
Para "crear un repo y subir cosas" hace falta el lado GitHub del remoto antes
de poder hacer git push. Esta pieza es un wrapper fino y testeable sobre
tres subcomandos de la CLI gh -- nunca la API REST de GitHub directamente,
nunca un token propio: usa la sesión de gh que Luis ya tiene autenticada
(gh auth status → cuentas Luissalet (activa) y Mlgpigeon, protocolo
ssh). Todo corre a través de un único punto de entrada inyectable,
git_github._run_gh (nunca shell=True, argv real, stdin=DEVNULL para que
un gh no interactivo jamás se quede esperando un prompt) -- los tests
sustituyen esa función, nunca necesitan un gh real. En Windows, gh puede
resolver como gh.exe; shutil.which("gh") ya lo encuentra vía PATHEXT
sin nada especial.
gh_available(): comprobación de PATH, sin lanzar ningún proceso.gh_version():gh --version, parseado a"2.40.1".gh_accounts():gh auth status, parseado a{"available", "version", "accounts": [{"login","active","protocol","scopes"}]}-- nunca el token, solo lo quegh auth statusya imprime en claro. Cacheado 15 s (gh_accounts(use_cache=False)lo salta) porque tanto el listado de identidades como la elección de URL de remoto lo consultan.gh_token(login):gh auth token --user <login>-- el token vive SOLO en memoria, comoGH_TOKENen el entorno del proceso hijo que crea el repo; nunca se loguea ni aparece en ninguna respuesta.create_github_repo(login, name, private=True, description=""):gh repo create <login>/<name> --private|--public [--description ...], ejecutado conGH_TOKENdeloginen el entorno del hijo -- SIN cambiar la cuenta activa global degh(gh auth switch). No toca el working tree local (sin--source/--push/--remote): solo crea el repo en GitHub;git_panel.add_remote/pushhacen el resto, igual que lo haría un humano a mano. Tras crear,gh repo view <login>/<name> --json nameWithOwner,url,sshUrlda las URLs exactas (no se parsea el stdout derepo create, que cambia de formato entre versiones degh). Devuelve{"full_name","html_url","ssh_url","https_url"}. 409github.existssi el nombre ya existe bajo esa cuenta (stderr contiene "already exists"); 502github.failedconstderrpara cualquier otro fallo degh.
GET /api/git/github/accounts
→ {"available": bool, "version": str|null,
"accounts": [{"login","active","protocol","scopes":[...]}]}
require_user.
POST /api/git/repos
{..., "github"?: {"create": bool, "login": str, "private": true,
"description"?: str, "push": true, "identity_id"?: str}}
Solo tiene efecto con `mode: "init"` ("clone no aplica" -- un repo
clonado ya trae el `origin` de su fuente). Tras crear el repo local (y
su commit inicial, si aplica): crea `login/name` en GitHub, añade
`origin` (URL ssh de una `identity_id` explícita si se dio, si no la
del protocolo de la cuenta `login` en `gh_accounts()`, si no la ssh que
`create_github_repo` ya resolvió) y, si `push` (default true), hace
`git push -u origin <default_branch>`.
→ 201 `{"repo", "github": {...}|null, "push": {"ok","output"}|
{"ok": false,"error_class","stderr"}|null}` -- `github`/`push` quedan
`null` cuando no se pidió `github.create`.
400 `github.login_required` si `github.create` es true sin `login`.
409 `github.exists` / 502 `github.failed` si `gh` falla -- el repo LOCAL
ya creado se conserva en ambos casos, y la respuesta incluye `"repo"`.
POST /api/git/repos/{repo_id}/github/publish
{"login", "private"=true, "name"?: (por defecto el nombre del repo),
"identity_id"?, "push"=true, "description"?}
→ para un repo que aún no tiene `origin`: crea `login/name` en GitHub,
añade `origin`, empuja (`push` default true). Misma respuesta que
arriba (siempre con `github`/`push`, nunca `null` aquí -- si `gh`
falla, el error se devuelve directamente en vez de `null`s). 409
`git.remote_exists` si el repo YA tiene `origin` (no se llama a `gh`
en absoluto). require_human.
GET /api/git/folders
→ {"folders": [{"path", "project_id", "project_name"}]} -- las carpetas
enlazadas del owner (workspace + enlaces `folder`), de-duplicadas; el
único sitio donde `POST /api/git/repos` puede escribir.
POST /api/git/repos
{"mode": "init"|"clone", "parent_folder": str (una de /folders o un
subdirectorio suyo), "name": str (solo [A-Za-z0-9._-]),
"url"?: str (clone), "identity_id"?: str,
"initial_commit": true (init: crea README.md + commit "Initial commit"),
"default_branch": "main",
"github"?: {...}} # ver "GitHub vía `gh`" más abajo
→ 201 {"repo": <objeto repo>, "github": {...}|null, "push": {...}|null}.
parent_folder fuera de las carpetas enlazadas del owner: 403
git.folder_not_allowed (la misma garantía de confinamiento que ya da el
descubrimiento, antes de tocar el disco). name inválido: 400
git.invalid_name. El directorio destino ya existe y no está vacío: 409
git.exists. mode: "clone" sin url: 400 git.invalid_name. Clone con
identity_id: la URL se reescribe al alias de esa identidad antes de
clonar (mismo rewrite_remote_alias de arriba), timeout 120 s; fallo → 409
git.clone_failed con stderr. mode: "init" con identity_id: fija
user.name/user.email LOCALES del nuevo repo desde esa identidad antes
del commit inicial; sin identidad ni user.* global en el host, el commit
inicial falla con 409 git.no_identity (el repo queda creado, sin commit).
Crear rama: la ruta ya existente POST .../branches {name, start_point?, checkout} (ver arriba) es también el endpoint que usa Studio para "crear
rama desde X" -- no hay una ruta nueva para esto.
Qué hace el agente por su cuenta dentro de un repo que está trabajando, configurable: ¿usa una rama aparte?, ¿comitea lo que cambia?, ¿hace push? -- a preferencia del usuario, global o por repo.
Setting global: git_agent_policy en src/settings.py DEFAULT_SETTINGS
(no agent_git_policy -- ver el docstring de módulo de
src/agent_git_policy.py para el porqué: ese prefijo está reservado por el
formulario genérico de Settings → Agent Tools, que solo sabe describir
campos escalares, y esta es una política de cinco campos con su propia ruta
y su propia tarjeta en Studio). Override por repo en
DATA_DIR/git_repo_policies.json (owner+repo_id).
{"use_branch": false, "branch_prefix": "faustus/", "commit": false,
"commit_message_prefix": "faustus: ", "push": false, "push_set_upstream": true}GET/PUT /api/git/policy → política global. PUT admite un
subconjunto de campos.
GET/PUT /api/git/repos/{repo_id}/policy → override por repo.
{"policy": {"effective", "overridden",
"override": {...}|null}}.
PUT con {"inherit": true} quita el
override (vuelve a heredar la global).
Semántica, cuando el workspace de un turno de agente está dentro de un repo
(git rev-parse --show-toplevel desde el workspace -- puede ser un
subdirectorio del repo):
use_branch: antes de que el turno llegue al bucle del agente (routes/chat_routes.py,chat_stream'sstream_with_save, justo antes destream_agent_loop), si HEAD no está ya en una ramabranch_prefix*, crea<branch_prefix><slug-de-la-sesión>-<yymmdd>desde HEAD y hace checkout. Si el árbol está sucio, NO crea rama y emiteskipped: "dirty". Idempotente sin tabla de sesión que mantener: como el turno 1 deja HEAD en la rama del agente, el turno 2 la encuentra ya activa y no hace nada.commit: al final del turno (_record_turn_side_effects, que ya conoce los ficheros que tocó el harness),git addSOLO esos ficheros (nunca-Asobre todo el árbol) ygit commit -m "<prefix><resumen>"con la identidad YA configurada del repo -- nunca una que este módulo inyecte; sin identidad → avisogit.no_identity, sin commit.push: solo tras un commit con éxito en la MISMA llamada,git push(con-u origin <rama>si no hay upstream ypush_set_upstreamestá activo); si falla, aviso constderr, nunca reintenta.- Política toda en
false: el repo no se toca. Nuncapush --force(git_panel.pushno tiene esa opción). Nunca comitea/hace push sobre un árbol con conflictos.
Cada acción emite un evento SSE git_policy:
{"action": "branch"|"commit"|"push", "ok": bool, "branch"?, "sha"?, "detail"?, "skipped"?}, que Studio pinta como un chip discreto en el
transcript.
Además de la política automática de arriba, el modelo puede llamar a git
explícitamente dentro de un turno — "haz commit de esto y push" — a través
de once tools de function-calling, en vez de bash. Implementación:
src/agent_tools/git_tools.py; cada una es un ejecutor fino sobre
src.git_panel (el mismo runner de git endurecido — sin shell=True,
-c core.fsmonitor=, --no-ext-diff — que ya usa el panel), así que su
vocabulario de errores es el mismo que documentan las rutas de arriba.
git_status lectura rama, ahead/behind, staged/unstaged/untracked, últimos N commits
git_log lectura historial de commits (limit, ref)
git_diff lectura diff de árbol de trabajo / staged / de un commit, recortado a 60 KB
git_branch escritura crea (y por defecto hace checkout de) una rama
git_checkout escritura cambia de rama
git_merge escritura funde `branch` en la rama actual (Lote 89, ff auto/only/no)
git_delete_branch escritura borra una rama local (Lote 89)
git_commit escritura stage de paths EXPLÍCITOS (nunca `-A`) + commit con la identidad propia del repo
git_push remoto push (nunca --force -- git_panel.push no tiene esa opción)
git_pull remoto pull --ff-only (nunca merge/rebase)
git_fetch remoto fetch
Las once aceptan un path opcional y, desde el Lote 90, un repo opcional
(nombre del repositorio — ver "Lenguaje natural" abajo); las de solo lectura
devuelven además datos estructurados (branch, commits, diff, ...) junto
al output de texto.
git_merge SIEMPRE llama a git_panel.merge con keep_conflicts=False --
nunca deja un conflicto a medio resolver para que el modelo lo arregle
dentro del turno: un conflicto se aborta automáticamente y se informa
(error_class git.merge_conflict, con conflicts/aborted) para que un
humano lo resuelva desde el panel (que sí puede dejar el conflicto en pie,
vía su propio keep_conflicts). git_delete_branch rehúsa la rama
actualmente activa (git.branch_is_current) y, sin force, una rama no
fusionada del todo (git.branch_unmerged) -- mismo vocabulario que
DELETE /repos/{id}/branches/{name} arriba.
Cuando se da path, se resuelve con src.tool_execution._resolve_tool_path
— el MISMO allowlist que ya usan read_file/write_file/
manage_spreadsheet (el workspace del turno, más cualquier carpeta enlazada
al proyecto de la sesión). Un path que se sale de esas raíces, o cuyo
confinado no tiene repo en él ni por encima, se rehúsa ANTES de lanzar
ningún proceso git:
error_class: "git.outside_workspace"— el path (o el workspace activo, cuando nipathnireporesuelven a nada — ver "Lenguaje natural" abajo) no está dentro de las raíces confinadas del turno.error_class: "git.not_a_repo"— el path confinado no tiene ningún.giten él ni por encima.
El resto de errores reutiliza, byte a byte, el vocabulario que ya usan las
rutas del panel: git.dirty, git.diverged, git.rejected,
git.no_identity, git.nothing_to_commit, git.command_failed,
dependency.missing (git no está instalado en el host).
Luis (literal): "que no tenga que ser todo tan explícito… en vez de 'con la herramienta X en el directorio X' simplemente 'dime nosequé para el proyecto X'. Un usuario no debería saberse de memoria todas las tools de Faustus." "¿en qué rama está el repo del proyecto?", "mergea la rama pruebas en main y haz push", "crea una rama para esto", "commitea lo que has cambiado" — funcionan sin que el usuario nombre una tool ni una ruta.
Resolución del repo (src/agent_tools/git_tools.py::_repo_root), en
orden:
pathexplícito (como antes — confinado al workspace del turno).repo(nombre del repositorio): coincidencia exacta, o si no, un prefijo único, insensible a mayúsculas, entre los repos que enlaza el PROYECTO de la sesión —git_panel.discover_repos_for_owner(owner, project_id), no solo el workspace activo del turno (un proyecto puede enlazar carpetas además del workspace). Varias coincidencias por prefijo, o ninguna, también caen en los errores de los pasos 4-5.- el repo que contiene el workspace activo del turno (
git_panel. repo_toplevel— el fallback que ya existía). - si el proyecto tiene UN solo repo, ese (aunque el workspace activo no esté dentro de él, o no haya workspace).
- si el proyecto tiene VARIOS y nada de lo anterior resolvió uno:
error_class: "git.which_repo"con"repos": [nombres]— pensado para que el modelo pregunte conask_user("¿cuál: a, b, c?"), nunca para que adivine.repocon un nombre que no coincide con ninguno (ni exacto ni por prefijo) eserror_class: "git.repo_not_found"con"repos": los nombres que sí existen en el proyecto.
ctx["project_id"] (el paso 2) ya viaja hasta la tool sin cambios en
tool_execution.py: _direct_fallback lo pone en el ctx de TODAS las
tools desde turn_options["harness_options"]["project_id"]
(services/projects.py::agent_options, poblado por
routes/chat_routes.py desde project_for_session(session_id, owner)) —
ver el comentario de ese campo en tool_execution.py::_direct_fallback. Las
tools git_* solo tenían que empezar a LEER esa clave, ya presente.
Contexto del proyecto en el prompt (src/agent_loop.py:: _project_repos_block, llamado desde _build_system_prompt): cuando el
proyecto de la sesión tiene repos, el prompt del sistema gana una sección
compacta que dice al modelo qué es "el repo":
## Repositories in this project
- faustus: master +0/-2 3d (Luissalet)
- other-repo: main +1/-0 0d
Una línea por repo — nombre, rama (o (detached)/(unborn)),
ahead/behind, nº de ficheros sucios, alias de identidad ssh entre paréntesis
cuando el remoto origin hace match (git_identities. active_identity_for_repo) — usando el modo light de git_panel. repo_summaries (un solo git status por repo; ver "Rendimiento" arriba),
nunca el modo completo. Cacheada 20 s por (owner, project_id)
(agent_loop._REPOS_BLOCK_CACHE) — corre en cada construcción del prompt,
así que sin caché cada turno pagaría un git status+git remote -v fresco
por repo solo para montar el prompt. Acotada a 12 repos y 400 caracteres
(_REPOS_BLOCK_MAX_REPOS/_REPOS_BLOCK_MAX_CHARS) — un proyecto con más
repos de los que caben en el bloque simplemente no los lista todos ahí (las
tools git_* sí los resuelven igualmente por nombre, sin ese límite: el
recorte es solo del texto del prompt). Vacía — no añade nada — cuando la
sesión no tiene proyecto, o el proyecto no tiene repos.
Reglas del agente (_AGENT_RULES/_API_AGENT_RULES en
src/agent_loop.py): "When the user talks about branches, commits, pushes
or 'the repo' without naming one, use git_ with repo (name) or no path at
all — they resolve to the project's repository; only ask which one if there
are several."*
Detección de intención git (_git_intent, el regex que decide si el
turno recibe las git_* tools de escritura — ver "Herramientas del agente"
arriba): además de git|commit|commits|commitea|push|pull|fetch|rama| ramas|branch|branches|merge|stage|checkout|repositorio|repo, ahora también
mergea|mergear|fusiona|subir|sube|sincroniza|pull request.
git_branch/git_checkout/git_merge/git_delete_branch (policy.use_branch
-- Lote 89 pone git_merge/git_delete_branch bajo la misma política y
aprobación que git_checkout), git_commit
(policy.commit) y git_push (policy.push) consultan la política
EFECTIVA del repo destino (agent_git_policy.effective_policy — la misma
función que usan GET /api/git/repos/{id}/policy y los hooks
before_turn/after_turn de arriba) antes de tocar nada. Si el campo relevante
es false, la llamada se rehúsa con
{"error": ..., "policy": "git_agent_policy", "git_policy_field": "commit"|"push"|"use_branch"}
— salvo que un humano haya aprobado explícitamente ESA llamada exacta.
git_pull/git_fetch no llevan gate de política: solo leen del remoto y
hacen fast-forward de refs locales, la misma clase de riesgo que los
botones de Fetch/Pull siempre activos del panel.
Cómo una tool sabe si su llamada fue aprobada por un humano — dos vías,
ambas comprobadas por git_tools._human_approved(ctx, args):
ctx["human_approved"]— puesto porsrc.tool_execution.execute_tool_blocka partir de si el contenido de ESTA llamada coincidió, byte a byte, con unaPendingToolApprovalsellada que el usuario respondió en una tarjeta de aprobación (src.tool_approvals.ExactToolApproval.claim). Es el MISMO mecanismo que ya usan el resto de tools con aprobación (entrada de escritorio, un veredicto del guard de comandos destructivos, una escritura tras contexto externo) — ningún subsistema de aprobación nuevo. Se activa cuando el turno ya necesitaba una tarjeta por otro motivo (una página externa leída antes, un comando marcado por el guard, ...) y el usuario también aprobó esta llamada de git.args["user_confirmed"]— el propio modelo deja constancia de que preguntó al usuario (víaask_user) si quiere saltarse la política del repo, el usuario dijo que sí, y reintenta la MISMA llamada con el flag atrue. Es la misma forma "pregunta, y reintenta con un flag explícito" que ya usainstall_dependencies(src/agent_tools/exec_tools.py) para un plan que necesita un sí humano sin que haya ninguna tarjeta sellada de por medio — un turno normal, sin contexto contaminado ("comitea esto y haz push"), no crea tarjeta porque ningún gate la habría bloqueado antes.
Un modelo corriendo desatendido (una tarea programada, sin turno humano que
relaye una respuesta) no tiene ninguna de las dos: ctx["human_approved"]
es falso porque nunca se selló una tarjeta, y nada le indica poner
user_confirmed. Es deliberado: en modo autónomo sin aprobación, no.
git_commit exige siempre paths (una lista no vacía de ficheros a
stagear) — nunca hace git add -A implícito. No hay, hoy, forma de que la
tool sepa qué ficheros tocó el propio turno del agente sin que
src/agent_loop.py se lo pase explícitamente (ver "Cambios necesarios en
ficheros ajenos" en el informe de cierre del Lote 87); mientras eso no
exista, el modelo debe nombrar los ficheros exactos.
Las once están declaradas en src/tool_schemas.py::FUNCTION_TOOL_SCHEMAS
(function-calling nativo), registradas en src/agent_tools/__init__.py
(TOOL_HANDLERS/TOOL_TAGS, para el fencing XML de los modelos sin tool
calling nativo) y clasificadas en src/tool_capabilities.py: las de lectura
como READ_WORKSPACE; git_branch/git_checkout/git_commit/git_merge/
git_delete_branch como WRITE_WORKSPACE; git_pull/git_fetch como
NETWORK_EGRESS + WRITE_WORKSPACE; git_push como NETWORK_EGRESS +
EXTERNAL_SIDE_EFFECT (no existe un ToolEffect.REMOTE literal — se
componen a partir de los efectos existentes, todos ya dentro de
POST_EXTERNAL_BLOCKED_EFFECTS). src/tool_registry.py deriva su catálogo
automáticamente de esas tres fuentes — ninguna entrada manual adicional.
Las once están además en NON_ADMIN_BLOCKED_TOOLS
(src/tool_security.py) — misma clase de privilegio que bash/
read_file/write_file: tocan el disco (y, para push/pull/fetch, un host
remoto) del owner, nunca de un usuario público — y las de solo lectura en
PLAN_MODE_READONLY_TOOLS (git_merge/git_delete_branch, como el resto
de las de escritura, están en _PLAN_MODE_KNOWN_MUTATORS, el respaldo
estático que mantiene el modo plan cerrado incluso si el import de los
esquemas fallara). También están en _GIT_TOOL_NAMES
(src/tool_execution.py), la rama de despacho que pasa owner/
human_approved a estas tools -- sin eso el gate de política no sabría
quién llama ni si ya se aprobó la llamada -- y por tanto en el "suelo" de
tools que src/agent_loop.py ofrece cuando el turno habla de git
(_GIT_TOOL_NAMES es la única fuente, así que entrar ahí ya es suficiente).
Dos tools nuevas cierran el ciclo "dale al agente una issue de GitHub y que
termine en un pull request", sobre un módulo puro (src/github_pr.py) sin
dependencia de las tools ni del agente:
-
github_issue(lectura, red) -- resuelve una referencia de issue/PR (parse_issue_ref: URL completa,owner/repo#12, o un#12/12que se resuelve contra el remotoorigindel workspace) y trae el issue vía la API pública de GitHub (src.reach.credentials.get_token("github")si hay token configurado; si no, cae agh issue view --jsoncuandoghestá instalado). Devuelve el issue completo (title,body,labels,state, hasta 20comments,url), un brief compacto en markdown (issue_brief: título, labels, body recortado a ~2000 caracteres, y los- [ ]del cuerpo extraídos como pistas de aceptación) y una rama sugerida (suggest_branch_name:fix/123-slug-corto, ofeat/...si alguna label suena a feature/enhancement). -
git_open_pr(efecto lateral, red) -- abre el pull request para una rama que YA se empujó aorigin(nunca hace push por sí misma): siheadno tiene upstream, se rechaza conerror_class: git.no_upstreampidiendo ejecutargit_pushprimero.basepor defecto es la rama por defecto del repo (detect_default_branch:origin/HEADsi está seteado, si no la primera demain/masterque exista como rama remota, si nomain-- nunca toca la red, solo refs locales). Si se pasaissue_ref, añadeCloses #Nal body cuando no está ya. Si ya existe un PR abierto para esehead, lo devuelve tal cual (created: false) en vez de duplicarlo. Usa la misma API pública (ogh pr createde respaldo sin token) y está gateada por el MISMO campopolicy.pushquegit_push/git_publish-- abrir un PR es una escritura remota sobre un repositorio que este proceso no posee, misma clase de riesgo que empujarle un commit.
Clasificación: github_issue como ToolEffect.BROKERED_NETWORK_READ (mismo
tipo que web_fetch/reach_read -- el título/body/comentarios de un issue
puede haberlos escrito cualquiera con acceso al repo, contenido no fiable);
git_open_pr como NETWORK_EGRESS + EXTERNAL_SIDE_EFFECT (mismo tipo que
git_push/git_publish). Ambas en NON_ADMIN_BLOCKED_TOOLS; github_issue
además en PLAN_MODE_READONLY_TOOLS.
pr_body_from_turn(issue, summary, files_changed, tests_line) es el helper
que arma el body de un PR a partir del resumen de un turno del agente
(Closes #N, el resumen, la lista de ficheros tocados, una línea de tests) --
sin llamar a la red, para que el propio agente pueda inspeccionarlo antes de
llamar a git_open_pr.
Nota de wiring: _GIT_TOOL_NAMES (src/tool_execution.py) y el "suelo" de
git tools en src/agent_loop.py (que la usa para decidir qué tools de git
ofrecer cuando el turno habla de git/pull request) son ficheros del
integrador y no incluyen todavía estas dos tools -- ver A_wiring.md para
el diff exacto. Mientras tanto ambas tools funcionan igual (se despachan por
dynamic_handlers, que ya pasa owner); lo único que falta es que una
tarjeta de aprobación ya sellada por otra causa cubra automáticamente esta
llamada -- el camino "pregunta al usuario y reintenta con
user_confirmed: true" ya funciona hoy.
{ "repos": [{ "id": "a1b2c3d4e5f6", "path": "/home/luis/code/faustus", "name": "faustus", "project_id": "p1", "project_name": "Faustus", // uno por cada proyecto que enlaza esta misma ruta real -- normalmente // uno solo; project_id/project_name arriba son siempre projects[0]. "projects": [{"id": "p1", "name": "Faustus"}], "root_folder": "/home/luis/code/faustus", "parent_repo_id": null, "branch": "master", "detached": false, "head_sha": "abc123...", "upstream": "origin/master", "ahead": 0, "behind": 2, "dirty": {"staged": 1, "unstaged": 3, "untracked": 5}, "conflicts": 0, // entradas sin fusionar (subconjunto de dirty) "watched": false, // true si sólo lo encontró una carpeta vigilada (radar) // Los 4 campos siguientes -- ausentes con `?light=1` (ver Rendimiento arriba): "user": {"name": "Luis", "email": "luis@example.com"}, "remotes": [{"name": "origin", "fetch_url": "git@github.com:...", "push_url": "git@github.com:..."}], "identity": {"id": "sshcfg:...", "label": "Luissalet", "github_login": "Luissalet"}, "policy": {"effective": {"...": "..."}, "overridden": false} }], "git_version": "2.43.0" }