проектные команды и ссылки на источник

- заведён internal/source: уровни ссылаются друг на друга путём на диске
  или git-репозиторием, ревизия закрепляется хвостом #ref; клон делается
  заново и удаляется, кэша нет
- добавлены init, add, pull, list, check в проекте — манифест
  .conventions.toml, сборка копий по разу на компонент, маркер локальной
  части, READING.md рядом
- проверки формы развязаны с набором: принимают lang.Vocabulary, а язык
  копии узнаётся по строке о версии — манифеста рядом с ней нет
This commit is contained in:
av
2026-07-27 20:42:18 +03:00
parent 4615de6e86
commit 23d88c4048
27 changed files with 3009 additions and 57 deletions
+163
View File
@@ -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)
}
}