проектные команды и ссылки на источник
- заведён internal/source: уровни ссылаются друг на друга путём на диске или git-репозиторием, ревизия закрепляется хвостом #ref; клон делается заново и удаляется, кэша нет - добавлены init, add, pull, list, check в проекте — манифест .conventions.toml, сборка копий по разу на компонент, маркер локальной части, READING.md рядом - проверки формы развязаны с набором: принимают lang.Vocabulary, а язык копии узнаётся по строке о версии — манифеста рядом с ней нет
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package source_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/convy/internal/source"
|
||||
)
|
||||
|
||||
func TestParseTellsTheTransportsApart(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw string
|
||||
kind source.Kind
|
||||
location string
|
||||
rev string
|
||||
}{
|
||||
{"../dev-conventions", source.Local, "../dev-conventions", ""},
|
||||
{"/srv/conventions", source.Local, "/srv/conventions", ""},
|
||||
{"https://git.example.org/av/conventions.git", source.Git, "https://git.example.org/av/conventions.git", ""},
|
||||
{"http://git.example.org/av/conventions.git#v2", source.Git, "http://git.example.org/av/conventions.git", "v2"},
|
||||
{"ssh://git@git.example.org:2222/av/conventions.git", source.Git, "ssh://git@git.example.org:2222/av/conventions.git", ""},
|
||||
{"git@git.example.org:av/conventions.git", source.Git, "git@git.example.org:av/conventions.git", ""},
|
||||
// A plain path is the directory as it lies; file:// is the same
|
||||
// repository as it is committed.
|
||||
{"file:///srv/conventions#main", source.Git, "file:///srv/conventions", "main"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.raw, func(t *testing.T) {
|
||||
ref, err := source.Parse(tc.raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parsing %q: %v", tc.raw, err)
|
||||
}
|
||||
if ref.Kind != tc.kind {
|
||||
t.Errorf("read as %s, expected %s", ref.Kind, tc.kind)
|
||||
}
|
||||
if ref.Location != tc.location {
|
||||
t.Errorf("location %q, expected %q", ref.Location, tc.location)
|
||||
}
|
||||
if ref.Rev != tc.rev {
|
||||
t.Errorf("revision %q, expected %q", ref.Rev, tc.rev)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRefusesWhatItCannotMean(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{"nothing at all", " ", "empty"},
|
||||
{"a revision of a directory", "../conventions#main", "only a git repository has"},
|
||||
{"a home directory", "~/projects/conventions", "every other machine"},
|
||||
{"a scheme nobody serves", "rclone://remote/conventions", "over git"},
|
||||
{"a hash with no revision", "https://git.example.org/c.git#", "names no revision"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := source.Parse(tc.raw)
|
||||
if err == nil {
|
||||
t.Fatalf("the reference %q went through", tc.raw)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.want) {
|
||||
t.Errorf("the refusal does not say %q: %s", tc.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenResolvesAPathAgainstTheManifest(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(base, "vendor", "conventions"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ref, err := source.Parse("vendor/conventions")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tree, err := source.Open(ref, base)
|
||||
if err != nil {
|
||||
t.Fatalf("opening a relative path: %v", err)
|
||||
}
|
||||
defer tree.Close()
|
||||
|
||||
if tree.Dir() != filepath.Join(base, "vendor", "conventions") {
|
||||
t.Errorf("resolved to %s", tree.Dir())
|
||||
}
|
||||
// Nothing was fetched, so nothing is released: the directory was there
|
||||
// before the command and stays after it.
|
||||
tree.Close()
|
||||
if _, err := os.Stat(tree.Dir()); err != nil {
|
||||
t.Errorf("closing removed a directory that was not fetched: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRefusesAFileWhereALevelIsExpected(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(base, "conventions"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ref, _ := source.Parse("conventions")
|
||||
if _, err := source.Open(ref, base); err == nil || !strings.Contains(err.Error(), "a level is a directory") {
|
||||
t.Errorf("a file passed as a level: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A git reference takes what is committed rather than what lies in the working
|
||||
// tree, and that is the difference between the two spellings of a local suite.
|
||||
func TestOpenGitTakesTheCommittedState(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("no git in PATH")
|
||||
}
|
||||
repo := t.TempDir()
|
||||
name := filepath.Join(repo, "suite.toml")
|
||||
if err := os.WriteFile(name, []byte("committed\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, args := range [][]string{
|
||||
{"init", "--quiet", "-b", "main"},
|
||||
{"-c", "user.email=t@example.org", "-c", "user.name=t", "add", "suite.toml"},
|
||||
{"-c", "user.email=t@example.org", "-c", "user.name=t", "commit", "--quiet", "-m", "first"},
|
||||
} {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(name, []byte("uncommitted\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ref, err := source.Parse("file://" + repo + "#main")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tree, err := source.Open(ref, ".")
|
||||
if err != nil {
|
||||
t.Fatalf("cloning a local repository: %v", err)
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(tree.Dir(), "suite.toml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.TrimSpace(string(body)) != "committed" {
|
||||
t.Errorf("the working tree leaked into the clone: %q", body)
|
||||
}
|
||||
|
||||
dir := tree.Dir()
|
||||
if err := tree.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(dir); err == nil {
|
||||
t.Errorf("the clone survived the close: %s", dir)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user