package zitcli
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"craftdesign.group/quarry/internal/lease"
)
// GitImport is what one `zit import-git -F json` run reported (zit
// 0.6.2). Retry is what to do next: "none" (the outcome is final),
// "deepen" (the source is too shallow to prove where it joins the store;
// fetch more history), "later" (another import holds the store) or
// "recover" (an interrupted import must be finished first with
// RecoverGitImport). Changed covers ref-only changes too: a tag added or
// deleted changes the store while Added stays 0.
type GitImport struct {
Outcome string `json:"outcome"`
Reason string `json:"reason"`
Changed bool `json:"changed"`
Durability string `json:"durability"`
Retry string `json:"retry"`
Added int `json:"added_checkins"`
// Detail is zit's last line of prose, which names what a refusal
// was about where Reason is only its class.
Detail string `json:"-"`
}
// Err is nil for an import that finished, changed or not, and otherwise
// names zit's outcome and reason.
func (r GitImport) Err() error {
switch r.Outcome {
case "changed", "unchanged", "recovered_changed", "recovered_unchanged":
return nil
}
if r.Detail != "" {
return fmt.Errorf("zit import-git: %s (%s): %s", r.Outcome, r.Reason, r.Detail)
}
return fmt.Errorf("zit import-git: %s (%s)", r.Outcome, r.Reason)
}
// GitImportPending says whether the store at dir holds an interrupted
// import. zit refuses every read and write of that store until
// RecoverGitImport finishes it.
func GitImportPending(dir string) bool {
_, err := os.Stat(filepath.Join(dir, ".zit-import-pending"))
return err == nil
}
// ImportedHead is the pinned source default's head from the last successful
// Git import. It is not Fossil's date-based trunk resolution: an abandoned
// leaf can be newer than the head the upstream now publishes (zit#252).
func (c *Client) ImportedHead(ctx context.Context) (name, hash string, err error) {
cmd, done := c.command(ctx, "info", "--imported-head", "-F", "json", "-R", c.repo)
defer done()
out, err := cmd.Output()
if err != nil {
return "", "", fmt.Errorf("zitcli: imported head: %w", err)
}
var head struct {
Name string `json:"name"`
Hash string `json:"hash"`
}
if err := json.Unmarshal(out, &head); err != nil {
return "", "", fmt.Errorf("zitcli: imported head: %w", err)
}
if !strings.HasPrefix(head.Name, "heads/") || len(head.Name) == len("heads/") || !immutableVersion(head.Hash) {
return "", "", fmt.Errorf("zitcli: imported head: invalid name or hash")
}
return head.Name, head.Hash, nil
}
// ImportGit makes the store at dest from the Git repository at source:
// the first import of a repository that follows a Git upstream (#358).
// Flat manifests, so plain fossil can clone the result as it can any
// store quarry makes. dest must not exist; zit writes it only when the
// import completes. No lease: the store is not served yet.
func ImportGit(ctx context.Context, source, dest string, env []string) (GitImport, error) {
return runImportGit(ctx, repoName(dest), env, "import-git", source, dest, "--no-submanifests", "-F", "json")
}
// ImportGitInto adds what the store does not yet hold from the Git
// repository at source (zit import-git --into): new commits, and moved
// or deleted branches and tags. A write under the store's lease. The
// error is for a run that reported nothing; an outcome zit reported,
// refusals included, comes back in the GitImport for the caller to act
// on its Retry.
func (c *Client) ImportGitInto(tok lease.Token, ctx context.Context, source string, env []string) (GitImport, error) {
if err := tok.Err(); err != nil {
return GitImport{}, err
}
ctx, stop := underLease(ctx, tok)
defer stop()
return runImportGit(ctx, repoName(c.repo), env, "import-git", source, c.repo, "--no-submanifests", "--into", "-F", "json")
}
// RecoverGitImport finishes an import that was interrupted after it
// began writing (zit import-git --recover). It needs no source: what
// was being written is recorded beside the store.
func (c *Client) RecoverGitImport(tok lease.Token, ctx context.Context) (GitImport, error) {
if err := tok.Err(); err != nil {
return GitImport{}, err
}
ctx, stop := underLease(ctx, tok)
defer stop()
return runImportGit(ctx, repoName(c.repo), nil, "import-git", "--recover", c.repo, "-F", "json")
}
func runImportGit(ctx context.Context, repo string, env []string, args ...string) (GitImport, error) {
if err := checkArgs(args); err != nil {
return GitImport{}, err
}
cmd, done := spawnUnattended(ctx, repo, "git-import", args...)
defer done()
if cmd.Err != nil {
return GitImport{}, cmd.Err
}
// No inherited GIT_* variable reaches the git zit runs: one such as
// GIT_ALLOW_PROTOCOL would override what env sets.
cmd.Env = append(withoutGitEnv(os.Environ()), env...)
OwnGroup(cmd)
cmd.WaitDelay = waitDelay
// The JSON result is the first line of standard output, written
// whatever the exit status; standard error is zit's prose.
var out, diag bytes.Buffer
cmd.Stdout, cmd.Stderr = &out, &diag
err := cmd.Run()
line, _, _ := strings.Cut(strings.TrimSpace(out.String()), "\n")
var r GitImport
if jerr := json.Unmarshal([]byte(line), &r); jerr != nil || r.Outcome == "" {
return GitImport{}, fmt.Errorf("zit import-git: %s", lastLine(strings.TrimSpace(diag.String()), err))
}
if err != nil {
r.Detail = lastLine(strings.TrimSpace(diag.String()), err)
}
return r, nil
}
// lastLine is the import's last line of output, which is its refusal
// when it refuses, else the process error.
func lastLine(text string, err error) string {
if i := strings.LastIndexByte(text, '\n'); i >= 0 {
text = text[i+1:]
}
text = strings.TrimSpace(strings.TrimPrefix(text, "import-git:"))
if text == "" {
return err.Error()
}
return text
}
func withoutGitEnv(env []string) []string {
out := env[:0:0]
for _, kv := range env {
if !strings.HasPrefix(kv, "GIT_") {
out = append(out, kv)
}
}
return out
}