add appended .md unconditionally, so openapi.json became openapi.json.md. Reported from use against a repository that publishes an OpenAPI document beside its prose. It is now supplied only when the name carries no extension of its own. Two consequences came with it that the report could not have seen. A facet's name was built by stripping the extension, so openapi.json would have taken openapi.usages.md and collided with the facet for a sibling openapi.md; only a trailing .md is stripped now. And the unlocked walk matched *.md only, so a hand-fetched .json under .loom/externals was adopted, unlocked, and invisible to check; it now walks everything that is not the lock file or a dotfile. The assumption was never decided, it was typed. externals says a repository publishes documents and publication says prose in whatever form its readers need. Neither says markdown and three places in this tool did. Also records that the wrong diagnosis cost more than the bug: the repository is private, a scratch config with no token made every fetch look broken, and loomctl config would have said so in one line. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018UTxuSizozEA8yDitPuris
115 lines
3.6 KiB
Go
115 lines
3.6 KiB
Go
package external
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os/exec"
|
|
"path"
|
|
"strings"
|
|
|
|
"git.hypertheory-labs.dev/loom/loom-cli/internal/config"
|
|
)
|
|
|
|
// AddByName adopts a document named the way `list` prints it, against the
|
|
// current context.
|
|
//
|
|
// loomctl external add loom/cart cart
|
|
//
|
|
// Nobody should have to type a host's raw-file route to adopt a document. The
|
|
// route belongs to the host, so it lives in the context; the published
|
|
// directory belongs to the convention, so it lives in the code; and what is
|
|
// left — which repository, which document — is the only part a person knows.
|
|
func AddByName(root, ownerRepo, name string, out io.Writer) error {
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ctx, err := cfg.Current()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if strings.Count(ownerRepo, "/") != 1 {
|
|
return fmt.Errorf("want owner/repo, got %q", ownerRepo)
|
|
}
|
|
// A published surface is not only markdown — an OpenAPI document is a
|
|
// document. Supply .md only when the name carries no extension at all,
|
|
// and never append one to a name that already has its own.
|
|
if path.Ext(name) == "" {
|
|
name += ".md"
|
|
}
|
|
|
|
raw, resolved := ctx.RawURL(ownerRepo, PublishedDir+"/"+name)
|
|
if raw == "" {
|
|
return fmt.Errorf("context %q has flavor %q; known flavors are %s",
|
|
cfg.CurrentContext, ctx.Flavor, config.Flavors())
|
|
}
|
|
if !resolved {
|
|
// The lock must record a resolved URL. A short form that stays short
|
|
// follows whatever the default branch is at the time you ask, so a
|
|
// branch rename would report as a change in the document.
|
|
branch, err := defaultBranch(ctx.CloneURL(ownerRepo))
|
|
if err != nil {
|
|
return fmt.Errorf("resolving the default branch of %s: %w", ownerRepo, err)
|
|
}
|
|
raw = fmt.Sprintf(raw, branch)
|
|
}
|
|
err = Add(root, raw, "", out)
|
|
|
|
// A 404 here has a reading the convention does not list, because it can only
|
|
// happen at adoption: the name is wrong. We know the repository, so rather
|
|
// than reporting an ambiguity we can resolve, look.
|
|
var nf *NotFoundError
|
|
if errors.As(err, &nf) {
|
|
var names strings.Builder
|
|
if lerr := List(ctx.CloneURL(ownerRepo), &names); lerr == nil {
|
|
return fmt.Errorf("%s publishes no %q. It publishes:\n%s",
|
|
ownerRepo, name, indent(names.String()))
|
|
}
|
|
// Listing failed too, so the repository itself is unreachable and the
|
|
// original ambiguity stands.
|
|
}
|
|
return err
|
|
}
|
|
|
|
func indent(s string) string {
|
|
var b strings.Builder
|
|
for _, line := range strings.Split(strings.TrimRight(s, "\n"), "\n") {
|
|
fmt.Fprintf(&b, " %s\n", line)
|
|
}
|
|
return strings.TrimRight(b.String(), "\n")
|
|
}
|
|
|
|
// ListByName enumerates a repository named against the current context.
|
|
func ListByName(ownerRepo string, out io.Writer) error {
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ctx, err := cfg.Current()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return List(ctx.CloneURL(ownerRepo), out)
|
|
}
|
|
|
|
// defaultBranch asks the remote which branch HEAD points at, without cloning.
|
|
//
|
|
// Only hosts whose raw URLs do not redirect need this. Gitea resolves its own
|
|
// short form, which is why adopting from gitea needs no git at all.
|
|
func defaultBranch(cloneURL string) (string, error) {
|
|
cmd := exec.Command("git", "ls-remote", "--symref", cloneURL, "HEAD")
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return "", fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
|
|
}
|
|
for _, line := range strings.Split(stdout.String(), "\n") {
|
|
if after, ok := strings.CutPrefix(line, "ref: refs/heads/"); ok {
|
|
return strings.TrimSpace(strings.SplitN(after, "\t", 2)[0]), nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("no symref in git ls-remote output")
|
|
}
|