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.
func ImportGit(ctx context.Context, source, dest string, env []string, zitOnly bool) (GitImport, error) {
args := []string{"import-git", source, dest, "--no-submanifests", "-F", "json"}
if zitOnly {
args = []string{"import-git", source, dest, "-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.
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
}