Git datasources
docs/spec/datasource-paths.md got DataGripe as far as seeing the checkout: you name a directory, it becomes a sidebar section, files open and save back.
This is an engineering document. Specs are written for people building DataGripe: they record what a subsystem does and why the alternatives were rejected, and some of them describe things that are not built yet. If you are using DataGripe, the documentation is the place to be — it is maintained for you and this is not.
Supersedes: the manifest.json half of docs/spec/domains.md
"The manifest" (extends docs/spec/connection-sources.md,
docs/spec/datasource-paths.md, docs/spec/domains.md; shares the
host-filesystem gate with both)
Goal
docs/spec/datasource-paths.md got DataGripe as far as seeing the
checkout: you name a directory, it becomes a sidebar section, files open
and save back. What it cannot do is say where that checkout came from,
and it cannot carry its own configuration — the pairs live in a table in
somebody's workspace, so the second person on the team sets them up
again by hand, from memory, slightly differently.
A git datasource inverts that. The repository is the definition. It
carries a .datagripe/ directory describing the connection, the
directories worth showing, and the domain tagging; DataGripe reads it,
and a teammate who clones the repo gets the same datasource, the same
sidebar sections and the same domains without configuring anything.
The corollary is that DataGripe now has to be honest about git. It runs
the system git binary with your ambient configuration
(docs/spec/domains.md "Committing" already establishes this and the
rules do not change), and it runs it only when you press something.
Editing a file does not commit it. Saving does not push. The left bar
gets a section that tells you what has changed and gives you the three
buttons; nothing behind it happens on a timer.
Non-goals
- Not a git client. Status, commit, push, pull, fetch. No branch
management, no merge conflict resolution, no rebase, no stash, no
history browser, no blame. When git needs a decision DataGripe cannot
represent, it stops and shows you git's own stderr, and you go to a
terminal. The one exception is for callers with no terminal: MCP's
git_pullandgit_pushrebase unpushed commits onto the upstream and abort on any conflict (docs/spec/mcp.md"Tools"). - Nothing automatic. No autocommit, no autopush, no pull on open, no background fetch. Every invocation is a press. This is the single most important sentence in this spec.
- No credential management. Unchanged from
docs/spec/domains.md:GIT_TERMINAL_PROMPT=0, an askpass that fails immediately, andHOME/PATH/SSH_AUTH_SOCKpassed through because that is where working credentials live. A push with no credentials fails with git's message, verbatim. - Not a secret store for the repo.
.datagripe/config.yamlis committed. It names an environment variable; it never contains a password. - Not multi-repo per datasource. One datasource, one work tree. Two repositories are two datasources.
- Not a submodule-aware anything. A submodule is a directory to the file tree and invisible to the status list.
Definitions
| Term | Meaning |
|---|---|
| Git datasource | A datasource whose definition is read from a repository's .datagripe/ directory |
| Work tree | The repository root, as git rev-parse --show-toplevel reports it |
| Clone | DataGripe created the checkout, from a URL, under the repos home |
| Adopted | The user pointed at an existing checkout DataGripe did not create |
| Sync dir | The directory inside the repo the domain export writes into |
A git datasource implements the same ConnectionMetadata contract as a
managed or predefined one, so the explorer, editor and execution paths
never branch on origin — the third value of source is the only
difference they can see.
Design
The .datagripe directory
At the work tree root, committed, hand-editable:
.datagripe/ config.yaml connection identity, fields, branding, paths sync.yaml where the domain export writes, and how (optional) run.yaml commands DataGripe can run (optional) domains.yaml domains, colours and tagged objects (generated)
run.yaml is the one file here DataGripe executes rather than
interprets, and it has its own gate, its own approval and its own
document: docs/spec/repo-commands.md.
Three files rather than one, and the split is by feature rather than by
size. A config that is "really just connection info and branding" stays
readable in a diff and stays stable; a domain dump churns every time
somebody tags a table, and burying the connection under three hundred
lines of object list means every tagging change shows up as a change to
the file that defines the database. New features get new files here
rather than new top-level keys in config.yaml.
Unknown top-level keys are preserved on write and ignored on read, so an older DataGripe does not silently delete a newer one's settings when it rewrites the file. Unknown keys inside a block DataGripe owns are an error, because that is almost always a typo.
Every file starts with version: 1. A file with a version DataGripe
does not know refuses the whole datasource rather than reading the parts
it recognises — a half-understood connection definition points at the
wrong database.
config.yaml
version: 1 datasource: name: wallet-prod adapter: postgres host: db.internal port: 5432 database: wallet username: reader passwordEnv: WALLET_PG_PASSWORD tlsMode: verify-full # Optional. PostgreSQL runtime parameters, sent when the connection # opens, so a teammate who clones this resolves unqualified names the # same way you do. An allowlist — `search_path` and `application_name` — # because an unrecognised one is a FATAL at connect time rather than a # warning. Never a secret; this file is committed. params: search_path: wallet,public readOnly: true showAllSchemas: false branding: # Optional. Colours the datasource breadcrumb and the tab accents so # prod does not look like staging. One of the eight palette slots # domains already use (docs/spec/domains.md "The colour rail"). colour: 3 # Free text under the name in the datasource selector. description: Production wallet database. Read-only credentials. paths: - name: Some Folder path: foo - name: bar path: bar
datasourceispredefinedConnectionSchemaminusidandworkspaces, which are meaningless here: the repository is the identity, and visibility is whoever can see the checkout.pathsis the same(name, directory)pair asdocs/spec/datasource-paths.md, with one difference that matters: the path is relative to the work tree root.fooand./fooare the same directory. An absolute path is refused, and so is any path that climbs out of the root — checked lexically and again withrealpath, by the sameresolveInsidethe file tree already uses. A repository that could name/etcin a committed file would be a remote code path with extra steps.- Two paths with the same
nameare refused for the reason they are refused in the form: the sidebar could not tell the sections apart.
sync.yaml
Optional. Absent means this datasource has no export target configured yet, which is a normal state — the button on the sync tab writes this file when you pick one.
version: 1
sync:
# Relative to the work tree root, like every path in this directory.
dir: domains
includeAccessReports: true
maxDataRows: 10000
sync.dir replaces datasource_export_paths for a git datasource. It
is deliberately not in config.yaml, even though the first sketch of
this feature put it there: it is the one setting here that a second
person might reasonably want set differently, and it belongs with the
feature that reads it.
domains.yaml
Generated by the export and read by the import. It replaces
manifest.json (docs/spec/domains.md "The manifest") for every
datasource, not only git ones — one serialisation format for the
.datagripe directory is worth more than backwards compatibility with a
file that has existed for one phase.
It lives in two places, by necessity. A git datasource keeps it in
.datagripe/domains.yaml, beside the config that defines the
datasource, because that is where a reader looks for what this
repository is. A managed or predefined datasource has no .datagripe/
to put it in, so it goes at the export root, exactly where
manifest.json was. The export root is what the import reads either
way; the writer knows which case it is in and there is one function that
decides.
version: 1
connection:
ref: git:9b1f…
name: wallet-prod
engine: postgres
domains:
- name: auth
colour: 1
description: ""
includeData: false
objects:
- schema: basic_auth
kind: table
name: users
- schema: public
kind: function
name: login(text, text)
The determinism rules from docs/spec/domains.md carry over unchanged
and gain one: YAML is emitted with a fixed serialiser configuration
— block style, two-space indent, no line folding, no anchors or
aliases, keys in the order above rather than alphabetically, and one
trailing newline. Line folding is the trap. A description that grows by
one character must not reflow a paragraph and put a twelve-line diff in
front of somebody.
manifest.json is not read as a fallback. An existing dump is
re-exported once, which produces domains.yaml and deletes
manifest.json in the same run — a prune, which the export already
does for files it no longer generates. Both names are in
OWNED_ROOT_FILES, so a stale copy at the export root goes the same
way when the datasource keeps its own in .datagripe/.
Importing one
Import is its own tab, reached from the datasource menu beside new datasource, and not a block inside the create form. They are different acts: creating asks you for a host and a password, importing asks you for a URL and reads everything else out of the repository. One form holding both made people read the half that did not apply to them.
The import tab does one thing and then gets out of the way: on success it is replaced by the datasource's own edit page, because the next thing anybody wants is to test it and — if it needs one — give it a password.
Two ways in, one destination:
- Clone. Paste an
https://orssh:///scp-style git URL. DataGripe runsgit cloneinto the repos home (below) and reads.datagripe/config.yamlout of the result. - Adopt. Give an absolute path to a checkout that already exists.
git rev-parse --show-toplevelmust succeed and must report a root that contains the path given, and the path given must be the root itself — adopting~/repo/srcand then writing~/repo/.datagripewould be a surprise.
Either way the repository must contain .datagripe/config.yaml. A
directory that does not is refused with the path it looked in, rather
than being adopted as an empty datasource nobody can use.
A clone URL is validated by the same SSRF policy as a connection host
(apps/server/src/security/ssrf.ts), because it is the same thing: a
user-supplied name the server is about to connect to. file:// and
local-path remotes are refused outright — a clone from /etc is not a
feature.
Where clones live
GIT_REPOS_DIR, defaulting to repos/ beside the embedded data
directory — ./data/repos in the repo checkout, and
<OS app-data>/repos under the desktop shell, which is what
apps/desktop/src/main.ts already does for pg/. One directory per
datasource, named <slug>-<first 8 of the datasource id>, so two
repositories called datasource do not collide and the directory name
is still readable.
DataGripe may delete a directory it created, and only one it created —
that is what managed_clone records. Removing an adopted datasource
removes the row and touches nothing on disk.
Secrets
The repository names an environment variable; the server resolves it.
This is passwordEnv from docs/spec/connection-sources.md, and it is
the documented path.
noPassword: true is the other one: this database takes no
credential at all. It exists for the self-contained case — a trust-auth
cluster on loopback that the repository starts for itself
(docs/spec/repo-commands.md), which is what makes datagripe-example
importable with nothing to configure. Explicit rather than inferred from
a missing passwordEnv, because inferring it would turn a typo in a
variable name into a silent attempt to connect with no credential. The
two are alternatives: setting both, or neither, refuses the file.
When the variable is not set, the datasource is listed and not
connectable, with the variable's name in the message. It is not hidden
and it is not an error at load: the person who clones the repo on a
laptop where WALLET_PG_PASSWORD is not exported should see the
datasource and be told exactly which variable to set, not wonder why the
sidebar is empty.
As an escape hatch, the password may be supplied in DataGripe once and
stored encrypted at rest with the existing keyring
(apps/server/src/crypto/keyring.ts), keyed to (workspace, datasource id). It never goes near the repository. An inline password: in
config.yaml is refused, not deprecated: connections.json can get
away with "development only" because it is gitignored, and this file is
the opposite of gitignored.
What the repo owns and what the workspace still owns
| Source | |
|---|---|
| name, adapter, host, port, database, username, tls, readOnly, showAllSchemas | config.yaml |
| branding colour and description | config.yaml |
| path pairs | config.yaml |
| sync dir and export options | sync.yaml |
| domains and tags | the app database; domains.yaml on export/import |
| password | a password stored here, else passwordEnv, else noPassword |
read only, show all schemas |
the repository's value is the default; this project may override either |
| default connection for a document, layout, drafts | the workspace, as before |
On the datasource edit page the connection fields render like a
predefined one — read-only, with "Defined by .datagripe/config.yaml"
and a button that opens that file in the editor. Editing the file and
saving it is how you change what the datasource is, which is the
whole point; the connection list reloads when a .datagripe/ file is
saved or a pull changes one.
The page is not otherwise dead, and this is the difference from
predefined. Three settings are this project's, saved by
git.datasource.set-options (migration 0017) and never written to the
repository:
- A password. Encrypted with the same keyring as every other
connection secret. It wins over
passwordEnv, because storing one is a deliberate act somebody took on this page — usually precisely because the variable was missing or wrong, and falling back to it afterwards would make the field look broken. Clearing it falls back. read onlyandshow all schemas.NULLmeans "whatever the repository says", which is distinguishable from an explicit override of the same value, and the form shows what the repository asks for beside the toggle. Somebody who imported a repository to look at production should be able to keep read-only on without opening a pull request against a repository they may not own. The override reaches the connection, not just the form — aread onlythat did not refuse writes would be a label.
Everything describing what the datasource is — engine, host, port, database, user — stays the repository's. The split is "what this is" versus "how I use it", and it is the same split that already puts the export path and the domain tagging where they are.
Note the reversal against docs/spec/datasource-paths.md: there, paths
were workspace-local configuration about a datasource, precisely so a
read-only predefined connection could carry them. Here the datasource
brings its own, and the repo's list is mirrored into
datasource_paths rather than merged with it — the table becomes a
cache of what the file says, the form cannot edit it, and every reload
rewrites it from the file. Merging would mean the sidebar showed
sections a teammate did not have, from a file that claims to define the
sidebar.
Mirroring rather than special-casing is the whole reason this feature is
small: file.list, file.open, the document origin and the
archive-on-removal behaviour are the Phase 12 machinery, untouched. Rows
are matched by name, so renaming a directory in config.yaml keeps
the section's id and every file already open from it; a row whose name
disappears archives its documents rather than deleting them, exactly as
datasource.set-paths does.
Exporting a config from an existing datasource
On the edit page of any managed or predefined datasource, export
config. It generates the .datagripe/ file set from what the
datasource already has — connection fields, its domainExportPath as
sync.dir when that path is inside the target repo, its
datasource_paths relativised against the target repo, and its domains
as domains.yaml — and writes it into a directory you name.
- The password is never written. The generated
config.yamlcarriespasswordEnv: <NAME>with a name derived from the datasource (WALLET_PROD_PASSWORD), which you are told about in the panel above the button, because a placeholder that looks like a setting is worse than a missing one. - A path that does not sit inside the target repository is written as a comment with the absolute path and a note, not as a broken relative path. Silently dropping it is how a teammate ends up with three of your four sections.
- The generated YAML is shown before it is written, with copy-to- clipboard, because half the time the destination is a repository on a different machine.
- Writing over an existing
.datagripe/config.yamlasks first and shows what differs.
Exporting a config does not convert the datasource. It stays managed or predefined; you get a directory you can commit, and adding it back as a git datasource is a separate, deliberate act.
The repository section
A new left-bar section, present only while a git datasource is the
active one, above its path sections
(docs/spec/datasource-paths.md "What the sidebar shows"):
┌ repository (main) (3) ⟳┐ ← branch, changed count, refresh │ 3 changed · 2 staged │ │ ☑ M foo/blah.sql │ ← staged for the next commit │ ☑ M .datagripe/config.yaml │ │ ☐ ? bar/baz/query22.sql │ │ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ │ │ [ commit… ] [ push 2 ][ pull 0 ]│ ├ Some Folder ──────────────────┤ │ ▾ foo │
The branch, the change count and refresh live in the section header, where they are legible whether or not the section is open. "Which branch am I on" is a question people ask without wanting the file list, and it used to cost a click and two rows of panel. What is left inside is one summary line and the buttons.
The ahead/behind counts ride on push and pull themselves —
push 2, pull 0 — rather than sitting in a pair of arrows above them.
The count belongs to the press that changes it, and ↑2 ↓0 needs its
words said out loud to mean anything anyway.
- The list is
git status --porcelain -uallat the work tree root, not scoped to a sub-path. This is a repository view, and hiding a changed file because it is outside a configured path is how you commit half of something. - Every file is a checkbox and nothing is checked by default. The
commit stages exactly what you ticked —
git add -- <paths>with the selected pathspecs and no-Aanywhere — thencommit -m. The rule fromdocs/spec/domains.mdsurvives in the form that matters: a commit from DataGripe never stages a file the person did not name. Files already staged when the section loaded show as checked and stay checked; unticking one runsgit restore --staged. commit…opens a message field. It is a press, then a second press.pushis separate and always separate, because it is the only button here that leaves the machine.pullisgit pull --ff-only. Anything that would need a merge commit, a rebase, or a conflict resolution refuses with git's own message. DataGripe cannot represent a conflicted work tree and must not pretend to.- A row's status letters are git's, unexplained and unabbreviated:
M,M,??,A,D,R,UU. People who use git read these already, and people who do not are not served by a paraphrase. - Clicking a row opens the file, when a configured path covers it.
The two path vocabularies meet here and nowhere else: git names files
relative to the work tree root, the editor names them relative to a
path pair. A row outside every pair says so and opens nothing, rather
than opening a different file.
git.statuscarries the work tree root for exactly this — without it the client has two vocabularies and no way to relate them. refreshis the only way the list updates, plus after any operation this section ran. Nothing polls. A status call per second across every open workspace is agitprocess per second.- Refresh fetches first, where there is an upstream (
git.fetch→git fetch --quiet, then a status).rev-listcounts against the remote-tracking ref, which is a memory of the last fetch, so a refresh that only ranstatuswould answer "0 behind" from whenever somebody last pulled — a button that says there is nothing to pull when there is. A fetch moves no file and touches no work tree, and it is still a press: nothing fetches on a timer. Git's verdict comes back verbatim like every other command here, so a fetch that failed on credentials says so instead of showing stale counts. With no upstream it is a plain status.
The section is capped at 500 rows; past that it shows the count and a line saying to use a terminal, because a 12,000-file status is not a list anybody is going to tick through.
After a pull
A successful pull can change files that are open in tabs. The machinery
for this already exists — disk_content_hash and the three cases in
docs/spec/datasource-paths.md "When the file moves underneath" — and
this is exactly the git pull case that spec names.
What changes: the server broadcasts repo.changed with the datasource
ref after a pull that moved HEAD, and each client re-runs the open
check for every file-backed document it holds from that datasource.
Documents whose cached content still matches the recorded hash adopt the
new bytes silently. Documents with edits of their own raise the existing
banner. Directory listings for the datasource's paths are invalidated,
and a .datagripe/ file that changed reloads the connection list.
A pull is refused while any file-backed document from that datasource is
dirty and would be touched by the pull. DataGripe knows the changed
file list from git status, and offering to fast-forward over somebody's
unsaved work is not a service.
Storage
Migration 0014:
CREATE TABLE git_datasources ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, -- Absolute path of the work tree root on the host the server runs on. repo_path text NOT NULL, -- The remote it was cloned from; NULL when an existing checkout was -- adopted. Not read back for anything except display: the remote of -- record is whatever `git remote` says now. remote_url text, -- True only when DataGripe created this directory, and therefore the -- only case in which it may delete it. managed_clone boolean NOT NULL DEFAULT false, created_by uuid REFERENCES users (id) ON DELETE SET NULL, created_at timestamptz NOT NULL DEFAULT now(), UNIQUE (workspace_id, repo_path) );
Plus git_datasource_secrets, the escape hatch above: one encrypted
password per datasource, same layout and same keyring as
connection_secrets, cascading off the datasource row so removing the
datasource takes the secret with it.
A pointer, not a copy. Nothing from config.yaml is denormalised into
this table, because then there would be two answers to what the
datasource is and one of them would be stale. The file is read on
workspace.open and cached in memory with the file's mtime and size as
the cache key.
ConnectionMetadata.id for a git datasource is git:<uuid>, so nothing
branches on id shape: managed ids are bare UUIDs, predefined ids are
slugs, git ids carry their prefix. connectionSourceSchema gains
"git".
Domains, tags, gripe dismissals, datasource_roles and query history all
key on connection_ref as text already and need no change.
Actions (WebSocket)
| Action | Role | Notes |
|---|---|---|
git.datasource.add |
owner |
Clone or adopt; talks to a remote, writes the host |
git.datasource.remove |
owner |
Deletes the checkout only when managed_clone |
git.datasource.reload |
editor |
Re-read .datagripe/, bypassing the mtime cache |
git.datasource.set-options |
editor |
The password and the two overrides — this project's settings, like the export path beside them |
git.status |
editor |
Branch, upstream ahead/behind, porcelain rows |
git.stage |
editor |
add -- / restore --staged for named paths |
git.commit |
editor |
Stages the named paths, then commits |
git.pull |
editor |
pull --ff-only |
git.push |
owner |
The only one that leaves the machine |
datasource.export-config |
editor |
Generate, preview, write .datagripe/ |
commit and pull being editor is a deliberate departure from
docs/spec/domains.md, where every git operation is owner. The reason
that rule exists is that domain.git writes a generated tree and pushes
it; an editor who may already open a file, edit it and write it back to
disk is not meaningfully more dangerous for being able to record that in
the local history. push and add stay owner because they reach the
network.
Every invocation keeps the guarantees from docs/spec/domains.md
"Committing" without restatement: Bun.spawn with an argv array and no
shell, a fixed verb list with no user-supplied flags, -- before every
user-supplied value, GIT_TERMINAL_PROMPT=0 with a failing askpass, a
GIT_TIMEOUT_MS kill, verbatim stdout/stderr with the exit code as the
verdict, and an audit line per call. apps/server/src/domains/git.ts
moves to apps/server/src/git/ and grows the new verbs; the domain
export keeps its own root-scoped add.
Configuration
| Variable | Default | Meaning |
|---|---|---|
GIT_ENABLED |
false |
Gates every git feature, including git datasources. DOMAIN_EXPORT_GIT is the pre-rename name and is still honoured |
GIT_REPOS_DIR |
<data dir>/repos |
Where clones go |
GIT_TIMEOUT_MS |
60000 |
Per invocation. DOMAIN_GIT_TIMEOUT_MS is the pre-rename name |
GIT_CLONE_TIMEOUT_MS |
600000 |
Clone separately, because a big repository is not a hung one |
HOST_FS_DISABLED turns git datasources off regardless, the same way it
turns the export and the path tree off: all three are the server reading
and writing a disk the person pressing the button may not own.
Testing
- Round trip. Export a config from a managed datasource into a temporary repo, add that repo as a git datasource, assert the connection fields, the path pairs and the domains match the original, and that no password was written.
- Determinism. Export domains twice against a fixture database and
assert the two
domains.yamlfiles are byte-identical, including after a description is edited to a length that would tempt a folder. - Relative paths.
foo,./fooandfoo/resolve to one directory;/etc,../outsideand a symlink in the repo pointing at/etcare each refused with a named error and list nothing. - Config refusals. Missing
.datagripe/config.yaml, an unknownversion, two paths with the same name, and an inlinepassword:each refuse the datasource and say which file and which key. - Unknown keys. A
config.yamlwith a top-level block DataGripe does not know survives a write from DataGripe unchanged. - Staging. With five modified files, ticking two and committing produces a commit containing exactly those two and leaves the other three modified in the work tree.
- Argv. A commit message of
--amend, and a filename beginning with-, both round-trip as values. - No credentials. A push against a remote with no credentials surfaces git's stderr and a non-zero exit and does not hang until the timeout.
- Pull refusal. A dirty file-backed document whose file the pull would touch refuses the pull and names the file; a dirty document the pull would not touch does not.
- Pull adoption. A file changed only on disk is adopted silently as
a new revision; a file changed on both sides raises the banner. Both
are the existing
file.opencases, asserted through a real pull. - Missing secret. With
passwordEnvnaming an unset variable, the datasource is listed, the connection test fails, and the message names the variable. - Clone safety.
file:///etc, a bare local path, and a URL resolving to a private range with SSRF enabled are each refused before any process is spawned. - Adopted removal. Removing an adopted datasource leaves the
directory on disk; removing a cloned one deletes only its own
directory under
GIT_REPOS_DIR.
What is not built
- Branch switching. The section shows the branch and will not change it. Checking out a branch with open dirty tabs is a whole design.
- Conflict resolution.
--ff-onlyis the position, not a first step toward a merge UI. - Watching the work tree. Consistent with
docs/spec/datasource-paths.md: nothing polls, andrefreshis a button. A watcher across every repository in every workspace is a different feature with a different cost. - Per-user overrides of a repo-defined datasource. If the committed host is wrong for you, the answer today is a second datasource.
.datagripein a subdirectory. It is at the work tree root or it does not exist.
Open questions
- Whether
branding.colourhere and the domain colour palette should be the same eight slots. Reusing them is cheap and might make a prod datasource look like a domain rail. - Whether a git datasource should be able to carry additional workspace-local paths on top of the repo's, for the one person who keeps a scratch directory beside the checkout. Currently no, because merged lists make "what does this file define" unanswerable.
- Whether
sync.yamlshould be able to name more than one sync dir, once a repository holds two datasources. Two datasources in one repository is not supported and this is the first thing that would need to change if it were. - Whether the commit list should offer hunk-level staging. Almost certainly not — that is the point where a terminal is better — but it is the most likely request.
- Whether a managed datasource's
domains.yamlshould also move into a.datagripe/directory at its export root, so there is one answer instead of two. It would mean inventing that directory for a datasource that has no repository, which reads like a promise the feature does not make.
This page as Markdown, for an agent or a diff.