Repository Content Ingest
Summary
Repository content ingest clones a GitHub repository, walks its Markdown files, and upserts each one as a document. It runs against two kinds of source:
- Public sources — one repository shared across every organization. Documents land in a single global table, and any library can subscribe to a subset of them.
- Private sources — a repository owned by one organization's library. Documents land in that library's own table, exactly like hand-authored documents.
Both kinds go through the same pipeline: acquire a workspace (a git clone), walk the files that match the configured filter, parse and hash each one, and upsert it with move detection. The only thing that differs is which database table the documents land in.
High-Level Flow
Trigger (manual HTTP call, or the scheduler on a timer)
-> publish a "sync requested" message
-> a message handler picks it up
-> the handler asks a dispatcher to run the job
-> the dispatcher acquires a workspace (git clone)
-> the runner walks the workspace and upserts documents
-> the dispatcher disposes the workspace (deletes the clone)
Every step after "publish a message" happens outside the original HTTP request. Triggering a sync returns immediately with a token; the actual clone and upsert work happens on a message queue worker.
Key Pieces
| Piece | Role |
|---|---|
RepositoryIngestJob | Everything one sync run needs: repo URL, source kind, filter, run id, trace context. Built once by whoever triggers the run (an endpoint or the scheduler) and carried through unchanged. |
IRepositoryIngestDispatcher | Runs a job on a specific runtime. Today there's one implementation, InProcessIngestDispatcher, which runs the whole job inline in the current process. |
IIngestWorkspaceProvider | Acquires a working directory for the clone. LocalTempWorkspaceProvider clones into the OS temp directory and deletes it when the run finishes. |
RepositoryIngestRunner | The actual pipeline: enumerate files, apply the filter, parse and hash each one, upsert, and run a deletion sweep on a clean pass. Knows nothing about git, dispatch, or messaging — it only needs an already-acquired workspace. |
IngestFileFilter | Decides whether one file path is in scope, given the effective include/exclude globs. See Filtering below. |
PublicRepositorySyncRequestedHandler / PrivateRepositorySyncRequestedHandler | Message consumers that load the source/library, build the job, and call the dispatcher. |
IngestSchedulerHostedService | A background timer that claims sources/libraries whose next sync is due and publishes sync-requested messages for them, same as a manual trigger would. |
Public vs. Private Sources
| Public source | Private source | |
|---|---|---|
| Owned by | Nobody — shared globally | One organization's library |
| Documents table | docs_public_documents | docs_library_documents (same table hand-authored docs use) |
| Identified by | PublicSourceId | OrganizationId + LibraryId |
| Filter comes from | The source's own default filters (today — see callout below) | The library's own IncludeFilters/ExcludeFilters, falling back to the source-suggested defaults if empty |
| GitHub auth | Anonymous clone, or a PAT for rate limits | GitHub App installation token, resolved per organization |
| Manual trigger endpoint | POST /api/v1/admin/public-sources/{publicSourceId}/ingest-run (system admin only) | POST /api/v1/orgs/{orgId}/libraries/{name}/ingest-run |
Triggering a Sync
A sync can start two ways:
- Manual trigger — an HTTP request to one of the endpoints above. Rate-limited (5 triggers per hour per source/library by default) and idempotent (a
409if a sync is already queued or running). - Scheduled —
IngestSchedulerHostedServiceticks on a timer (5 minutes by default), atomically claims every source/library whosenext_sync_athas passed, and publishes a sync request for each one. After a run finishes,next_sync_atis set to roughly one hour later with ±20% random jitter, so a fleet of sources doesn't all wake up in lockstep.
Both paths publish the same kind of message and go through the same handler and runner — a scheduled sync and a manual one are indistinguishable once they're queued.
The Clone: Two Layers of Filtering
Getting from "clone the whole repo" down to "only the Markdown files that matter" happens in two separate steps, at two different levels:
1. Git-level narrowing (coarse, extension-only). The clone itself uses a sparse checkout restricted to three extensions — *.md, *.mdc, *.mdx — combined with a partial clone (--filter=blob:none) and shallow history (--depth=1). This means git never downloads file content for anything that isn't a Markdown file, and never downloads more than the latest commit. For a large repository this is the difference between fetching a handful of megabytes and fetching the entire repository history.
This step is deliberately not directory-aware. It doesn't know or care about your include/exclude filters — it only narrows by extension.
2. In-process filtering (fine-grained, directory-aware). Once the clone lands on disk, RepositoryIngestRunner walks every Markdown file that survived step 1 and asks IngestFileFilter.IsIncluded whether it's actually in scope for this sync's configured filter. This is where include globs, exclude globs, and directory targeting apply.
Why split it this way? Translating an arbitrary include/exclude glob into git's own sparse-checkout pattern language (which has its own dialect, with cone and non-cone modes) is a second glob dialect to get right, for a bandwidth-only benefit. Worst case with the current design: a narrowly-filtered sync's clone briefly materializes some Markdown files on disk that the in-process filter then correctly excludes from the database — it never under-materializes what correctness requires. IngestFileFilter is the single source of truth for what actually gets ingested.
Filtering
Syntax
A filter is two lists of glob patterns: IncludeGlobs and ExcludeGlobs. The glob dialect is intentionally small — just two wildcards:
| Wildcard | Meaning |
|---|---|
* | Matches anything, of any length — including /. A single * can match an entire subtree, not just one path segment. |
? | Matches exactly one character. |
There's no special ** syntax. Because * already crosses directory boundaries, docs/* and docs/** would behave identically if ** existed — so it doesn't.
Matching is case-insensitive and always against the full repository-relative path, normalized to forward slashes with no leading slash (a path like .agents/plans/notes.md, not /.agents/plans/notes.md).
The Two Narrowing Rules That Always Apply
Regardless of any configured filter:
- Only
.md,.mdc, and.mdxfiles are ever ingested. - An empty include list means "everything" (subject to rule 1) — it is not the same as "include nothing."
Include and Exclude, Together
| Step | Rule |
|---|---|
| 1. Extension check | Reject anything that isn't .md/.mdc/.mdx, unconditionally. |
| 2. Include check | If IncludeGlobs is non-empty, the path must match at least one of them (a union — any match is enough). If IncludeGlobs is empty, this step passes everything through. |
| 3. Exclude check | If the path matches any pattern in ExcludeGlobs, it's rejected — even if it matched an include pattern in step 2. Exclude always wins. |
Worked Examples
These are real results from dry-running the ingest pipeline against public repositories — see Dry-Run Diagnostics below.
| Include globs | Exclude globs | What matched |
|---|---|---|
[".agents/plans/*"] | [] | Every Markdown file under .agents/plans/, at any depth. |
[".github/agents/*", ".agents/skills/d*"] | [] | Everything under .github/agents/, plus everything under any skill directory whose name starts with d (dashboard-testing, dependency-update, ...). Two include globs are a union, not an intersection — a file only needs to match one of them. |
[".github/agents/*", ".agents/skills/*"] | [".agents/skills/d*"] | Everything under .github/agents/, plus every skill except the ones whose directory starts with d. |
That last example is worth slowing down on, because of one file: .agents/skills/hosting-integration-authoring/resources/dashboard-ux.md. Its filename starts with d — but it was still included. The exclude glob .agents/skills/d* matches on the full path starting right after .agents/skills/, so it only excludes directories that themselves start with d. hosting-integration-authoring doesn't start with d, so nothing under it is excluded, no matter what the individual filenames look like.
*/d*.md would exclude any file whose name starts with d, in any directory, since the leading * absorbs the directory path.Move Detection
Every file upsert resolves to exactly one of four outcomes, decided in this order:
| Outcome | Condition |
|---|---|
| Unchanged | A document already exists at this exact path, and its content hash matches. Only the sync-run stamp is updated — nothing else changes, so unrelated readers don't see spurious churn. |
| Updated | A document already exists at this exact path, but the content hash differs. |
| Moved | No document exists at this exact path, but exactly one existing document (scoped to this source/library) has the same content hash. The old path is appended to that document's history, and its path is updated to the new one. |
| Added | Nothing above matched. |
If two or more existing documents share the same content hash as an incoming file with no path match (for example, two files that happen to be byte-for-byte identical), the ambiguous case falls through to Added rather than guessing which one moved. An exact path match always takes priority over a hash match, so this ambiguity only ever affects genuine moves where the destination content also happens to collide with something else in the same source.
After every file in a sync has been processed with zero failures, a deletion sweep removes any document in that source/library that wasn't touched by this run's stamp — it's no longer present upstream. If even one file failed to process, the sweep is skipped entirely for that run, so a partial failure never causes correct documents to be deleted because the run couldn't prove they were actually gone.
Dry-Run Diagnostics
Because a real sync writes to the database, testing filter behavior against a real repository used to mean either accepting real writes or writing a one-off script. Zeeq.Platform.Ingest.Diagnostics provides a permanent way to run the real pipeline — real clone, real parsing, real reads against existing data for accurate move detection — with all writes only logged, never persisted.
This is meant to be run through the zeeq-dotnet-repl skill against a live local server, not called from application code. It's how the worked examples above were produced: a real clone of a real public repository, filtered, walked, and logged, with nothing landing in Postgres afterward.
The Library UI
The Libraries view's create/edit slideover (LibraryFormSlideover.vue) is how an organization actually sets up repository-sourced libraries, rather than calling the endpoints above directly.
Create. An "Import from GitHub" switch reveals a source picker with two tabs: Public (a free-text GitHub URL — creates or reuses a docs_public_sources row keyed by normalized repo URL) and Private (a picker restricted to repositories already configured for the organization's GitHub App installation, since a private clone needs an installation token). Both tabs expose the same include/exclude filter text areas as the manual-library form. Submitting immediately follows up with a call to the same trigger-ingest endpoint the "Sync now" button uses, so the first sync queues without a separate user action — a 409/429 from that follow-up call (already in flight or rate-limited) is swallowed into an informational toast rather than failing the create.
Edit. Once a library exists, the slideover becomes tabbed:
| Tab | Shown when | Contents |
|---|---|---|
| Library | Always | Name, description, repository mappings, and (for a source-backed library) a read-only source summary plus editable include/exclude filters. The repo URL and source kind can never be edited here — see below. |
| Sync status | Source-backed only | Current syncStatus, last/next sync timestamps, quarantine warning if applicable, a "Sync now" button, and a cursor-paginated run history (10 per page, GET /orgs/{orgId}/libraries/{name}/ingest-runs). The slideover polls the library every 3s while a sync is queued or running, and stops once it settles. |
| Delete | Always | Switch-gated ("Enable deletion") plus an exact-name-confirmation input before the delete button enables — mirrors other destructive-action confirmations in the app. Deleting a private-source library removes it and its documents outright; deleting a library that subscribes to a public source only unsubscribes that library — the shared docs_public_documents rows remain for any other organization still subscribed to the same source. |
A source-backed library's repo URL and source kind are permanently fixed at creation. There is no "change the URL" affordance anywhere in the edit form — the intended path to point a library at a different repository is to delete it and create a new one, which also avoids having to reconcile move/rename detection across two different upstream repositories.
The document editor (md-editor-v3) is read-only for any document whose origin resolves to "remote" — which covers every document that came from a sync, both public- and private-sourced. Hand-authored documents in a plain (non-source-backed) library remain fully editable.
Known Gaps
- Public-source union filters (planned, not built). A public source's effective filter should be the union of every subscribing library's filter. Today it only uses the source's own defaults.
- Public visibility re-verification (planned, not built). A public source that goes private upstream should be detected and quarantined before its content is ever confused with a repository Zeeq no longer has permission to read. Not implemented yet.
- Bounded concurrency is coarse. Each source kind has its own message channel with a fixed number of concurrent workers (
noOfPerformers, a compile-time constant matchingIngestSettings.MaxConcurrentPublic/MaxConcurrentPrivate's defaults). If those settings are ever tuned without a matching code change, the two will drift. - A rare failure can strand a source at
running. If the final write that resets a source back toidlefails, nothing else resets it — the scheduler only ever claimsidlesources. Documented as an accepted, low-probability gap rather than fixed, since there's no real production traffic yet to justify the extra machinery.