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...)
untrack, _ := trackChild(filepath.Base(dir), verb, cmd)
var stderr bytes.Buffer
cmd.Stderr = &stderr
_, err := cmd.Output()
untrack()
timing.Record(ctx, timing.Zit, 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
verb := ""
if len(args) > 0 {
verb = args[0]
}
untrack, _ := trackChild(filepath.Base(dir), verb, cmd)
return cmd, func() {
untrack()
timing.Record(ctx, timing.Zit, start)
}
}