// 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 "" or "#"; // 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 }