package external import ( "bytes" "errors" "fmt" "io" "os/exec" "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) } if !strings.HasSuffix(name, ".md") { 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") }