Skip to content

Read a checkout where it actually is - #22

Merged
tschm merged 3 commits into
mainfrom
fix/honour-real-checkout-paths
Aug 30, 2026
Merged

Read a checkout where it actually is#22
tschm merged 3 commits into
mainfrom
fix/honour-real-checkout-paths

Conversation

@tschm

@tschm tschm commented Aug 30, 2026

Copy link
Copy Markdown
Member

localgit.scan derived every path as <JQ_REPO_ROOT>/<owner>/<name>. Inside the container that is always right — the generated bind mounts put each checkout at exactly that path, so repos.yml can say anything and the mount normalises it. Outside the container it is often wrong, and silently so.

Four repos in the fleet this was built against are laid out like this:

  - path: ~/repos/tschm/rhiza_projects/cs     # a checkout of tschm/cs

No amount of joining owner to name produces that path. Those repos dropped off the working-copy panels while staying on the GitHub ones — present on the board, quietly missing half their columns, nothing saying why. Running the collector on the host reported 20 of 24 checkouts.

The fix

JQ_REPO_PATHS carries the real path per repo as owner/name=path pairs, with <repo_root>/<owner>/<name> kept as the fallback. gen-repos.py --env emits it from repos.yml alongside JQ_REPOS, so the layout is stated once in the file that already owns it rather than retyped.

before after
in the container (bind mounts, no JQ_REPO_PATHS) 24/24 24/24
on the host (JQ_REPO_ROOT empty, paths only) 20/24 24/24

The container is genuinely untouched — same fallback, rebuilt and verified at 1,743 metric lines with a clean log.

Two ways this could have gone quiet, both closed

  • A malformed pair raises at startup rather than being skipped. Dropping one would take a repo's local panels off the board and say nothing — the exact failure mode this module is shaped to avoid.
  • A path containing a comma cannot be expressed, comma being the separator. gen-repos.py refuses to emit one rather than print a line that would be misread as two repos.

The origin check matters more now, not less, since the path is arbitrary: a path pointing at the wrong repo is still refused rather than filed under the listed repo's name. Test included.

A bad JQ_REPO_ROOT also no longer abandons the whole scan — it logs once and withdraws the fallback, leaving repos with explicit paths working.

Checks

69 tests (10 new; one existing test updated because --env now prints two lines — that was pinning the old contract), ruff, check-dashboard.py, mkdocs build --strict, both compose stacks parse.

tschm added 2 commits August 30, 2026 18:12
localgit.scan derived every path as <JQ_REPO_ROOT>/<owner>/<name>. Inside the
container that is always right, because the generated bind mounts put each
checkout at exactly that path - repos.yml can say anything and the mount
normalises it. Outside the container it is often wrong, and silently so.

Four repos in the fleet this was built against are laid out like this:

    - path: ~/repos/tschm/rhiza_projects/cs     # a checkout of tschm/cs

No amount of joining owner to name produces that path, so those repos dropped
off the working-copy panels while staying on the GitHub ones: present on the
board, quietly missing half their columns, and nothing said why. Running the
collector on the host reported 20 of 24 checkouts.

JQ_REPO_PATHS now carries the real path per repo, as owner/name=path pairs,
with <repo_root>/<owner>/<name> kept as the fallback. `gen-repos.py --env`
emits it from repos.yml alongside JQ_REPOS, so the layout is stated once, in
the file that already owns it, rather than retyped.

  * The container is untouched: it sets no JQ_REPO_PATHS and uses the fallback
    exactly as before. Verified - still 24 of 24, 1743 metric lines.
  * On the host, 24 of 24 now, with JQ_REPO_ROOT empty and every path coming
    from JQ_REPO_PATHS alone.

The origin check matters more now rather than less, since the path is
arbitrary - a path pointing at the wrong repo is still refused rather than
filed under the listed repo's name, and there is a test for it.

Two ways this could have gone quiet, both closed:

  * A malformed pair raises at startup instead of being skipped. Dropping one
    would take a repo's local panels off the board and say nothing - the exact
    failure this module is shaped to avoid.
  * A path containing a comma cannot be expressed, comma being the separator.
    gen-repos.py refuses to emit one rather than print a line that would be
    misread as two repos.

A bad JQ_REPO_ROOT no longer abandons the whole scan either: it logs once and
withdraws the fallback, leaving repos with explicit paths working.
Prometheus and Grafana stay in Docker - they are pinned images with
repo-tracked provisioning, which is what Docker is good at here. The collector
is not; it is a Python package that reads your working copies, and the
container was costing more than it gave:

  * bind mounts have to place every checkout at <root>/<owner>/<name>, so a
    repo living anywhere else silently lost its local panels. Four in this
    fleet did.
  * reading thousands of small files back through a macOS bind mount is slow
    enough that the line counts needed a cache to stay affordable.
  * eight of the Dockerfile's nineteen lines existed only to undo problems the
    container created - installing git, and trusting a bind mount owned by a
    uid the container does not have.

What changes

  * collector/Dockerfile is gone, and so is the collector service.
  * prometheus.yml scrapes host.docker.internal:9109. extra_hosts maps it on
    Linux, where it is not built in.
  * scripts/collector.sh runs it in the foreground - under launchd, in a
    terminal, or under tmux. up.sh installs and loads a launchd agent on
    macOS and says what to do elsewhere; down.sh unloads it.
  * gen-repos.py no longer writes a compose override, because there is no
    container to mount anything into. It prints JQ_REPOS and JQ_REPO_PATHS,
    and collector.sh runs it at every launch - so repos.yml is the only place
    the fleet and the layout are written down, with no generated file in
    between to go stale.

Two things this turned up, both fixed here

  * A launchd agent inherits /usr/bin:/bin:/usr/sbin:/sbin and nothing else,
    so uv under /opt/homebrew was invisible and the agent failed on every
    launch. KeepAlive then respawned it in a tight loop - fourteen failures
    before it was noticed. up.sh now pins uv's directory into the plist, and
    ThrottleInterval turns any future misconfiguration into a slow retry with
    a readable log rather than thousands of lines a minute.
  * Moving the collector changed its `instance` label from collector:9109 to
    the new scrape address, which forked every series in two and showed each
    repo on the board twice until the old ones went stale. There is exactly
    one collector, so the label carries no information; prometheus.yml now
    relabels it to a constant and the history stays continuous across any
    future move.

The trade, stated in scripts/collector.sh and docs/operations.md: inside the
container the collector could only see the checkouts mounted into it, so an
unlisted repo was invisible rather than merely filtered out. It now runs as you
and could read anything you can. It still never writes - every git call is
read-only and passes --no-optional-locks - but that is now a property of the
code rather than something the sandbox enforces.

Verified end to end on the real fleet: launchd agent running, 24 of 24
checkouts reporting, Prometheus target up at host.docker.internal:9109, one
instance label, no duplicate series, Grafana serving.
Run the collector on the host, not in a container
@tschm
tschm merged commit 37e561e into main Aug 30, 2026
6 checks passed
@tschm
tschm deleted the fix/honour-real-checkout-paths branch August 30, 2026 14:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant