Skip to content
quarry Artifact [11b2934f92]
Login

Artifact [11b2934f92]

Artifact 11b2934f927cb33fd55bce829510ea6c6818a767c8ead65cc71c2afbfea7a162:


package zitcli

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"regexp"
	"strconv"
	"strings"
	"sync"

	"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).
// dest must not exist; zit writes it only when the
// import completes. No lease: the store is not served yet.
//
// zitOnly imports with sub-manifests, which zit sizes to the tree and
// stock Fossil cannot read; otherwise every check-in gets a flat
// manifest. The choice is the store's from then on: later imports
// (ImportGitInto) follow it.
//
// --use-author (zit#270): a check-in names the commit's Git author, the
// person who wrote the change, not its committer. Fossil's own import
// defaults to the committer, which on GitHub is "GitHub" for every pull
// request merged through the web.
func ImportGit(ctx context.Context, source, dest string, env []string, zitOnly bool) (GitImport, error) {
	args := []string{"import-git", source, dest, "--use-author", "--no-submanifests", "-F", "json"}
	if zitOnly {
		args = []string{"import-git", source, dest, "--use-author", "-F", "json"}
	}
	return runImportGit(ctx, dest, env, args...)
}

// 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()
	// No manifest flag: zit keeps the format the store was imported
	// with, and refuses a flag that contradicts it.
	// No attribution flag either: the first import recorded author or
	// committer, --into reuses it, and a contradicting flag is refused.
	return runImportGit(ctx, c.repo, env, "import-git", source, c.repo, "--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, c.repo, nil, "import-git", "--recover", c.repo, "-F", "json")
}

func runImportGit(ctx context.Context, dest string, env []string, args ...string) (GitImport, error) {
	if err := checkArgs(args); err != nil {
		return GitImport{}, err
	}
	cmd, done := spawnUnattended(ctx, repoName(dest), "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, &importProgress{dest: dest, w: &diag}
	defer gitImports.Delete(dest)
	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
}

// gitImports holds each running import's latest progress, by the store
// it writes: check-ins written, of those it will write.
var gitImports sync.Map // dest -> [2]int

// progressLine is zit's count as an import runs:
// "import-git: 192/563 commits (34%), 84/s".
var progressLine = regexp.MustCompile(`(\d+)/(\d+) commits`)

// importProgress passes zit's standard error through to w, noting each
// count as it goes by. Writes arrive in whatever pieces the pipe gives,
// so a count split across two is missed, and the next one is taken.
type importProgress struct {
	dest string
	w    io.Writer
}

func (p *importProgress) Write(b []byte) (int, error) {
	if m := progressLine.FindAllSubmatch(b, -1); len(m) > 0 {
		last := m[len(m)-1]
		done, _ := strconv.Atoi(string(last[1]))
		total, _ := strconv.Atoi(string(last[2]))
		gitImports.Store(p.dest, [2]int{done, total})
	}
	return p.w.Write(b)
}

// GitImportProgress is how far the import-git writing dest has come:
// check-ins written, of those it will write. ok is false before zit has
// counted anything and after the import ends. A Git import's store
// appears only when it completes, so the store's size says nothing
// while it runs; this is what does.
func GitImportProgress(dest string) (done, total int, ok bool) {
	v, ok := gitImports.Load(dest)
	if !ok {
		return 0, 0, false
	}
	n := v.([2]int)
	return n[0], n[1], true
}

// 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
}