Feature 10: import Git repositories from GitHub / any git source (with UI) #21

Closed
opened 2026-09-04 10:30:26 +00:00 by crueber · 8 comments
Owner

Import Git repositories from GitHub / any git source — implementation plan

Status: PLAN ONLY (no production code written). Read-only exploration of the
worktree; git status unchanged by this plan except for pre-existing dirt
(the tree was already dirty before planning began — see §10).
Module: git.packden.us/crueber/walhub. Laws: AGENTS.md; features:
docs/features/README.md (P1–P9) + 09_rollout.md; git argv:
docs/go/04_git.md; concurrency: docs/go/13_concurrency.md; seams:
docs/go/14_extensibility.md; deviations: DEVIATIONS.md.

0. What exists today (verified in code, not guessed)

  • Repo create: wal.Registry.Create (internal/wal/registry.go:211) does
    PutCreate of manifest.pb (revision:1, head_seq:0, min_seq:0) → 412 maps
    to ErrExists; then git.InitLocalRepo. cmd/walhub/serve.go:556
    (repoRegistry.Create) maps WalErrAlreadyExists → api.ErrExists.
    Auto-create-on-push lives in internal/server/smart.go:145 +
    internal/server/bind_wal.go. access.json defaults are synthesized by the
    identity bootstrap (internal/identity/bootstrap.go: SynthesizeDefault
    • PutCreate, 412 = skip) — import must reuse this, not invent defaults.
  • Classic import exists: cmd/walhub/ops.go:320 runImport implements the
    classic path (source refs via sourceRefs + ref-glob filter; reuse source
    packs as tier-0 entries by trailer checksum; full repack as tier-2 base).
    import --direct is a stub (ops.go:330 → notImplemented), but fully
    spec'd in docs/go/11_config_cli.md:400: verify closure → side files →
    history pack → striped uploads (HEAD-skip existing, marker-file
    resumability, --force after moved target) → ref snapshot + checkpoint →
    manifest CAS (min_seq = seq+1, first_state_at = as_of = now) → bundle
    list (supersede same-strategy + dependents); re-run = no-op; --parallelism
    workers over a bounded channel (11 §6.4).
  • Forks already point at the reuse: docs/features/03 §7 builds forks
    (pull-fork) on the import --direct "already-on-bucket" mode (HEAD-skip
    shared packs, Create fork manifest, 409 on name taken, meta/forks.json
    GC rule). Import-from-URL is the same shape with a network fetch first.
  • Fetching from elsewhere exists: docs/go/04_git.md §11 upstream
    helpers — repair (fetch_objects_as_pack, 500-oid batches,
    git -c fetch.negotiationAlgorithm=noop -c protocol.version=2 fetch --no-tags --no-write-fetch-head --quiet --depth=1 <upstream> <oid>…) and
    follow (fetch_refs, persistent scratch <cache.dir>/follow/<o>/<n>.git
    with alternates, update-ref --stdin staging refs/follow/*,
    git -c fetch.unpackLimit=1 -c transfer.unpackLimit=1 -c fetch.writeCommitGraph=false -c gc.auto=0 -c protocol.version=2 fetch <upstream> +<ref>:refs/follow/<ref>…, for-each-ref readback). Code:
    internal/git/bundle.go:108, internal/maintain/follow.go:24,298,
    internal/maintain/repair.go:70. Credential pattern is fixed: inline
    config-pair helper
    -c credential.helper= -c credential.helper=!f(){ echo username=x-access-token; echo password=$WALGIT_UPSTREAM_TOKEN; };f, token from env var named by
    upstream.token_env, GIT_TERMINAL_PROMPT=0 always.
  • Tasks + SSE are frozen: internal/wal/tasks.go TaskTable.Run(repo, kind) — key "repo/kind", second start JOINS (bounded by joiner ctx),
    Broadcast[T] replay ring 200 / sub cap 16 / lag-tolerant drop,
    Notice/Progress(label,done,total,unit)/log_tail (60), TaskRecord
    frozen shape (docs/go/07_api.md §12.3), recent ring 30, byID janitor 1h,
    Drain() phase-1 cancel. Ops seam: cmd/walhub/serve.go:581 opsTasks
    (Ops/List/Get/Begin/Attach; Begin subscribes then
    Tasks().Run(WithoutCancel…)). Wire: GET …/ops, POST …/ops/{op} →
    SSE attach stream, GET …/tasks, GET …/tasks/{id} (JSON or
    Accept: text/event-stream attach, §12.4 replay-then-live, terminal
    result/error exactly once). Frozen kinds list at 07 §12.3
    (materialize, remote-index, history-pack, compact, bundle, checkpoint, fsck, repair, follow, rev-index, sync, rematerialize, prewarm).
    Extension kinds: maintain.RegisterKind (14 Seam 5). Note: feature
    packages also carry their own tables (internal/pulls/tasks.go) — import
    must use the core wal table, not a second table.
  • Subprocess discipline: 04 §2 shape (exec.CommandContext, GIT_DIR=,
    feeder goroutine + stdout drain + 8 KiB stderr ring + Wait; deadlock
    rule), Pool.Run cap git.max_git_procs (4×GOMAXPROCS), per-repo
    server.max_concurrent_per_repo semaphore in the HTTP layer, timeouts
    ingest 600 s / connectivity 300 s / maintenance 1800 s / follow 900 s
    per batch. Bulk-vs-control-plane separation is load-bearing (13 §4.6,
    §7 incident 2).
  • Secret hygiene (follow it exactly): upstream.token_env is host-only
    (rejected in repo settings — internal/config/settings.go:49;
    internal/api/settings.go:147 strips it, returns bool presence;
    internal/api/gaps3_test.go:225, handlers_test.go:847 pin the leak
    tests). Secrets never live in TOML values (token_env names an env var;
    setup file mode 0600). Webhooks (features/06): secret write-only,
    secret_set: bool, shown once at creation. CI tokens (features/05):
    wct_<id>.<secret>, only sha256 hash stored, revoked retained, scopes
    field, handler-side capability check, frozen Principal untouched.
  • UI + SDK: SolidJS SPA (D-WEB-6), routes in web/src/index.jsx
    (/ Owners, /:owner Repos, /:owner/:name Repo shell + children,
    /setup, /keys, /api), chrome in web/src/App.jsx, SDK submodules
    in web/sdk/src/*.js esbuild-bundled into web/dist/repos.js (dogfood
    rule: every call through the SDK; lane rewrite api/api-browser as in
    web/sdk/src/pulls.js:77; envelope/SSE parsing shared in sdk/src/sse.js).
    Dark mode default, Tailwind v4.

1. Objective + scope

Objective: a user pastes a git URL (GitHub shorthand or any git URL),
picks target owner/name, optionally supplies a token for private sources,
and gets a first-class walhub repo — refs, objects, default access.json
(creator admin), default policy.json, import provenance — with narrated
progress (no silent spinner) and safe re-runs.

IN scope

  • Source: any git clone-able URL (https://, git@…: scp-like SSH,
    ssh://, git://, file:// for tests/fixtures); GitHub owner/repo
    shorthand + full https://github.com/owner/repo[.git] accepted and
    normalized to one canonical URL.
  • Refs: all branches + all tags by default; --ref allowlist / default-branch-only
    option; annotated tags preserved with peel.
  • Object formats: sha1 and sha256 sources (target format follows source;
    mismatch with an existing target = 409, never convert).
  • Private sources via per-request token (HTTPS) — see §3.

OUT of scope (v1, explicitly)

  • LFS content: pointer blobs imported as-is, never smudged; LFS batch
    endpoints untouched. UI states this.
  • Submodule recursion: gitlinks imported as gitlinks; no recursive clone.
  • GitHub PR refs refs/pull/*, Gerrit refs/changes/*: skipped by
    default (opt-in: refs/pull/N/head only — never /merge; see §9).
  • GitHub API integration (issues/PRs/metadata/collaborators): pure-git v1.
    No Octokit, no new dependency (budget law).
  • Shallow/blobless import (--depth, --filter): refused with a plain-text
    message telling the user to do a full import (mirrors the D17 forcing
    precedent, 04 §8.2). Rationale: walhub serves full clones; a filtered
    base breaks connectivity guarantees.
  • Running CI, webhooks-on-import-fanout beyond the normal push events the
    publish path already emits.

New package internal/import (RouteProvider + task kind + CLI glue).
Depends ONLY on seam interfaces + frozen types
(store.Backend, wal.Registry, git.Layer, server auth chain,
identity.Service, config.Config) — never on internal/server or
feature-package internals (law 8). Registration:

  • Seam 1 (routes): implement the in-code seam (server.RouteProvider
    chain / ChainAPI.Handle, both lanes — per the Wave A amendment note in
    14 §14.11; do NOT invent a second router). Top-level twins
    /api/v1/... + /api-browser/v1/... (same handler, 14 §14.12 two-lane
    rule) + discovery endpoints[] entry (07 §12 discovery lists only real
    routes — D-API-2).
  • Seam 5 (task kind): repo-import via maintain.RegisterKind
    (panics on duplicate — pick the name once). Single-flight key
    "<target o/r>,repo-import" (13 §3 "task:" row). Runs on the core
    wal.TaskTable, not a private table.
  • Seam 7 (CLI): extend walhub import (keep --from classic path and
    the --direct spec untouched): walhub import --url URL owner/name [--ref …] [--default-branch-only] [--token-env VAR] [--format sha1|sha256].
    CLI goes through the same publish/CAS path as the server (14 §14.9).

Bucket objects. No WAL kind (closed enum — 14 §14.11.1). One addition to
the frozen overwritable-key list in the same revision (14 §14.11.2):

Key Kind Content
repos/<o>/<r>/meta/import.json Create-once-then-CAS'd provenance (same family as fork.json, 03 §7) {version, source_url (canonical, token scrubbed), source_kind: "github"|"generic"|"file", requested_refs?, imported_at RFC3339, head_shas {ref: sha}, importer, format}

Everything else reuses existing objects: manifest.pb (commit point),
checkpoint.pb ∥ refs.pb, wal/* packs + side files, access.json
(identity bootstrap), policy.json (defaults). Cancel-before-commit leaves
only task-scoped scratch + possibly orphan pack objects (harmless; same
orphan philosophy as 14 §14.10.2 / 05 §6.4 burn).

Git-level approach (never hand-roll object writes).
Task-scoped scratch under <cache.dir>/import/<o>/<r>.<nanos>/ (unique per
attempt; defer os.RemoveAll; never the serving copy — 04 §3.1 pattern):

  1. git clone --mirror <url> <scratch> (exact argv pinned in code + doc;
    --mirror gives all refs + tags; no --depth/--filter accepted).
  2. git for-each-ref --format=%(objectname) %(refname) [...] enumerate;
    apply refmap: drop refs/pull/*, refs/changes/*, refs/review/*
    unless opted in (heads only); drop nothing else.
  3. Ingest through the EXISTING publish path — two legal implementations,
    pick one in implementation (both reuse frozen code):
    (a) hand the mirror dir to the classic runImport machinery (publish
    packs tier-0 + full bitmap'd repack as tier-2 base), or
    (b) index-pack scratch ingest + stock connectivity pipeline
    (rev-list --objects --stdin --not --all | cat-file --batch-check,
    04 §7.1) + WAL publish. (a) is preferred: it is the tested path and
    forks already bless its shape.
  4. Commit point: manifest.pb PutCreate on the target
    (min_seq = seq+1, first_state_at = as_of = now, per the --direct
    spec) — the CAS decides ownership (same arbitration as create: 13 §3
    and forks 03 §7: Create conflict = name taken → 409 with winner URL).
    Then access.json bootstrap (creator = importer admin), default
    policy.json if absent, import.json provenance Create.
  5. Env for every git spawn: GIT_DIR/GIT_TERMINAL_PROMPT=0, credential
    helper as -c pairs (04 §11 exact text), token ONLY via child env
    (never argv), PATH-only inheritance. All spawns in Pool.Run with
    ctx timeouts (clone phase: new import.clone_timeout, default 1800 s).

Concurrency (of this section)

Hazard: import holds no repo locks across subprocesses/store (13 §2 rule 4
— the target may not even exist yet; there is no handle to lock). The task
goes through Registry.Create + the ordinary publish path. Avoidance:
single-flight key dedups duplicate POSTs; manifest Create arbitrates
cross-instance races; scratch dirs unique per attempt.

3. Credential handling (private repos)

Decision: never-stored (v1). The token arrives in the POST body, lives
in task memory only, is injected as child-env (WALGIT_IMPORT_TOKEN_<id>
or reuse of the WALGIT_UPSTREAM_TOKEN pattern — one name, documented)
for the clone spawn, and is never written to the bucket, the TaskRecord
params, logs, or import.json. GET responses carry secret_set: bool
only (webhook precedent, 06 §webhooks table).

Justification (existing hygiene): tokens-in-bucket would create a new
secret-management surface (rotation, revocation, at-rest exposure via
any read path); the codebase's consistent answer is indirection or
ephemerality — token_env names env (never the value), repo settings
reject host secrets, webhook secrets are write-only, CI secrets store
only hashes. Import tokens are user PATs with cross-repo power: persisting
them in per-repo sidecars maximizes blast radius. Host-level static needs
are already covered by upstream.token_env (follow/repair/CLI
--token-env).

  • Scopes: public source → no token. Private GitHub → classic PAT / fine-
    grained token with contents:read on the source repo (document exact
    scope; least privilege). Generic HTTPS → password/token with read.
  • SSH sources: server-side ssh transport is OUT for v1 (no key agent on
    the host; document https + token as the path). file:// allowed only
    when import.allow_file_urls=true (default false outside tests — SSRF/
    local-read guard).
  • URLs with embedded credentials (https://user:pass@…) are refused
    (400, "strip credentials; use the token field") so tokens never land in
    import.json, logs, or the task params.
  • SSRF: server-side fetch is a new egress surface. v1 ships
    import.allow_private_networks=false default (loopback/RFC1918 denied
    with a plain-text 400) + import.url_allowlist (empty = allow public).
    Document; no new dep (stdlib net parse).

4. API endpoints + task SSE attach + progress packets

Auth per P6 (roles read < triage < write < maintain < admin; resolution
access.json → org ownership → principal flags → anonymous). Import needs
create rights on the target namespace: host write/admin flag, or
org owner, or org member with write+ on the target owner scope (identity
01 owns the exact check); anonymous → 401 (real 401 with
WWW-Authenticate: Bearer realm="walgit" so git/clients erase creds —
law 9). Reads of task status: read on the target namespace (task may
exist before the repo does — gate on namespace, not repo).

METHOD + path Auth Request → response Seam
POST /api/v1/repos/imports (+ /api-browser/v1 twin) create-on-namespace {source_url, owner, name, token?, refs?[], default_branch_only?, include_pull_heads?, format?} → 202 {task: TaskRecord, target: "o/r"} + SSE attach stream 1 + 5
GET /api/v1/repos/imports/{id} (+ twin) read-on-namespace → TaskRecord JSON; with Accept: text/event-stream → attach (task packet → replay → live → terminal result/error) 1 + 5
GET /api/v1/repos/imports (+ twin) read-on-namespace ?owner= → {imports:[{id, target, source_url (scrubbed), state, updated_at}], more} (from recent ring + running; paged, no LIST — task records are instance-memory, same caveat as 07 §12.3) 1

Wire rules (07 conventions + 14 §14.12): plain-text errors, [] never
null, RFC3339 UTC, full SHAs, no-store on task starts, ETag/SWR not
applicable (task records mutate by design — same reasoning as checks 05).
Status mapping: 400 bad URL/options, 401 bad credential (upstream auth
failure surfaces as task error, NOT HTTP 401 — the HTTP 401 is only for
walhub auth), 404 unknown task id on this instance, 409 name taken
/ source-mismatch / same-target import running on another host (07 §12.2
cross-host rule: record with hostname + terminal error 409), 422
empty source / no refs after filter / format mismatch, 503 +
Retry-After: 15 on drain interrupt.

Progress packets (law 7 — no silent spinners). Clone phase parses
git clone --progress stderr (Receiving objects: %, Resolving deltas)
into Progress(label="clone", done, total?, unit="objects"|"bytes") +
Notice lines at phase edges (clone start, clone done: N refs, M objects, ingest, verify, publish, refs snapshot, done). Fallback:
indeterminate Notice heartbeat ≥ every 15 s when git emits nothing (a
quiet 60 s clone is the spinner bug reborn). Bars dedup by label in the
200-packet replay buffer (07 §12.4); terminal result {"task", "value": {repo, head_shas}} / error {"status", "message"} exactly once.

5. UI: pages/flows, SDK, theming, gating

Mount: top-level /import route (new Import.jsx), linked from
Owners.jsx (global "Import repository" button) and Repos.jsx (org page,
prefills owner). Rationale: the target repo does not exist yet, so it
cannot live under /:owner/:name/*; this mirrors the top-level forks
route (POST /api/v1/repos/{o}/{r}/forks, 03 §8).

Flow (one page, four states; Solid signals/stores + context only — no new
deps, D-WEB-6):

  1. form: URL field (accepts owner/repo, full GitHub URL, any git URL;
    client normalizes + suggests owner/name, validated against
    ParseRepoId rules client-side: charset/length/no-leading-dot), owner
    picker (only namespaces the principal may create in), name field,
    "private source" toggle → password-style token field (never echoed,
    autocomplete off, excluded from any persisted draft), options
    (default-branch-only checkbox; "include GitHub PR heads" checkbox with
    help text; LFS-as-pointers note; SSH-unsupported note). Submit → SDK
    imports.start.
  2. running: progress bars (clone, ingest, publish) from the SSE
    task stream + scrolling log tail (last 60) + keepalive-safe attach
    (replay-then-live via sdk/src/sse.js; terminal packet ends the view).
  3. done: success card → link to /:owner/:name, head SHAs, "imported
    from at
  4. error: plain-text reason (name taken → link to winner; auth failed →
    "check token scope"; empty source; timeout/size cap) + Retry button
    (re-POST joins-or-restarts per §6 idempotency).

SDK: new submodule web/sdk/src/import.js (start(payload),
get(id), attach(id, onEvent)), group on the default client, registered
in index.js re-exports; esbuild-bundled into repos.js (D-WEB-2);
envelope/SSE parsing reuses sdk/src/sse.js (one parser — 05 precedent).

Theming/gating: existing Tailwind classes, dark-by-default
(dark: variants on every new surface — CI greps this); permission gating
both disables the form (with "request access" hint) and honors server
409/401/403 (never client-only enforcement).

6. Concurrency: single-flight, pools, idempotency, cancel, caps

  • Single-flight: ("o/r", "repo-import") in the core table; second
    POST joins and reuses the outcome (bounded by joiner ctx, 13 §3). Cross-
    host: 409 + owning hostname (07 §12.2 rule).
  • Pool usage: clone + all git spawns under git.Pool.Run
    (max_git_procs); HTTP layer takes server.max_concurrent_per_repo
    for the target name (counts against flood even pre-create); bulk pack
    uploads go through the bulk pool/worker channel, never request
    goroutines (13 §4.6; incident-2 rule).
  • Idempotency: same (target, canonical source URL) re-POST after
    success → 200 {repo, import} no-op (compare import.json.source_url +
    head SHAs; zero pack traffic). Same target + different source → 409
    (delete first / pick another name). Crash mid-task → safe re-POST:
    manifest Create either won (no-op path) or not (fresh attempt; scratch
    unique per attempt, orphan packs inert).
  • Cancellation: task ctx cancel (client disconnect does NOT cancel the
    leader — WithoutCancel as in opsTasks.Begin; explicit cancel via
    drain or a DELETE …/imports/{id} kill-switch) → CommandContext
    SIGKILLs git, scratch RemoveAll, task records terminal error
    (drain: 503 "interrupted…", 07 §12.4).
  • Timeouts/caps (new config, additive per 14 §14.12):
    import.clone_timeout (default 1800 s), import.max_bytes (default =
    server.max_push_bytes, enforced while streaming like ingest §3.2),
    import.max_refs (default 100 000 — prefix-filter cost guard),
    import.max_concurrent (default 2 server-side clones; bounded channel,
    sender-owns-close per 13 §5). Exceeding → terminal error naming the
    key and the fix (same style as max_wants guard, 04 §8.2).

Concurrency (of this plan)

Hazard: two imports to one target interleaving scratch/publish; clone
holding a pool slot forever; pre-create task with no handle to lock.
Avoidance: single-flight + manifest-CAS arbitration + unique scratch +
pool/timeout ctx + no repo locks held (see §2 Concurrency).

7. Performance: budget + why it can't explode + EVIDENCE sketch

Import is a task, never the push/fetch hot path — it adds zero round
trips to push ≤ 5 / warm-refs-1 / checkpoint-4 (sim budgets, 15_testing;
law 6). Per-import store budget (control plane; bulk pack bytes excluded
by definition):

Step Ops
clone 0 (network → scratch)
pack uploads (striped, bulk pool) N packs × (HEAD probe + striped PUTs) — off-hot-path, --parallelism-bounded
manifest PutCreate 1
checkpoint.pb ∥ refs.pb 2 (parallel pair, 13 §4 row)
access.json + policy.json + import.json Creates 3
Total control-plane ~6 + Npacks, all exact-key, zero LIST

Cannot explode: exact-key probes only; ref enumeration is local
(for-each-ref on scratch, capped by max_refs); git concurrency pool-
gated; bulk/control transports split (13 §4.6); no lock held across I/O.

EVIDENCE.md entry sketch (E5 — import): harness
internal/import/importbench_test.go (or internal/devtools/importbench
with the evidence tag precedent) driving the real task path over
memory + filesystem (contract-suite pattern) and the S3 rig when
touched: fixture remote = local file:// mirror repo at two populations
(S: ~200 commits, M: ~5k commits + tags); measure store ops by class
(counting store), wall time, peak scratch bytes, git subprocess count;
assert ops ≈ 6+Npacks at both sizes (flat), time linear in pack bytes only.
Also record one live github.com run (env-gated, manual — same gating as
WALHUB_TEST_S3_ENDPOINT) as a comment, not a CI gate.

8. Acceptance criteria

  • POST /api/v1/repos/imports + browser twin; GET one/list; impl in
    internal/import only (+ registrations); no change to
    internal/{store,wal,git} except (if any) a spec-amended touch listed
    in the commit (09 §4 pattern).
  • Task kind repo-import registered (Seam 5); (target, kind)
    single-flight join verified; SSE attach (replay → live → terminal
    result/error); progress ≥1 packet per phase + 15 s heartbeat floor
    (law 7 — fail the test on a silent 60 s window).
  • Git via subprocess only, exact argv pinned + doc'd (04 §2 shape;
    GIT_TERMINAL_PROMPT=0, -c credential pairs, token via child env,
    Pool.Run, ctx timeouts). No go-git, no new module (budget law).
  • Publish through the existing path (classic/--direct machinery);
    manifest PutCreate is the commit point; min_seq = seq+1 honored.
  • meta/import.json added to the frozen overwritable list in
    docs/go/14_extensibility.md + this plan's doc section in the same
    change
    (law 12); DEVIATIONS.md untouched unless a real deviation.
  • Credentials never stored (bucket/log/record/params grep-clean;
    secret_set bool only); embedded-URL creds refused; SSRF defaults on.
  • Permission gating per P6 (create-on-namespace); anonymous 401 with
    WWW-Authenticate; 409-winner-URL; plain-text errors; []-not-null;
    RFC3339; full SHAs; no-store on starts; discovery endpoints[].
  • CLI walhub import --url works against file:// fixture; --help
    documents all flags; exit codes 0/1/2 per 11 §6.3.
  • UI /import (form → running → done/error), SDK import.js, dark +
    light, server-enforced gating; make test-web green.
  • Coverage ≥ 95% on internal/import (make cover); -race clean;
    stress -count=100 on the single-flight/join test; no skipped tests.
  • e2e with real git: local fixture remote (file://) green in CI;
    one optional live github.com run documented (env-gated).
  • Real-browser pass (per AGENTS ladder §8): /, /import full flow,
    repo page, /setup; console clean; screenshots attached to the change.
  • Sim/budget: import adds no hot-path round trip (make sim if WAL
    touched, else budget table in tests); EVIDENCE E5 entry committed.
  • make fmt && make vet clean; commit message names the doc section +
    appended decisions.

9. Open questions / risks

  1. GitHub API vs pure-git: plan says pure-git v1. If "suggest name/
    default branch before import" is wanted, a client-side (browser→api.
    github.com) prefill keeps server egress pure — no token leaves the
    browser. Server calling GitHub API is rejected for v1 (new auth,
    rate-limit, PII surface).
  2. refs/pull/*: skip by default (importing refs/pull/N/head rewrites
    GitHub's ephemeral namespace into permanent local refs; /merge refs
    are computed, often dangling). Opt-in imports heads only, renamed?
    No — import verbatim or not at all (renaming breaks gh tooling
    expectations). Document.
  3. Default branch: mirror clone preserves HEAD symref; target HEAD
    follows source (else refs/heads/main fallback per 04 §1.2). Unborn-
    HEAD sources → 422.
  4. Object-format mix: source sha256 → target sha256 (git init --bare --object-format=sha256, 04 §1.2). Mixed-format sources don't exist
    from stock git; a lying remote fails connectivity → terminal error.
  5. Size: multi-GB sources pass through max_bytes/clone timeout;
    document "import from a nearby mirror / seed via import --direct"
    for the pathological case (the --direct path stays the bulk loader).
  6. SSRF: server fetches an arbitrary URL — the #1 risk. Mitigations in
    §3 + §6 (deny-private default, allowlist, allow_file_urls=false,
    timeouts). Call out in review; consider a confirming dangerous: true
    flag when the allowlist is empty and the URL is non-GitHub.
  7. Token-in-task-memory: a heap dump sees it (accepted; same as any
    in-flight request credential). Never log at any level (audit: grep
    token|password|secret on the new package in CI).
  8. GC interplay: imported repos are standalone (no fork network), but
    if a later fork feature shares their packs, the meta/forks.json GC
    rule (03 §7) applies unchanged — import writes no fork metadata, so
    nothing to reconcile.
  9. GitHub shorthand ambiguity: owner/repo could collide with a
    generic-host path — resolve strictly as github.com; anything else must
    be a full URL. \.git suffix stripped once (ParseRepoId precedent).

10. Provenance of this plan

Explored files (read-only): AGENTS.md, docs/features/README.md,
docs/features/09_rollout.md, docs/features/03_pull_requests.md §7–8,
docs/features/05_checks_statuses.md (CI-token hygiene),
docs/features/06_notifications.md (webhook secrets),
docs/go/04_git.md, docs/go/07_api.md §12, docs/go/11_config_cli.md
§import, docs/go/13_concurrency.md, docs/go/14_extensibility.md,
DEVIATIONS.md, docs/EVIDENCE.md (method standards),
internal/wal/tasks.go, internal/wal/registry.go:211,
cmd/walhub/serve.go:556 + opsTasks, cmd/walhub/ops.go:320,
internal/git/bundle.go:108, internal/maintain/follow.go,
internal/identity/bootstrap.go, internal/config/settings.go,
internal/api/settings.go, web/src/index.jsx, web/src/App.jsx,
web/sdk/src/pulls.js. No worktree file was written; pre-existing
git status dirt (AGENTS.md, serve.go, EVIDENCE.md, 03, 06, 12, 14,
bind_ssh.go, health.go, router.go, core.js, App.jsx, index.jsx,
Owners.jsx, Repo.jsx, Repos.jsx + untracked .opencode/, pulls.go,
managed.go, internal/pulls/) predates this plan.

# Import Git repositories from GitHub / any git source — implementation plan > Status: PLAN ONLY (no production code written). Read-only exploration of the > worktree; `git status` unchanged by this plan except for pre-existing dirt > (the tree was already dirty before planning began — see §10). > Module: `git.packden.us/crueber/walhub`. Laws: `AGENTS.md`; features: > `docs/features/README.md` (P1–P9) + `09_rollout.md`; git argv: > `docs/go/04_git.md`; concurrency: `docs/go/13_concurrency.md`; seams: > `docs/go/14_extensibility.md`; deviations: `DEVIATIONS.md`. ## 0. What exists today (verified in code, not guessed) - **Repo create:** `wal.Registry.Create` (`internal/wal/registry.go:211`) does `PutCreate` of `manifest.pb` (`revision:1, head_seq:0, min_seq:0`) → 412 maps to `ErrExists`; then `git.InitLocalRepo`. `cmd/walhub/serve.go:556` (`repoRegistry.Create`) maps `WalErrAlreadyExists` → `api.ErrExists`. Auto-create-on-push lives in `internal/server/smart.go:145` + `internal/server/bind_wal.go`. `access.json` defaults are synthesized by the identity bootstrap (`internal/identity/bootstrap.go`: `SynthesizeDefault` + `PutCreate`, 412 = skip) — import must reuse this, not invent defaults. - **Classic import exists:** `cmd/walhub/ops.go:320` `runImport` implements the classic path (source refs via `sourceRefs` + ref-glob filter; reuse source packs as tier-0 entries by trailer checksum; full repack as tier-2 base). **`import --direct` is a stub** (`ops.go:330` → `notImplemented`), but fully spec'd in `docs/go/11_config_cli.md:400`: verify closure → side files → history pack → striped uploads (HEAD-skip existing, marker-file resumability, `--force` after moved target) → ref snapshot + checkpoint → manifest CAS (`min_seq = seq+1`, `first_state_at = as_of = now`) → bundle list (supersede same-strategy + dependents); re-run = no-op; `--parallelism` workers over a bounded channel (11 §6.4). - **Forks already point at the reuse:** `docs/features/03 §7` builds forks (`pull-fork`) on the `import --direct` "already-on-bucket" mode (HEAD-skip shared packs, `Create` fork manifest, 409 on name taken, `meta/forks.json` GC rule). Import-from-URL is the same shape with a network fetch first. - **Fetching from elsewhere exists:** `docs/go/04_git.md §11` upstream helpers — repair (`fetch_objects_as_pack`, 500-oid batches, `git -c fetch.negotiationAlgorithm=noop -c protocol.version=2 fetch --no-tags --no-write-fetch-head --quiet --depth=1 <upstream> <oid>…`) and follow (`fetch_refs`, persistent scratch `<cache.dir>/follow/<o>/<n>.git` with alternates, `update-ref --stdin` staging `refs/follow/*`, `git -c fetch.unpackLimit=1 -c transfer.unpackLimit=1 -c fetch.writeCommitGraph=false -c gc.auto=0 -c protocol.version=2 fetch <upstream> +<ref>:refs/follow/<ref>…`, `for-each-ref` readback). Code: `internal/git/bundle.go:108`, `internal/maintain/follow.go:24,298`, `internal/maintain/repair.go:70`. Credential pattern is fixed: inline config-pair helper `-c credential.helper= -c credential.helper=!f(){ echo username=x-access-token; echo password=$WALGIT_UPSTREAM_TOKEN; };f`, token from env var named by `upstream.token_env`, `GIT_TERMINAL_PROMPT=0` always. - **Tasks + SSE are frozen:** `internal/wal/tasks.go` `TaskTable.Run(repo, kind)` — key `"repo/kind"`, second start JOINS (bounded by joiner ctx), `Broadcast[T]` replay ring 200 / sub cap 16 / lag-tolerant drop, `Notice`/`Progress(label,done,total,unit)`/`log_tail` (60), `TaskRecord` frozen shape (`docs/go/07_api.md §12.3`), recent ring 30, byID janitor 1h, `Drain()` phase-1 cancel. Ops seam: `cmd/walhub/serve.go:581` `opsTasks` (`Ops`/`List`/`Get`/`Begin`/`Attach`; `Begin` subscribes then `Tasks().Run(WithoutCancel…)`). Wire: `GET …/ops`, `POST …/ops/{op}` → SSE attach stream, `GET …/tasks`, `GET …/tasks/{id}` (JSON or `Accept: text/event-stream` attach, §12.4 replay-then-live, terminal `result`/`error` exactly once). Frozen kinds list at 07 §12.3 (`materialize, remote-index, history-pack, compact, bundle, checkpoint, fsck, repair, follow, rev-index, sync, rematerialize, prewarm`). Extension kinds: `maintain.RegisterKind` (14 Seam 5). Note: feature packages also carry their own tables (`internal/pulls/tasks.go`) — import must use the **core wal table**, not a second table. - **Subprocess discipline:** 04 §2 shape (`exec.CommandContext`, `GIT_DIR=`, feeder goroutine + stdout drain + 8 KiB stderr ring + `Wait`; deadlock rule), `Pool.Run` cap `git.max_git_procs` (4×GOMAXPROCS), per-repo `server.max_concurrent_per_repo` semaphore in the HTTP layer, timeouts ingest 600 s / connectivity 300 s / maintenance 1800 s / follow 900 s per batch. Bulk-vs-control-plane separation is load-bearing (13 §4.6, §7 incident 2). - **Secret hygiene (follow it exactly):** `upstream.token_env` is host-only (rejected in repo settings — `internal/config/settings.go:49`; `internal/api/settings.go:147` strips it, returns bool presence; `internal/api/gaps3_test.go:225`, `handlers_test.go:847` pin the leak tests). Secrets never live in TOML values (`token_env` names an env var; setup file mode 0600). Webhooks (features/06): secret write-only, `secret_set: bool`, shown once at creation. CI tokens (features/05): `wct_<id>.<secret>`, only sha256 hash stored, revoked retained, `scopes` field, handler-side capability check, frozen `Principal` untouched. - **UI + SDK:** SolidJS SPA (D-WEB-6), routes in `web/src/index.jsx` (`/` Owners, `/:owner` Repos, `/:owner/:name` Repo shell + children, `/setup`, `/keys`, `/api`), chrome in `web/src/App.jsx`, SDK submodules in `web/sdk/src/*.js` esbuild-bundled into `web/dist/repos.js` (dogfood rule: every call through the SDK; lane rewrite `api`/`api-browser` as in `web/sdk/src/pulls.js:77`; envelope/SSE parsing shared in `sdk/src/sse.js`). Dark mode default, Tailwind v4. ## 1. Objective + scope **Objective:** a user pastes a git URL (GitHub shorthand or any git URL), picks target owner/name, optionally supplies a token for private sources, and gets a first-class walhub repo — refs, objects, default `access.json` (creator admin), default `policy.json`, import provenance — with narrated progress (no silent spinner) and safe re-runs. **IN scope** - Source: any `git clone`-able URL (`https://`, `git@…:` scp-like SSH, `ssh://`, `git://`, `file://` for tests/fixtures); GitHub `owner/repo` shorthand + full `https://github.com/owner/repo[.git]` accepted and normalized to one canonical URL. - Refs: all branches + all tags by default; `--ref` allowlist / default-branch-only option; annotated tags preserved with peel. - Object formats: sha1 and sha256 sources (target format follows source; mismatch with an existing target = 409, never convert). - Private sources via per-request token (HTTPS) — see §3. **OUT of scope (v1, explicitly)** - LFS content: pointer blobs imported as-is, never smudged; LFS batch endpoints untouched. UI states this. - Submodule recursion: gitlinks imported as gitlinks; no recursive clone. - GitHub PR refs `refs/pull/*`, Gerrit `refs/changes/*`: skipped by default (opt-in: `refs/pull/N/head` only — never `/merge`; see §9). - GitHub API integration (issues/PRs/metadata/collaborators): pure-git v1. No Octokit, no new dependency (budget law). - Shallow/blobless import (`--depth`, `--filter`): refused with a plain-text message telling the user to do a full import (mirrors the D17 forcing precedent, 04 §8.2). Rationale: walhub serves full clones; a filtered base breaks connectivity guarantees. - Running CI, webhooks-on-import-fanout beyond the normal push events the publish path already emits. ## 2. Recommended architecture **New package `internal/import` (RouteProvider + task kind + CLI glue).** Depends ONLY on seam interfaces + frozen types (`store.Backend`, `wal.Registry`, `git.Layer`, server auth chain, `identity.Service`, `config.Config`) — never on `internal/server` or feature-package internals (law 8). Registration: - **Seam 1 (routes):** implement the in-code seam (`server.RouteProvider` chain / `ChainAPI.Handle`, both lanes — per the Wave A amendment note in 14 §14.11; do NOT invent a second router). Top-level twins `/api/v1/...` + `/api-browser/v1/...` (same handler, 14 §14.12 two-lane rule) + discovery `endpoints[]` entry (07 §12 discovery lists only real routes — D-API-2). - **Seam 5 (task kind):** `repo-import` via `maintain.RegisterKind` (panics on duplicate — pick the name once). Single-flight key `"<target o/r>,repo-import"` (13 §3 `"task:"` row). Runs on the core `wal.TaskTable`, not a private table. - **Seam 7 (CLI):** extend `walhub import` (keep `--from` classic path and the `--direct` spec untouched): `walhub import --url URL owner/name [--ref …] [--default-branch-only] [--token-env VAR] [--format sha1|sha256]`. CLI goes through the same publish/CAS path as the server (14 §14.9). **Bucket objects.** No WAL kind (closed enum — 14 §14.11.1). One addition to the frozen overwritable-key list **in the same revision** (14 §14.11.2): | Key | Kind | Content | |---|---|---| | `repos/<o>/<r>/meta/import.json` | Create-once-then-CAS'd provenance (same family as `fork.json`, 03 §7) | `{version, source_url (canonical, token scrubbed), source_kind: "github"\|"generic"\|"file", requested_refs?, imported_at RFC3339, head_shas {ref: sha}, importer, format}` | Everything else reuses existing objects: `manifest.pb` (commit point), `checkpoint.pb ∥ refs.pb`, `wal/*` packs + side files, `access.json` (identity bootstrap), `policy.json` (defaults). Cancel-before-commit leaves only task-scoped scratch + possibly orphan pack objects (harmless; same orphan philosophy as 14 §14.10.2 / 05 §6.4 burn). **Git-level approach (never hand-roll object writes).** Task-scoped scratch under `<cache.dir>/import/<o>/<r>.<nanos>/` (unique per attempt; `defer os.RemoveAll`; never the serving copy — 04 §3.1 pattern): 1. `git clone --mirror <url> <scratch>` (exact argv pinned in code + doc; `--mirror` gives all refs + tags; no `--depth`/`--filter` accepted). 2. `git for-each-ref --format=%(objectname) %(refname) [...]` enumerate; apply refmap: drop `refs/pull/*`, `refs/changes/*`, `refs/review/*` unless opted in (heads only); drop nothing else. 3. Ingest through the EXISTING publish path — two legal implementations, pick one in implementation (both reuse frozen code): (a) hand the mirror dir to the classic `runImport` machinery (publish packs tier-0 + full bitmap'd repack as tier-2 base), or (b) `index-pack` scratch ingest + stock connectivity pipeline (`rev-list --objects --stdin --not --all | cat-file --batch-check`, 04 §7.1) + WAL publish. (a) is preferred: it is the tested path and forks already bless its shape. 4. Commit point: `manifest.pb` **`PutCreate`** on the target (`min_seq = seq+1`, `first_state_at = as_of = now`, per the `--direct` spec) — the CAS decides ownership (same arbitration as `create:` 13 §3 and forks 03 §7: Create conflict = name taken → 409 with winner URL). Then `access.json` bootstrap (creator = importer admin), default `policy.json` if absent, `import.json` provenance `Create`. 5. Env for every git spawn: `GIT_DIR`/`GIT_TERMINAL_PROMPT=0`, credential helper as `-c` pairs (04 §11 exact text), token ONLY via child env (never argv), `PATH`-only inheritance. All spawns in `Pool.Run` with ctx timeouts (clone phase: new `import.clone_timeout`, default 1800 s). ### Concurrency (of this section) Hazard: import holds no repo locks across subprocesses/store (13 §2 rule 4 — the target may not even exist yet; there is no handle to lock). The task goes through `Registry.Create` + the ordinary publish path. Avoidance: single-flight key dedups duplicate POSTs; manifest `Create` arbitrates cross-instance races; scratch dirs unique per attempt. ## 3. Credential handling (private repos) **Decision: never-stored (v1).** The token arrives in the POST body, lives in task memory only, is injected as child-env (`WALGIT_IMPORT_TOKEN_<id>` or reuse of the `WALGIT_UPSTREAM_TOKEN` pattern — one name, documented) for the clone spawn, and is never written to the bucket, the TaskRecord params, logs, or `import.json`. `GET` responses carry `secret_set: bool` only (webhook precedent, 06 §webhooks table). **Justification (existing hygiene):** tokens-in-bucket would create a new secret-management surface (rotation, revocation, at-rest exposure via any read path); the codebase's consistent answer is indirection or ephemerality — `token_env` names env (never the value), repo settings reject host secrets, webhook secrets are write-only, CI secrets store only hashes. Import tokens are user PATs with cross-repo power: persisting them in per-repo sidecars maximizes blast radius. Host-level static needs are already covered by `upstream.token_env` (follow/repair/CLI `--token-env`). - Scopes: public source → no token. Private GitHub → classic PAT / fine- grained token with **contents:read** on the source repo (document exact scope; least privilege). Generic HTTPS → password/token with read. - SSH sources: server-side `ssh` transport is OUT for v1 (no key agent on the host; document `https + token` as the path). `file://` allowed only when `import.allow_file_urls=true` (default false outside tests — SSRF/ local-read guard). - URLs with embedded credentials (`https://user:pass@…`) are **refused** (400, "strip credentials; use the token field") so tokens never land in `import.json`, logs, or the task params. - SSRF: server-side fetch is a new egress surface. v1 ships `import.allow_private_networks=false` default (loopback/RFC1918 denied with a plain-text 400) + `import.url_allowlist` (empty = allow public). Document; no new dep (stdlib `net` parse). ## 4. API endpoints + task SSE attach + progress packets Auth per P6 (roles `read < triage < write < maintain < admin`; resolution `access.json` → org ownership → principal flags → anonymous). Import needs **create rights on the target namespace**: host `write`/`admin` flag, or org owner, or org member with `write`+ on the target owner scope (identity 01 owns the exact check); anonymous → 401 (real 401 with `WWW-Authenticate: Bearer realm="walgit"` so git/clients erase creds — law 9). Reads of task status: `read` on the target namespace (task may exist before the repo does — gate on namespace, not repo). | METHOD + path | Auth | Request → response | Seam | |---|---|---|---| | `POST /api/v1/repos/imports` (+ `/api-browser/v1` twin) | create-on-namespace | `{source_url, owner, name, token?, refs?[], default_branch_only?, include_pull_heads?, format?}` → `202 {task: TaskRecord, target: "o/r"}` + SSE attach stream | 1 + 5 | | `GET /api/v1/repos/imports/{id}` (+ twin) | read-on-namespace | → TaskRecord JSON; with `Accept: text/event-stream` → attach (task packet → replay → live → terminal `result`/`error`) | 1 + 5 | | `GET /api/v1/repos/imports` (+ twin) | read-on-namespace | `?owner=` → `{imports:[{id, target, source_url (scrubbed), state, updated_at}], more}` (from `recent` ring + running; paged, no LIST — task records are instance-memory, same caveat as 07 §12.3) | 1 | Wire rules (07 conventions + 14 §14.12): plain-text errors, `[]` never null, RFC3339 UTC, full SHAs, `no-store` on task starts, ETag/SWR not applicable (task records mutate by design — same reasoning as checks 05). Status mapping: `400` bad URL/options, `401` bad credential (upstream auth failure surfaces as task `error`, NOT HTTP 401 — the HTTP 401 is only for walhub auth), `404` unknown task id **on this instance**, `409` name taken / source-mismatch / same-target import running on another host (07 §12.2 cross-host rule: record with `hostname` + terminal `error 409`), `422` empty source / no refs after filter / format mismatch, `503` + `Retry-After: 15` on drain interrupt. **Progress packets (law 7 — no silent spinners).** Clone phase parses `git clone --progress` stderr (`Receiving objects: %`, `Resolving deltas`) into `Progress(label="clone", done, total?, unit="objects"|"bytes")` + `Notice` lines at phase edges (`clone start`, `clone done: N refs, M objects`, `ingest`, `verify`, `publish`, `refs snapshot`, `done`). Fallback: indeterminate `Notice` heartbeat ≥ every 15 s when git emits nothing (a quiet 60 s clone is the spinner bug reborn). Bars dedup by label in the 200-packet replay buffer (07 §12.4); terminal `result {"task", "value": {repo, head_shas}}` / `error {"status", "message"}` exactly once. ## 5. UI: pages/flows, SDK, theming, gating **Mount:** top-level `/import` route (new `Import.jsx`), linked from `Owners.jsx` (global "Import repository" button) and `Repos.jsx` (org page, prefills `owner`). Rationale: the target repo does not exist yet, so it cannot live under `/:owner/:name/*`; this mirrors the top-level forks route (`POST /api/v1/repos/{o}/{r}/forks`, 03 §8). **Flow (one page, four states; Solid signals/stores + context only — no new deps, D-WEB-6):** 1. `form`: URL field (accepts `owner/repo`, full GitHub URL, any git URL; client normalizes + suggests `owner`/`name`, validated against `ParseRepoId` rules client-side: charset/length/no-leading-dot), owner picker (only namespaces the principal may create in), name field, "private source" toggle → password-style token field (never echoed, autocomplete off, excluded from any persisted draft), options (default-branch-only checkbox; "include GitHub PR heads" checkbox with help text; LFS-as-pointers note; SSH-unsupported note). Submit → SDK `imports.start`. 2. `running`: progress bars (`clone`, `ingest`, `publish`) from the SSE task stream + scrolling log tail (last 60) + keepalive-safe attach (replay-then-live via `sdk/src/sse.js`; terminal packet ends the view). 3. `done`: success card → link to `/:owner/:name`, head SHAs, "imported from <scrubbed URL> at <time>". 4. `error`: plain-text reason (name taken → link to winner; auth failed → "check token scope"; empty source; timeout/size cap) + Retry button (re-POST joins-or-restarts per §6 idempotency). **SDK:** new submodule `web/sdk/src/import.js` (`start(payload)`, `get(id)`, `attach(id, onEvent)`), group on the default client, registered in `index.js` re-exports; esbuild-bundled into `repos.js` (D-WEB-2); envelope/SSE parsing reuses `sdk/src/sse.js` (one parser — 05 precedent). **Theming/gating:** existing Tailwind classes, dark-by-default (`dark:` variants on every new surface — CI greps this); permission gating both disables the form (with "request access" hint) and honors server 409/401/403 (never client-only enforcement). ## 6. Concurrency: single-flight, pools, idempotency, cancel, caps - **Single-flight:** `("o/r", "repo-import")` in the core table; second POST joins and reuses the outcome (bounded by joiner ctx, 13 §3). Cross- host: 409 + owning `hostname` (07 §12.2 rule). - **Pool usage:** clone + all git spawns under `git.Pool.Run` (`max_git_procs`); HTTP layer takes `server.max_concurrent_per_repo` for the target name (counts against flood even pre-create); bulk pack uploads go through the bulk pool/worker channel, never request goroutines (13 §4.6; incident-2 rule). - **Idempotency:** same `(target, canonical source URL)` re-POST after success → `200 {repo, import}` no-op (compare `import.json.source_url` + head SHAs; zero pack traffic). Same target + different source → `409` (delete first / pick another name). Crash mid-task → safe re-POST: manifest `Create` either won (no-op path) or not (fresh attempt; scratch unique per attempt, orphan packs inert). - **Cancellation:** task ctx cancel (client disconnect does NOT cancel the leader — `WithoutCancel` as in `opsTasks.Begin`; explicit cancel via drain or a `DELETE …/imports/{id}` kill-switch) → `CommandContext` SIGKILLs git, scratch `RemoveAll`, task records terminal `error` (drain: 503 "interrupted…", 07 §12.4). - **Timeouts/caps (new config, additive per 14 §14.12):** `import.clone_timeout` (default 1800 s), `import.max_bytes` (default = `server.max_push_bytes`, enforced while streaming like ingest §3.2), `import.max_refs` (default 100 000 — prefix-filter cost guard), `import.max_concurrent` (default 2 server-side clones; bounded channel, sender-owns-close per 13 §5). Exceeding → terminal `error` naming the key and the fix (same style as `max_wants` guard, 04 §8.2). ### Concurrency (of this plan) Hazard: two imports to one target interleaving scratch/publish; clone holding a pool slot forever; pre-create task with no handle to lock. Avoidance: single-flight + manifest-CAS arbitration + unique scratch + pool/timeout ctx + no repo locks held (see §2 Concurrency). ## 7. Performance: budget + why it can't explode + EVIDENCE sketch Import is a **task**, never the push/fetch hot path — it adds zero round trips to push ≤ 5 / warm-refs-1 / checkpoint-4 (sim budgets, 15_testing; law 6). Per-import store budget (control plane; bulk pack bytes excluded by definition): | Step | Ops | |---|---| | clone | 0 (network → scratch) | | pack uploads (striped, bulk pool) | N packs × (HEAD probe + striped PUTs) — off-hot-path, `--parallelism`-bounded | | manifest `PutCreate` | 1 | | `checkpoint.pb ∥ refs.pb` | 2 (parallel pair, 13 §4 row) | | `access.json` + `policy.json` + `import.json` Creates | 3 | | Total control-plane | ~6 + Npacks, all exact-key, **zero LIST** | Cannot explode: exact-key probes only; ref enumeration is local (`for-each-ref` on scratch, capped by `max_refs`); git concurrency pool- gated; bulk/control transports split (13 §4.6); no lock held across I/O. **EVIDENCE.md entry sketch (E5 — import):** harness `internal/import/importbench_test.go` (or `internal/devtools/importbench` with the `evidence` tag precedent) driving the real task path over **memory + filesystem** (contract-suite pattern) and the S3 rig when touched: fixture remote = local `file://` mirror repo at two populations (S: ~200 commits, M: ~5k commits + tags); measure store ops by class (counting store), wall time, peak scratch bytes, git subprocess count; assert ops ≈ 6+Npacks at both sizes (flat), time linear in pack bytes only. Also record one live `github.com` run (env-gated, manual — same gating as `WALHUB_TEST_S3_ENDPOINT`) as a comment, not a CI gate. ## 8. Acceptance criteria - [ ] `POST /api/v1/repos/imports` + browser twin; `GET` one/list; impl in `internal/import` only (+ registrations); no change to `internal/{store,wal,git}` except (if any) a spec-amended touch listed in the commit (09 §4 pattern). - [ ] Task kind `repo-import` registered (Seam 5); `(target, kind)` single-flight join verified; SSE attach (replay → live → terminal `result`/`error`); progress ≥1 packet per phase + 15 s heartbeat floor (law 7 — fail the test on a silent 60 s window). - [ ] Git via subprocess only, exact argv pinned + doc'd (04 §2 shape; `GIT_TERMINAL_PROMPT=0`, `-c` credential pairs, token via child env, `Pool.Run`, ctx timeouts). No go-git, no new module (budget law). - [ ] Publish through the existing path (classic/`--direct` machinery); manifest `PutCreate` is the commit point; `min_seq = seq+1` honored. - [ ] `meta/import.json` added to the frozen overwritable list in `docs/go/14_extensibility.md` + this plan's doc section **in the same change** (law 12); `DEVIATIONS.md` untouched unless a real deviation. - [ ] Credentials never stored (bucket/log/record/params grep-clean; `secret_set` bool only); embedded-URL creds refused; SSRF defaults on. - [ ] Permission gating per P6 (create-on-namespace); anonymous 401 with `WWW-Authenticate`; 409-winner-URL; plain-text errors; `[]`-not-null; RFC3339; full SHAs; `no-store` on starts; discovery `endpoints[]`. - [ ] CLI `walhub import --url` works against `file://` fixture; `--help` documents all flags; exit codes 0/1/2 per 11 §6.3. - [ ] UI `/import` (form → running → done/error), SDK `import.js`, dark + light, server-enforced gating; `make test-web` green. - [ ] Coverage ≥ 95% on `internal/import` (`make cover`); `-race` clean; stress `-count=100` on the single-flight/join test; no skipped tests. - [ ] e2e with real git: local fixture remote (file://) green in CI; one optional live `github.com` run documented (env-gated). - [ ] Real-browser pass (per AGENTS ladder §8): `/`, `/import` full flow, repo page, `/setup`; console clean; screenshots attached to the change. - [ ] Sim/budget: import adds no hot-path round trip (`make sim` if WAL touched, else budget table in tests); EVIDENCE E5 entry committed. - [ ] `make fmt && make vet` clean; commit message names the doc section + appended decisions. ## 9. Open questions / risks 1. **GitHub API vs pure-git:** plan says pure-git v1. If "suggest name/ default branch before import" is wanted, a client-side (browser→api. github.com) prefill keeps server egress pure — no token leaves the browser. Server calling GitHub API is rejected for v1 (new auth, rate-limit, PII surface). 2. **`refs/pull/*`:** skip by default (importing `refs/pull/N/head` rewrites GitHub's ephemeral namespace into permanent local refs; `/merge` refs are computed, often dangling). Opt-in imports heads only, renamed? No — import verbatim or not at all (renaming breaks `gh` tooling expectations). Document. 3. **Default branch:** mirror clone preserves `HEAD` symref; target `HEAD` follows source (else `refs/heads/main` fallback per 04 §1.2). Unborn- HEAD sources → 422. 4. **Object-format mix:** source sha256 → target sha256 (`git init --bare --object-format=sha256`, 04 §1.2). Mixed-format sources don't exist from stock git; a lying remote fails connectivity → terminal error. 5. **Size:** multi-GB sources pass through `max_bytes`/clone timeout; document "import from a nearby mirror / seed via `import --direct`" for the pathological case (the `--direct` path stays the bulk loader). 6. **SSRF:** server fetches an arbitrary URL — the #1 risk. Mitigations in §3 + §6 (deny-private default, allowlist, `allow_file_urls=false`, timeouts). Call out in review; consider a confirming `dangerous: true` flag when the allowlist is empty and the URL is non-GitHub. 7. **Token-in-task-memory:** a heap dump sees it (accepted; same as any in-flight request credential). Never log at any level (audit: grep `token|password|secret` on the new package in CI). 8. **GC interplay:** imported repos are standalone (no fork network), but if a later fork feature shares their packs, the `meta/forks.json` GC rule (03 §7) applies unchanged — import writes no fork metadata, so nothing to reconcile. 9. **GitHub shorthand ambiguity:** `owner/repo` could collide with a generic-host path — resolve strictly as github.com; anything else must be a full URL. `\.git` suffix stripped once (ParseRepoId precedent). ## 10. Provenance of this plan Explored files (read-only): `AGENTS.md`, `docs/features/README.md`, `docs/features/09_rollout.md`, `docs/features/03_pull_requests.md` §7–8, `docs/features/05_checks_statuses.md` (CI-token hygiene), `docs/features/06_notifications.md` (webhook secrets), `docs/go/04_git.md`, `docs/go/07_api.md` §12, `docs/go/11_config_cli.md` §import, `docs/go/13_concurrency.md`, `docs/go/14_extensibility.md`, `DEVIATIONS.md`, `docs/EVIDENCE.md` (method standards), `internal/wal/tasks.go`, `internal/wal/registry.go:211`, `cmd/walhub/serve.go:556` + `opsTasks`, `cmd/walhub/ops.go:320`, `internal/git/bundle.go:108`, `internal/maintain/follow.go`, `internal/identity/bootstrap.go`, `internal/config/settings.go`, `internal/api/settings.go`, `web/src/index.jsx`, `web/src/App.jsx`, `web/sdk/src/pulls.js`. No worktree file was written; pre-existing `git status` dirt (AGENTS.md, serve.go, EVIDENCE.md, 03, 06, 12, 14, bind_ssh.go, health.go, router.go, core.js, App.jsx, index.jsx, Owners.jsx, Repo.jsx, Repos.jsx + untracked `.opencode/`, pulls.go, managed.go, `internal/pulls/`) predates this plan.
Author
Owner

Review (1/3) — §0 factual claims checked against CODE (confirmations + inaccuracies). Full text: /tmp/opencode/import-plan-review.md §2.

CONFIRMED ACCURATE (with pins): Registry.Create + PutCreate rev:1/head_seq:0/min_seq:0 (internal/wal/registry.go:211,224-244); WalErrAlreadyExists→api.ErrExists (cmd/walhub/serve.go:556-560); auto-create (internal/server/smart.go:144-145, bind_wal.go:300-303); bootstrap SynthesizeDefault+PutCreate+412=skip (internal/identity/bootstrap.go:47-56, def access.go:95); classic import + --direct stub (cmd/walhub/ops.go:320,330); credential helper text (internal/git/bundle.go:107-108, internal/maintain/follow.go:24,297-298, repair.go:70-75); task ring 200 / sub-cap 16 / recent 30 / janitor 1h / Drain (internal/wal/tasks.go:28-34,240-241,286-308); opsTasks Begin+WithoutCancel (cmd/walhub/serve.go:581,611-624); secret-hygiene pins (internal/config/settings.go:49, internal/api/settings.go:147-150, gaps3_test.go:225, handlers_test.go:847,873-895); UI routes (web/src/index.jsx); lane twin (web/sdk/src/pulls.js:77-79); frozen kinds list matches docs/go/07_api.md:612-613; internal/pulls/tasks.go carries its own taskTable (so "use the core wal table" is the right call).

INACCURACIES (severity + ref):

  • [BLOCKING, §2] internal/import does not compile. import is a Go keyword; package import is a syntax error — verified with the toolchain (gofmt: expected 'IDENT', found 'import'). Rename package AND directory (e.g. internal/repoimport); AGENTS.md naming law needs the amendment anyway. §2 + §8 must be rewritten.
  • [BLOCKING, §2] maintain.RegisterKind doesn't exist in code — it's a doc sketch (docs/go/14_extensibility.md:301). The code seam is wal.TaskTable.Run (internal/wal/tasks.go:180) + maintain.TaskRunner (internal/maintain/units.go:82-86) + the opFn switch (cmd/walhub/serve.go:687). Rewrite the Seam-5 registration recipe in code terms.
  • [SHOULD-FIX, §0/§6] TaskTable.Run(repo, kind) shorthand vs actual Run(ctx, repo, kind, params, fn) (tasks.go:180); key is repo+"/"+kind (tasks.go:181) but §6 writes "<target>,repo-import" (that's the 13 §3 Group-key sketch, not the TaskTable key). Pick one spelling.
  • [NIT, §2/§8] "14 §14.11.1/§14.11.2" don't exist — cite 14 §14.11 rules 1 (WAL kinds) / 2 (overwritable list). The Wave A amendment lives in 14's Decisions bullets, not §14.11; the in-code seam is server.RouteProvider/ExtraRoutes/ChainAPI (internal/server/bind_api.go:18,53,72) with methods Serve/Handle — not "ChainAPI.Handle".
  • [NIT, §0] sdk/src/sse.js → web/sdk/src/sse.js.
  • [NIT, §5] Forks analogy inverted: POST /api/v1/repos/{o}/{r}/forks (03 §7) is repo-scoped under an EXISTING repo; imports POST is top-level precisely because the target is absent. Rationale right, "mirrors" wrong.
Review (1/3) — §0 factual claims checked against CODE (confirmations + inaccuracies). Full text: /tmp/opencode/import-plan-review.md §2. CONFIRMED ACCURATE (with pins): Registry.Create + PutCreate rev:1/head_seq:0/min_seq:0 (internal/wal/registry.go:211,224-244); WalErrAlreadyExists→api.ErrExists (cmd/walhub/serve.go:556-560); auto-create (internal/server/smart.go:144-145, bind_wal.go:300-303); bootstrap SynthesizeDefault+PutCreate+412=skip (internal/identity/bootstrap.go:47-56, def access.go:95); classic import + --direct stub (cmd/walhub/ops.go:320,330); credential helper text (internal/git/bundle.go:107-108, internal/maintain/follow.go:24,297-298, repair.go:70-75); task ring 200 / sub-cap 16 / recent 30 / janitor 1h / Drain (internal/wal/tasks.go:28-34,240-241,286-308); opsTasks Begin+WithoutCancel (cmd/walhub/serve.go:581,611-624); secret-hygiene pins (internal/config/settings.go:49, internal/api/settings.go:147-150, gaps3_test.go:225, handlers_test.go:847,873-895); UI routes (web/src/index.jsx); lane twin (web/sdk/src/pulls.js:77-79); frozen kinds list matches docs/go/07_api.md:612-613; internal/pulls/tasks.go carries its own taskTable (so "use the core wal table" is the right call). INACCURACIES (severity + ref): - [BLOCKING, §2] `internal/import` does not compile. `import` is a Go keyword; `package import` is a syntax error — verified with the toolchain (`gofmt: expected 'IDENT', found 'import'`). Rename package AND directory (e.g. `internal/repoimport`); AGENTS.md naming law needs the amendment anyway. §2 + §8 must be rewritten. - [BLOCKING, §2] `maintain.RegisterKind` doesn't exist in code — it's a doc sketch (docs/go/14_extensibility.md:301). The code seam is wal.TaskTable.Run (internal/wal/tasks.go:180) + maintain.TaskRunner (internal/maintain/units.go:82-86) + the opFn switch (cmd/walhub/serve.go:687). Rewrite the Seam-5 registration recipe in code terms. - [SHOULD-FIX, §0/§6] `TaskTable.Run(repo, kind)` shorthand vs actual `Run(ctx, repo, kind, params, fn)` (tasks.go:180); key is `repo+"/"+kind` (tasks.go:181) but §6 writes `"<target>,repo-import"` (that's the 13 §3 Group-key sketch, not the TaskTable key). Pick one spelling. - [NIT, §2/§8] "14 §14.11.1/§14.11.2" don't exist — cite 14 §14.11 rules 1 (WAL kinds) / 2 (overwritable list). The Wave A amendment lives in 14's Decisions bullets, not §14.11; the in-code seam is server.RouteProvider/ExtraRoutes/ChainAPI (internal/server/bind_api.go:18,53,72) with methods Serve/Handle — not "ChainAPI.Handle". - [NIT, §0] `sdk/src/sse.js` → `web/sdk/src/sse.js`. - [NIT, §5] Forks analogy inverted: POST /api/v1/repos/{o}/{r}/forks (03 §7) is repo-scoped under an EXISTING repo; imports POST is top-level precisely because the target is absent. Rationale right, "mirrors" wrong.
Author
Owner

Review (2/3) — design stress-test findings. Full text: /tmp/opencode/import-plan-review.md §1+§3.

BLOCKING (plan must change):

  • B2 (§6): single-flight join ignores params. TaskTable keys on repo+"/"+kind only and joiners reuse the outcome (internal/wal/tasks.go:181-194). A second POST with a DIFFERENT source/options to the same target joins the running import and gets the wrong outcome — contradicts §6's "different source → 409". Fix: params-aware join (join iff canonical source URL + refs/options/format match, else 409), compared in the handler before Run.
  • B3 (§6): manifest-exists ∧ import.json-missing is undefined. Crash between manifest PutCreate and import.json Create (or a target created by PUT /api / auto-create-on-push) leaves §6's no-op-vs-409 rules ambiguous. Define it — recommended: 409 (delete-and-retry) unless import.json matches; never adopt a foreign manifest.
  • B4 (§4): pre-create tasks have no home. opsTasks.Begin opens the repo first (cmd/walhub/serve.go:612) and subscribes to the per-handle broadcast (h.Progress()); the import target doesn't exist yet, and List(repo) is per-repo (internal/wal/tasks.go:270). Specify which TaskTable, which broadcast a pre-create task publishes to, and how Attach works before Create. The ?owner= list endpoint doesn't fit this surface (see 3/3: cut it).
  • B5 (§4/§6): DELETE kill-switch needs a core internal/wal API that doesn't exist (Run/Get/List/Drain only — no cancel-by-ID). That's a frozen-code touch outside docs/features/09_rollout.md §4's three touch points. Cut DELETE for v1 (drain-cancel suffices).
  • (B1/B6 in comment 1/3: package name + RegisterKind.)

SHOULD-FIX: S1 (§6) max_bytes unenforceable during clone (git writes scratch directly, no feeder) → post-clone du gate + publish-time gate. S2 (§3) TaskRecord.Params is persisted (tasks.go:206) → forbid token/raw URL there; scrub progress packets + terminal errors (git stderr echoes URLs); extend the grep-clean gate. S3 (§2) per-task env name needs dynamic -c helper argv (it hardcodes $WALGIT_UPSTREAM_TOKEN). S4 (§2) refmap undecided: refs/notes/, refs/replace/ (replace rewrites object identity — default-drop), refs/meta/*, keep-around; empty source 422 vs create-empty. S5 (§3) SSRF residuals: DNS TOCTOU + redirect following (allowlist must cover final URL; token helper host-pinned); keep the dangerous:true idea. S6 (§4) order: auth → authorize namespace → THEN join-or-start; joins must not leak cross-principal task existence. S7 (§0 vs §2) SynthesizeDefault(owner) binds user:, not the importer — import must write importer-admin explicitly via the identity service. S8 (§2) name short timeouts for for-each-ref/verify/repack, not just clone_timeout. S9 (§6) max_concurrent_per_repo "for the target name" has no handle pre-create — name-keyed gate or drop; keep import.max_concurrent=2. S10 (§7) "≈6+Npacks" needs a pinned single-pack fixture or a range, else unmeasurable. S11 (§1) LFS: add a server Notice + docs line, not just UI text. S12 (§8/09 §4) enumerate the touch list (server reg = item 3 allowed; additive [import] config; same-change doc amendments).

Review (2/3) — design stress-test findings. Full text: /tmp/opencode/import-plan-review.md §1+§3. BLOCKING (plan must change): - B2 (§6): single-flight join ignores params. TaskTable keys on repo+"/"+kind only and joiners reuse the outcome (internal/wal/tasks.go:181-194). A second POST with a DIFFERENT source/options to the same target joins the running import and gets the wrong outcome — contradicts §6's "different source → 409". Fix: params-aware join (join iff canonical source URL + refs/options/format match, else 409), compared in the handler before Run. - B3 (§6): manifest-exists ∧ import.json-missing is undefined. Crash between manifest PutCreate and import.json Create (or a target created by PUT /api / auto-create-on-push) leaves §6's no-op-vs-409 rules ambiguous. Define it — recommended: 409 (delete-and-retry) unless import.json matches; never adopt a foreign manifest. - B4 (§4): pre-create tasks have no home. opsTasks.Begin opens the repo first (cmd/walhub/serve.go:612) and subscribes to the per-handle broadcast (h.Progress()); the import target doesn't exist yet, and List(repo) is per-repo (internal/wal/tasks.go:270). Specify which TaskTable, which broadcast a pre-create task publishes to, and how Attach works before Create. The ?owner= list endpoint doesn't fit this surface (see 3/3: cut it). - B5 (§4/§6): DELETE kill-switch needs a core internal/wal API that doesn't exist (Run/Get/List/Drain only — no cancel-by-ID). That's a frozen-code touch outside docs/features/09_rollout.md §4's three touch points. Cut DELETE for v1 (drain-cancel suffices). - (B1/B6 in comment 1/3: package name + RegisterKind.) SHOULD-FIX: S1 (§6) max_bytes unenforceable during clone (git writes scratch directly, no feeder) → post-clone du gate + publish-time gate. S2 (§3) TaskRecord.Params is persisted (tasks.go:206) → forbid token/raw URL there; scrub progress packets + terminal errors (git stderr echoes URLs); extend the grep-clean gate. S3 (§2) per-task env name needs dynamic -c helper argv (it hardcodes $WALGIT_UPSTREAM_TOKEN). S4 (§2) refmap undecided: refs/notes/*, refs/replace/* (replace rewrites object identity — default-drop), refs/meta/*, keep-around; empty source 422 vs create-empty. S5 (§3) SSRF residuals: DNS TOCTOU + redirect following (allowlist must cover final URL; token helper host-pinned); keep the dangerous:true idea. S6 (§4) order: auth → authorize namespace → THEN join-or-start; joins must not leak cross-principal task existence. S7 (§0 vs §2) SynthesizeDefault(owner) binds user:<owner>, not the importer — import must write importer-admin explicitly via the identity service. S8 (§2) name short timeouts for for-each-ref/verify/repack, not just clone_timeout. S9 (§6) max_concurrent_per_repo "for the target name" has no handle pre-create — name-keyed gate or drop; keep import.max_concurrent=2. S10 (§7) "≈6+Npacks" needs a pinned single-pack fixture or a range, else unmeasurable. S11 (§1) LFS: add a server Notice + docs line, not just UI text. S12 (§8/09 §4) enumerate the touch list (server reg = item 3 allowed; additive [import] config; same-change doc amendments).
Author
Owner

Review (3/3) — scope judgment + verdict. Full text: /tmp/opencode/import-plan-review.md §4.

SCOPE: pure-git v1 (no GitHub API) is the RIGHT call — keeps the dependency budget (AGENTS.md law 1), avoids a new auth/PII/rate-limit surface; browser-side prefill (§9.1) is a clean compromise. Keep. LFS-as-pointers + no-submodule-recursion + PR-ref default-skip + no-shallow (mirrors the D17 precedent, 04 §8.2) are all correct v1 exclusions.
CUT for v1: (a) GET /repos/imports list — pagination over instance-memory, cross-namespace records (see B4); POST + GET-one suffice. (b) DELETE imports/{id} (see B5). KEEP the include_pull_heads flag (cheap, default-off) and format handling. MISSING for shippable: B2/B3/B4 decisions, the S4 refmap table, S2 hygiene checklist, S5 redirect/DNS note, S10 fixture pinning.

VERDICT: PROCEED WITH FIXES. Architecture direction is sound — existing publish path, manifest-CAS commit point, never-stored tokens, no new WAL kind, zero hot-path round trips. But B1–B6 (package name; RegisterKind; params-aware join; manifest-without-import.json state machine; pre-create task home; DELETE cancel) MUST change in the plan before implementation; S1–S12 addressed or explicitly deferred with reasons.

Review (3/3) — scope judgment + verdict. Full text: /tmp/opencode/import-plan-review.md §4. SCOPE: pure-git v1 (no GitHub API) is the RIGHT call — keeps the dependency budget (AGENTS.md law 1), avoids a new auth/PII/rate-limit surface; browser-side prefill (§9.1) is a clean compromise. Keep. LFS-as-pointers + no-submodule-recursion + PR-ref default-skip + no-shallow (mirrors the D17 precedent, 04 §8.2) are all correct v1 exclusions. CUT for v1: (a) GET /repos/imports list — pagination over instance-memory, cross-namespace records (see B4); POST + GET-one suffice. (b) DELETE imports/{id} (see B5). KEEP the include_pull_heads flag (cheap, default-off) and format handling. MISSING for shippable: B2/B3/B4 decisions, the S4 refmap table, S2 hygiene checklist, S5 redirect/DNS note, S10 fixture pinning. VERDICT: PROCEED WITH FIXES. Architecture direction is sound — existing publish path, manifest-CAS commit point, never-stored tokens, no new WAL kind, zero hot-path round trips. But B1–B6 (package name; RegisterKind; params-aware join; manifest-without-import.json state machine; pre-create task home; DELETE cancel) MUST change in the plan before implementation; S1–S12 addressed or explicitly deferred with reasons.
Author
Owner

Plan revision R1 (incorporates review comments 318–320 — all B-items blocking, all S-items)

Blocking fixes (normative for implementation)

  • B1 — package renamed to internal/repoimport (dir + package identical; import is a Go keyword). All plan references to internal/import now mean internal/repoimport; coverage gate applies to it. Naming-law doc amendment included in the change.
  • B2 — params-aware join. Handler flow is authenticate → authorize namespace → load any running task for the target key → compare canonical source URL + refs/options/format: match → join (bounded by joiner ctx); mismatch → 409 naming the running import. Comparison lives in the handler BEFORE TaskTable.Run. A join never leaks another principal's task: the 409/join decision requires the same read-on-namespace auth, and joined outcome contains no secrets (S2).
  • B3 — manifest-present ∧ import.json-absent → 409 ("target exists but was not created by import; delete and retry, or pick another name"), unless import.json exists and matches canonical source (→ idempotent no-op path). Never silently adopt a foreign manifest (covers PUT-created and auto-create-on-push targets).
  • B4 — pre-create task home. The task starts on the Registry-level core wal.TaskTable (exists without a handle) under key <target>/repo-import. Progress/attach is served from the table-level replay ring keyed by task id (GET /api/v1/repos/imports/{id} with Accept: text/event-stream, 07 §12.4 attach semantics) — NOT the per-handle broadcast. After manifest Create, later attaches may use the handle; the id-keyed path always works.
  • B5 — DELETE kill-switch CUT for v1. No per-task cancel API exists (Run/Get/List/Drain only) and adding one is a frozen-code touch outside 09 §4. Drain-cancel is the v1 cancellation story. §4 table and §6 drop the DELETE endpoint.
  • B6 — registration in code terms. No maintain.RegisterKind (doc sketch only). Recipe: kind string "repo-import" run through the core wal.TaskTable via an opsTasks-style wiring in cmd/walhub (Begin subscribes table-level replay, then Tasks().Run(WithoutCancel…)), plus the opFn-switch-adjacent dispatch entry. Panic-on-duplicate-kind behavior preserved by using the single kind constant.

Should-fix adoptions (all S-items)

  • S1: import.max_bytes enforced as post-clone scratch du gate + publish-time pack gate (git writes scratch directly; no streaming enforcement possible). Terminal error names the key and fix.
  • S2: TaskRecord.Params carries scrubbed canonical URL + options only; progress/log_tail packets and terminal errors scrubbed (git stderr echoes URLs); grep-clean acceptance covers bucket, logs, record params, packets, errors.
  • S3: credential -c argv built dynamically per spawn (per-task env name); static copy-paste forbidden.
  • S4 refmap (decided): import branches + tags; drop refs/pull/*, refs/changes/*, refs/review/* (opt-in heads-only flag kept), refs/notes/* (default drop, flag to keep), refs/replace/* (always drop — rewrites object identity), refs/meta/*, refs/keep-around/* (drop). Empty source (no refs after filter, incl. empty GitHub repos) → 422 "source has no importable refs".
  • S5: residuals named — DNS TOCTOU (check-time vs clone-time) and redirect following (allowlist evaluated against final URL; token helper host-pinned to the original host so redirects can't harvest it). Keep the dangerous: true confirm flag for empty-allowlist non-GitHub URLs.
  • S6: auth order normative — authenticate → authorize namespace → join-or-start (B2). Join path re-checks read-on-namespace.
  • S7: importer-admin written explicitly via the identity service at import commit; SynthesizeDefault stays the backstop only.
  • S8: short ctx timeouts named for non-clone spawns too (for-each-ref, verify pipeline, repack tail) — import.for_each_ref_timeout etc. or one import.git_timeout (default 300 s); clone_timeout 1800 s stays.
  • S9: drop the max_concurrent_per_repo-for-target claim (no handle pre-create, cf. B4); keep import.max_concurrent = 2 (bounded channel, sender-owns-close).
  • S10: EVIDENCE fixture pinned to a single-pack layout; assert an ops RANGE and flatness across S/M populations, not exact N.
  • S11: LFS-as-pointers gets a server-side terminal Notice + docs line + UI note (clones work, checkouts yield pointer text).
  • S12 touch list (closed): server registration (09 §4 item 3, allowed), additive [import] config (14 §14.12), overwritable-list (meta/import.json) + discovery amendments same-change (law 12). No internal/wal touch (B5 cut).

Scope cuts for v1 (per review verdict)

  • CUT: GET /api/v1/repos/imports list (instance-memory pagination across namespaces); CUT: DELETE …/imports/{id} (B5). Ship: POST start + GET one (+ SSE attach). KEPT: include_pull_heads flag (default off), format handling, browser-lane twins, endpoints[] discovery.

All section references above are to the original plan (issue #21 body). Unchanged plan text stands except where R1 overrides it; on conflict R1 wins.

# Plan revision R1 (incorporates review comments 318–320 — all B-items blocking, all S-items) ## Blocking fixes (normative for implementation) - **B1 — package renamed to `internal/repoimport`** (dir + package identical; `import` is a Go keyword). All plan references to `internal/import` now mean `internal/repoimport`; coverage gate applies to it. Naming-law doc amendment included in the change. - **B2 — params-aware join.** Handler flow is authenticate → authorize namespace → load any running task for the target key → compare canonical source URL + refs/options/format: match → join (bounded by joiner ctx); mismatch → **409** naming the running import. Comparison lives in the handler BEFORE `TaskTable.Run`. A join never leaks another principal's task: the 409/join decision requires the same read-on-namespace auth, and joined outcome contains no secrets (S2). - **B3 — manifest-present ∧ import.json-absent → 409** ("target exists but was not created by import; delete and retry, or pick another name"), unless `import.json` exists and matches canonical source (→ idempotent no-op path). Never silently adopt a foreign manifest (covers PUT-created and auto-create-on-push targets). - **B4 — pre-create task home.** The task starts on the **Registry-level core `wal.TaskTable`** (exists without a handle) under key `<target>/repo-import`. Progress/attach is served from the **table-level replay ring keyed by task id** (`GET /api/v1/repos/imports/{id}` with `Accept: text/event-stream`, 07 §12.4 attach semantics) — NOT the per-handle broadcast. After manifest Create, later attaches may use the handle; the id-keyed path always works. - **B5 — DELETE kill-switch CUT for v1.** No per-task cancel API exists (Run/Get/List/Drain only) and adding one is a frozen-code touch outside 09 §4. Drain-cancel is the v1 cancellation story. §4 table and §6 drop the DELETE endpoint. - **B6 — registration in code terms.** No `maintain.RegisterKind` (doc sketch only). Recipe: kind string `"repo-import"` run through the core `wal.TaskTable` via an `opsTasks`-style wiring in `cmd/walhub` (Begin subscribes table-level replay, then `Tasks().Run(WithoutCancel…)`), plus the `opFn`-switch-adjacent dispatch entry. Panic-on-duplicate-kind behavior preserved by using the single kind constant. ## Should-fix adoptions (all S-items) - **S1:** `import.max_bytes` enforced as post-clone scratch `du` gate + publish-time pack gate (git writes scratch directly; no streaming enforcement possible). Terminal error names the key and fix. - **S2:** `TaskRecord.Params` carries scrubbed canonical URL + options only; progress/log_tail packets and terminal errors scrubbed (git stderr echoes URLs); grep-clean acceptance covers bucket, logs, record params, packets, errors. - **S3:** credential `-c` argv built dynamically per spawn (per-task env name); static copy-paste forbidden. - **S4 refmap (decided):** import branches + tags; drop `refs/pull/*`, `refs/changes/*`, `refs/review/*` (opt-in heads-only flag kept), `refs/notes/*` (default drop, flag to keep), `refs/replace/*` (always drop — rewrites object identity), `refs/meta/*`, `refs/keep-around/*` (drop). Empty source (no refs after filter, incl. empty GitHub repos) → **422** "source has no importable refs". - **S5:** residuals named — DNS TOCTOU (check-time vs clone-time) and redirect following (allowlist evaluated against final URL; token helper host-pinned to the original host so redirects can't harvest it). Keep the `dangerous: true` confirm flag for empty-allowlist non-GitHub URLs. - **S6:** auth order normative — authenticate → authorize namespace → join-or-start (B2). Join path re-checks read-on-namespace. - **S7:** importer-admin written explicitly via the identity service at import commit; `SynthesizeDefault` stays the backstop only. - **S8:** short ctx timeouts named for non-clone spawns too (`for-each-ref`, verify pipeline, repack tail) — `import.for_each_ref_timeout` etc. or one `import.git_timeout` (default 300 s); `clone_timeout` 1800 s stays. - **S9:** drop the `max_concurrent_per_repo`-for-target claim (no handle pre-create, cf. B4); keep `import.max_concurrent` = 2 (bounded channel, sender-owns-close). - **S10:** EVIDENCE fixture pinned to a single-pack layout; assert an ops RANGE and flatness across S/M populations, not exact N. - **S11:** LFS-as-pointers gets a server-side terminal `Notice` + docs line + UI note (clones work, checkouts yield pointer text). - **S12 touch list (closed):** server registration (09 §4 item 3, allowed), additive `[import]` config (14 §14.12), overwritable-list (`meta/import.json`) + discovery amendments same-change (law 12). No `internal/wal` touch (B5 cut). ## Scope cuts for v1 (per review verdict) - CUT: `GET /api/v1/repos/imports` list (instance-memory pagination across namespaces); CUT: `DELETE …/imports/{id}` (B5). Ship: `POST` start + `GET` one (+ SSE attach). KEPT: `include_pull_heads` flag (default off), `format` handling, browser-lane twins, `endpoints[]` discovery. All section references above are to the original plan (issue #21 body). Unchanged plan text stands except where R1 overrides it; on conflict R1 wins.
Author
Owner

Feature 10 implementation starting on branch feat/repo-import (worktree /tmp/walhub-repoimport, from origin/main). Plan + R1 normative; building internal/repoimport, [import] config, CLI --url, /import UI + SDK, docs/features/10, EVIDENCE entry.

Feature 10 implementation starting on branch feat/repo-import (worktree /tmp/walhub-repoimport, from origin/main). Plan + R1 normative; building internal/repoimport, [import] config, CLI --url, /import UI + SDK, docs/features/10, EVIDENCE entry.
Author
Owner

Feature 10 implementation ready for review: PR #22 (#22) on branch feat/repo-import — docs/features/10 plus the full implementation (internal/repoimport, [import] config, CLI --url, /import UI + SDK, E11). Do NOT merge (review requested).

Feature 10 implementation ready for review: PR #22 (https://git.packden.us/crueber/walhub/pulls/22) on branch feat/repo-import — docs/features/10 plus the full implementation (internal/repoimport, [import] config, CLI --url, /import UI + SDK, E11). Do NOT merge (review requested).
Author
Owner

PR #22 review (Feature 10 import, feat/repo-import @ 83d0cc1) — findings + fixes pushed (now 3bff123)

Review axes all checked against code. Pre-fix verification (worktree /tmp/walhub-repoimport): gofmt clean, go vet clean, go test -race ./internal/repoimport/... PASS (24s), coverage 95.6% (gate 95%), node --test web/test/unit/sdk-import.test.js 6/6 PASS, live e2e from file:// fixture: 202 start → SSE replay→live→result exactly once → 200 no-op re-POST → git clone serves commit+tag; 409/400 matrix (foreign source, embedded creds, non-allowlisted host, unknown key) all per spec. internal/api + internal/config suites green.

Spec conformance (confirmed, no change needed)

  • Budget: no go.mod/go.sum/npm changes; feature imports core only (internal/git,store,wal,identity,server/auth), core never imports repoimport (grep-clean). B1 package name, B6 kind registration (repoimport.RegisterKind + api.RegisterExposed, law 12 same-change) correct.
  • Git argv matches new 04 §12 pins verbatim (clone --mirror --progress --, for-each-ref format, rev-parse, conditional index-pack <pack>, show HEAD:.gitattributes); token only via per-task child env (CredentialEnv), host-pinned helper, clear-then-set order. GIT_TERMINAL_PROMPT=0 on every spawn; CloneTimeout/GitTimeout/MaintTTL bound every spawn (FullRepack rides MaintTTL — S8 holds).
  • .idx latent-gap claim is REAL: installPackFile (internal/wal/publish.go:1037) writes only .pack while reconcile (reconcile.go:104-109,186-232) expects wal/<sha>.idx; fix confined to publishPack, zero internal/wal,git,store diff — classic path untouched. Verified.
  • "Absent policy.json IS the default": loadPolicy missing = allow-all nil doc (internal/api/policy.go:25). Verified.
  • B2 params-compare-before-Run, importer excluded from comparison, joined outcome secret-free; B3 manifest-present∧import.json-absent → 409; drain-cancel holds via TaskTable.Drain → table-ctx cancel (drive's detached ctx is correct — cancellation comes from t.ctx, tasks.go:227); P6 order authenticate→authorize→join-or-start, anonymous 401+Bearer; wire (plain-text, [], RFC3339, no-store, 404/405/503+Retry-After, discovery twins); Import.jsx has dark: variants + anonymous-disable + server-status honoring (server-side gating intact).

Findings fixed + pushed (3bff123, re-tested below)

  1. internal/repoimport/doc.go:42 — corrupt/unreadable import.json returned 500, but R1 B3 / feature doc say "never adopts (409)". Now 409 naming delete-and-retry; store failures stay 500. Test TestProbeCorruptDoc500 → TestProbeCorruptDoc409 (cover2_test.go).
  2. internal/repoimport/service.go:196 — Begin held s.mu across the B3 probe's store I/O (Head+GET), violating 13 §2 rule 4. Probe now runs unlocked; the join window is re-checked under the lock before installing running (that re-check already existed).
  3. internal/repoimport/service.go:402 — Service.Janitor() had zero callers: finished streams rings retained forever (unbounded growth). Rings now prune lazily (capped, lock-safe, s.mu→stream.mu direction preserved) on Begin/Lookup; Janitor() kept as the explicit-sweep entry.
  4. internal/repoimport/http.go:172 — get lacked the nil-Svc guard post has (nil deref panic). Now 503 like POST. Regression test TestNilSvc503 added.
  5. internal/repoimport/http.go:427 — SSE keepalive for range s.ka.C parks forever after Stop (ticker channels never close): one leaked goroutine per attach, against 13 §1. Now select on s.ctx.Done() (+idempotent Stop). Same shape noted in internal/notify/stream.go:251 — not touched here (out of scope), flagging for its owner.
  6. Docs-only: docs/features/10_git_import.md claimed a "table fallback for pruned windows" that cannot fire (service ids ≠ table ids); behavior (404 past 1h retention) already matches the §3 status table — sentence corrected. docs/EVIDENCE.md E11 header now reads "PUTs total (control)" for the 14 (12) cells.

Post-fix results (scratch worktree /tmp/pr22 @ 3bff123)

  • go test -race -count=1 ./internal/repoimport/... PASS; coverage 95.5%; internal/api, internal/config PASS; B2 join tests -count=20 PASS; rebuilt binary live e2e re-passed (202 → ok:true 3 refs → 200 no-op; unknown id 404).
  • Not run: real-browser pass (ladder §8 — Import.jsx is new browser surface; suggest author drive /import + /setup in the CDP Chrome before merge), make sim (no publish-path change — correctly skipped), S3 contract (no backend touched).

MERGE RECOMMENDATION: ready to merge (after the author's browser pass on /import)

# PR #22 review (Feature 10 import, feat/repo-import @ 83d0cc1) — findings + fixes pushed (now 3bff123) Review axes all checked against code. Pre-fix verification (worktree /tmp/walhub-repoimport): gofmt clean, `go vet` clean, `go test -race ./internal/repoimport/...` PASS (24s), coverage 95.6% (gate 95%), `node --test web/test/unit/sdk-import.test.js` 6/6 PASS, live e2e from file:// fixture: 202 start → SSE replay→live→`result` exactly once → 200 no-op re-POST → `git clone` serves commit+tag; 409/400 matrix (foreign source, embedded creds, non-allowlisted host, unknown key) all per spec. `internal/api` + `internal/config` suites green. ## Spec conformance (confirmed, no change needed) - Budget: no go.mod/go.sum/npm changes; feature imports core only (`internal/git,store,wal,identity,server/auth`), core never imports `repoimport` (grep-clean). B1 package name, B6 kind registration (`repoimport.RegisterKind` + `api.RegisterExposed`, law 12 same-change) correct. - Git argv matches new 04 §12 pins verbatim (`clone --mirror --progress --`, for-each-ref format, rev-parse, conditional `index-pack <pack>`, `show HEAD:.gitattributes`); token only via per-task child env (`CredentialEnv`), host-pinned helper, clear-then-set order. GIT_TERMINAL_PROMPT=0 on every spawn; CloneTimeout/GitTimeout/MaintTTL bound every spawn (FullRepack rides MaintTTL — S8 holds). - `.idx` latent-gap claim is REAL: `installPackFile` (internal/wal/publish.go:1037) writes only `.pack` while reconcile (reconcile.go:104-109,186-232) expects `wal/<sha>.idx`; fix confined to `publishPack`, zero `internal/wal,git,store` diff — classic path untouched. Verified. - "Absent policy.json IS the default": `loadPolicy` missing = allow-all nil doc (internal/api/policy.go:25). Verified. - B2 params-compare-before-Run, importer excluded from comparison, joined outcome secret-free; B3 manifest-present∧import.json-absent → 409; drain-cancel holds via `TaskTable.Drain` → table-ctx cancel (drive's detached ctx is correct — cancellation comes from `t.ctx`, tasks.go:227); P6 order authenticate→authorize→join-or-start, anonymous 401+Bearer; wire (plain-text, [], RFC3339, no-store, 404/405/503+Retry-After, discovery twins); Import.jsx has `dark:` variants + anonymous-disable + server-status honoring (server-side gating intact). ## Findings fixed + pushed (3bff123, re-tested below) 1. `internal/repoimport/doc.go:42` — corrupt/unreadable `import.json` returned 500, but R1 B3 / feature doc say "never adopts (409)". Now 409 naming delete-and-retry; store failures stay 500. Test `TestProbeCorruptDoc500` → `TestProbeCorruptDoc409` (cover2_test.go). 2. `internal/repoimport/service.go:196` — `Begin` held `s.mu` across the B3 probe's store I/O (Head+GET), violating 13 §2 rule 4. Probe now runs unlocked; the join window is re-checked under the lock before installing `running` (that re-check already existed). 3. `internal/repoimport/service.go:402` — `Service.Janitor()` had zero callers: finished `streams` rings retained forever (unbounded growth). Rings now prune lazily (capped, lock-safe, `s.mu`→`stream.mu` direction preserved) on Begin/Lookup; `Janitor()` kept as the explicit-sweep entry. 4. `internal/repoimport/http.go:172` — `get` lacked the nil-`Svc` guard `post` has (nil deref panic). Now 503 like POST. Regression test `TestNilSvc503` added. 5. `internal/repoimport/http.go:427` — SSE keepalive `for range s.ka.C` parks forever after `Stop` (ticker channels never close): one leaked goroutine per attach, against 13 §1. Now `select` on `s.ctx.Done()` (+idempotent Stop). Same shape noted in `internal/notify/stream.go:251` — not touched here (out of scope), flagging for its owner. 6. Docs-only: `docs/features/10_git_import.md` claimed a "table fallback for pruned windows" that cannot fire (service ids ≠ table ids); behavior (404 past 1h retention) already matches the §3 status table — sentence corrected. `docs/EVIDENCE.md` E11 header now reads "PUTs total (control)" for the `14 (12)` cells. ## Post-fix results (scratch worktree /tmp/pr22 @ 3bff123) - `go test -race -count=1 ./internal/repoimport/...` PASS; coverage 95.5%; `internal/api`, `internal/config` PASS; B2 join tests `-count=20` PASS; rebuilt binary live e2e re-passed (202 → ok:true 3 refs → 200 no-op; unknown id 404). - Not run: real-browser pass (ladder §8 — Import.jsx is new browser surface; suggest author drive `/import` + `/setup` in the CDP Chrome before merge), `make sim` (no publish-path change — correctly skipped), S3 contract (no backend touched). ## MERGE RECOMMENDATION: ready to merge (after the author's browser pass on `/import`)
Author
Owner

Feature 10 complete: plan reviewed (6 blocking items → R1), implemented in PR #22, code-reviewed (6 findings fixed: import.json 409, unlocked probe I/O, janitor prune, nil-Svc guard, SSE goroutine leak, doc nits), merged. internal/repoimport 95.5%, -race clean, browser pass done by implementer. Closing.

Feature 10 complete: plan reviewed (6 blocking items → R1), implemented in PR #22, code-reviewed (6 findings fixed: import.json 409, unlocked probe I/O, janitor prune, nil-Svc guard, SSE goroutine leak, doc nits), merged. internal/repoimport 95.5%, -race clean, browser pass done by implementer. Closing.
crueber added this to the v1 milestone 2026-09-10 22:27:24 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
crueber/walhub#21
No description provided.