Skip to content
quarry Artifact [46e6ec4ad1]
Login

Artifact [46e6ec4ad1]

Artifact 46e6ec4ad1f582acdb96d6626a7fb70cd2f9c3fecd2828dbe0ea8eefd5d51034:


package zitcli

import (
	"bytes"
	"context"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"sort"
	"strings"
	"time"

	"craftdesign.group/quarry/internal/timing"
)

// Init creates an empty repository at dir, creating dir if needed.
//
// A package function rather than a Client method because a Client is
// bound to a repository that already exists; this is what makes one.
func Init(ctx context.Context, dir string) error {
	return create(ctx, "init", []string{"init", dir}, dir)
}

// Clone copies the repository served at url into dir.
//
// zit saves url as dir's default remote, and never with a password: a
// `user:secret@host` URL is stored as `user@host`. That is worth knowing
// here because quarry keeps its own mirror configuration — the saved
// remote is a convenience for anyone running zit by hand in that
// directory, not the thing quarry's scheduler reads.
func Clone(ctx context.Context, url, dir string) error {
	return create(ctx, "clone", []string{"clone", url, dir}, dir)
}

// create runs a repository-making verb. These are the two zit calls
// with no repository to name, so they take neither -R nor a working
// directory — the target is an argument.
func create(ctx context.Context, verb string, args []string, dir string) error {
	if ctx == nil {
		ctx = context.Background()
	}
	start := time.Now()
	cmd := exec.CommandContext(ctx, "zit", args...)
	var stderr bytes.Buffer
	cmd.Stderr = &stderr
	_, err := cmd.Output()
	timing.Record(ctx, timing.Zit, time.Since(start))
	if err != nil {
		return fmt.Errorf("zitcli: %s %s: %w: %s", verb, dir, err,
			strings.TrimSpace(stderr.String()))
	}
	return nil
}

// SeedFiles writes files into a freshly created repository and records
// them as its first check-in, so the repository has a born trunk.
//
// This matters more than the convenience suggests: until a repository
// has a check-in, its default branch is unborn, so the timeline, the
// file tree and every "clone this" instruction have nothing to point at.
// GitHub offers a README at creation for the same reason.
//
// Like Init and Clone this is directory-scoped rather than -R: `zit add`
// and `zit commit` operate on a checkout, and a store made by Init has
// one. Content is written by quarry rather than by zit, so paths are
// checked against the repository root before anything is created — a
// name reaching the filesystem is the one place traversal matters.
func SeedFiles(ctx context.Context, dir, user, message string, files map[string]string) error {
	if len(files) == 0 {
		return nil
	}
	root, err := filepath.Abs(dir)
	if err != nil {
		return err
	}
	names := make([]string, 0, len(files))
	for name := range files {
		full := filepath.Join(root, name)
		if !strings.HasPrefix(full, root+string(os.PathSeparator)) {
			return fmt.Errorf("zitcli: seed path %q escapes the repository", name)
		}
		if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
			return err
		}
		if err := os.WriteFile(full, []byte(files[name]), 0o644); err != nil {
			return err
		}
		names = append(names, name)
	}
	sort.Strings(names)

	add, doneAdd := commandIn(ctx, dir, append([]string{"add"}, names...)...)
	defer doneAdd()
	var stderr bytes.Buffer
	add.Stderr = &stderr
	if _, err := add.Output(); err != nil {
		return fmt.Errorf("zitcli: seed add: %w: %s", err, strings.TrimSpace(stderr.String()))
	}
	if user == "" {
		user = "quarry"
	}
	commit, doneCommit := commandIn(ctx, dir, "commit", "-m", message, "--user", user)
	defer doneCommit()
	stderr.Reset()
	commit.Stderr = &stderr
	if _, err := commit.Output(); err != nil {
		return fmt.Errorf("zitcli: seed commit: %w: %s", err, strings.TrimSpace(stderr.String()))
	}
	return nil
}

// commandIn runs a zit verb with the working directory set, for the
// verbs that operate on a checkout rather than taking -R.
func commandIn(ctx context.Context, dir string, args ...string) (*exec.Cmd, func()) {
	if ctx == nil {
		ctx = context.Background()
	}
	start := time.Now()
	cmd := exec.CommandContext(ctx, "zit", args...)
	cmd.Dir = dir
	return cmd, func() { timing.Record(ctx, timing.Zit, time.Since(start)) }
}