Files
av 23d88c4048 проектные команды и ссылки на источник
- заведён internal/source: уровни ссылаются друг на друга путём на диске
  или git-репозиторием, ревизия закрепляется хвостом #ref; клон делается
  заново и удаляется, кэша нет
- добавлены init, add, pull, list, check в проекте — манифест
  .conventions.toml, сборка копий по разу на компонент, маркер локальной
  части, READING.md рядом
- проверки формы развязаны с набором: принимают lang.Vocabulary, а язык
  копии узнаётся по строке о версии — манифеста рядом с ней нет
2026-07-27 20:42:18 +03:00

237 lines
7.5 KiB
Go

// Package source resolves a reference from one level of the model to the level
// above it.
//
// The levels stack up: the language describes how a rule is written, a suite
// writes its rules in that language, a project takes copies out of a suite.
// Each level is a set of files, and each lower one names the level above by a
// reference. Where that set physically lies is a question of transport rather
// than of the model — a directory on disk, a git repository, one day an HTTP
// tree or an rclone remote — so a reference names the transport first.
//
// Two transports are implemented: a path on disk and a git repository. Kind is
// an enumeration rather than a boolean because the third one is expected, and
// because a reference that cannot be resolved has to say which transport it was
// understood as before it says what went wrong.
package source
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
)
// Kind is the transport a reference names.
type Kind int
const (
// Local is a directory on disk.
Local Kind = iota + 1
// Git is a repository cloned to read from.
Git
)
func (k Kind) String() string {
switch k {
case Local:
return "path"
case Git:
return "git"
}
return "unknown"
}
// Ref is a parsed reference to a level.
type Ref struct {
// Raw is the reference as the manifest wrote it.
Raw string
// Kind is the transport.
Kind Kind
// Location is the path or the URL of the repository, without the revision.
Location string
// Rev is a git branch, tag or commit. Empty means the default branch of
// the repository.
Rev string
}
// String renders the reference back the way it was written.
func (r Ref) String() string {
if r.Rev == "" {
return r.Location
}
return r.Location + "#" + r.Rev
}
// scpRe catches the short form git accepts instead of a URL: user@host:path.
var scpRe = regexp.MustCompile(`^[A-Za-z0-9_.\-]+@[A-Za-z0-9_.\-]+:`)
// schemes maps a URL scheme onto the transport that serves it. Everything git
// speaks is a git reference: http and https are the two the tool is written
// against, while ssh, git and file happen to work because the clone is the same
// clone.
//
// file:// is a git reference rather than a directory on purpose. A plain path
// already says "this directory as it lies", working tree and all; file:// says
// "the same repository as it is committed", which is a different and sometimes
// wanted thing — and the difference is the reason both spellings exist.
var schemes = map[string]Kind{
"http": Git,
"https": Git,
"ssh": Git,
"git": Git,
"file": Git,
}
// Parse reads a reference. The form is "<location>" or "<location>#<revision>";
// what the location starts with decides the transport.
func Parse(raw string) (Ref, error) {
text := strings.TrimSpace(raw)
if text == "" {
return Ref{}, errors.New("the source reference is empty")
}
location, rev := text, ""
if i := strings.LastIndex(text, "#"); i >= 0 {
location, rev = strings.TrimSpace(text[:i]), strings.TrimSpace(text[i+1:])
if location == "" {
return Ref{}, fmt.Errorf("the reference %q names a revision and nothing to take it from", text)
}
if rev == "" {
return Ref{}, fmt.Errorf("the reference %q ends with # and names no revision", text)
}
}
ref := Ref{Raw: text, Location: location, Rev: rev}
scheme, _, hasScheme := strings.Cut(location, "://")
switch {
case hasScheme:
kind, known := schemes[scheme]
if !known {
return Ref{}, fmt.Errorf("the reference %q names the scheme %q, and the tool reaches a level over a path on disk or over git", text, scheme)
}
ref.Kind = kind
case scpRe.MatchString(location):
ref.Kind = Git
default:
ref.Kind = Local
}
if ref.Kind == Local {
if rev != "" {
return Ref{}, fmt.Errorf("the reference %q pins a revision of a directory on disk: a revision is a thing only a git repository has", text)
}
// A manifest is committed and travels between machines, and a path
// through a home directory means a different place on each of them. A
// relative path resolves against the manifest, which is the form that
// survives the trip.
if strings.HasPrefix(ref.Location, "~") {
return Ref{}, fmt.Errorf("the reference %q starts from a home directory, which points somewhere else on every other machine; write it relative to the manifest or in full", text)
}
}
return ref, nil
}
// Tree is a level laid out as a directory that can be read.
type Tree struct {
ref Ref
dir string
temp bool
}
// Dir is the root of the level on disk.
func (t *Tree) Dir() string { return t.dir }
// Describe rewrites a message about the tree in terms of the reference it came
// from. A fetched level lies in a temporary directory whose name says nothing
// to anyone: what the reader can act on is the reference they wrote.
func (t *Tree) Describe(err error) error {
if err == nil || t == nil {
return err
}
return errors.New(strings.ReplaceAll(err.Error(), t.dir, t.ref.String()))
}
// Close releases whatever the opening took. A directory on disk was there
// before and stays; a clone is removed.
func (t *Tree) Close() error {
if t == nil || !t.temp {
return nil
}
return os.RemoveAll(t.dir)
}
// Open makes a reference readable. A relative path resolves against base — the
// directory of the manifest that carries the reference.
//
// A git repository is cloned afresh every time, into a directory that goes away
// with the Tree. A cache would spare the second clone and buy back the question
// of what is stale in it, and the answer to "what did it look like last time"
// belongs to git in the consuming repository rather than to a cache of the tool.
func Open(r Ref, base string) (*Tree, error) {
switch r.Kind {
case Local:
dir := filepath.FromSlash(r.Location)
if !filepath.IsAbs(dir) {
dir = filepath.Join(base, dir)
}
info, err := os.Stat(dir)
if err != nil {
return nil, fmt.Errorf("the source %s: %w", r.Raw, err)
}
if !info.IsDir() {
return nil, fmt.Errorf("the source %s is a file, while a level is a directory", r.Raw)
}
return &Tree{ref: r, dir: dir}, nil
case Git:
return clone(r)
}
return nil, fmt.Errorf("the source %s names no transport the tool knows", r.Raw)
}
// clone fetches a git reference into a temporary directory.
func clone(r Ref) (*Tree, error) {
if _, err := exec.LookPath("git"); err != nil {
return nil, fmt.Errorf("the source %s is a git repository, and there is no git in PATH to fetch it with", r.Raw)
}
dir, err := os.MkdirTemp("", "convy-source-")
if err != nil {
return nil, err
}
args := []string{"clone", "--quiet", "--depth", "1"}
if r.Rev != "" {
args = append(args, "--branch", r.Rev)
}
args = append(args, r.Location, dir)
out, err := git(args...)
if err == nil {
return &Tree{ref: r, dir: dir, temp: true}, nil
}
// A commit hash is not a branch and not a tag, so --branch turns it down.
// Reaching one costs the whole history, which is why it is the second
// attempt rather than the first.
if r.Rev != "" {
if _, deep := git("clone", "--quiet", r.Location, dir); deep == nil {
if _, at := git("-C", dir, "checkout", "--quiet", r.Rev); at == nil {
return &Tree{ref: r, dir: dir, temp: true}, nil
}
}
}
os.RemoveAll(dir)
return nil, fmt.Errorf("fetching the source %s: %w\n%s", r.Raw, err, strings.TrimSpace(out))
}
func git(args ...string) (string, error) {
cmd := exec.Command("git", args...)
// A clone that stops to ask for a password would hang a command meant to
// run unattended; failing with what git said is the answer that can be
// acted on.
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
out, err := cmd.CombinedOutput()
return string(out), err
}