Engine fixes from adopting sovrn, servers and moods
- actions get the running engine's directory first on PATH, so ci-result works in repos that don't pin the engine - GitDir asks jj (jj git root): jj workspaces and non-colocated repos - local runs are named after the forge repo (from origin), not the directory (a jj workspace or clone may differ) Bug 65e8495.
5 files changed,  +62, -12
M cmd/ci/engine.go
+11, -6
 1@@ -302,16 +302,21 @@ func prepareChild(parent, id, action string) (*manifest.Manifest, engine.Meta, e
 2 // --repo links them.
 3 func prepareLocal(m *manifest.Manifest, root string, dir engine.Dir, f *runFlags) (engine.Meta, error) {
 4 	ctx := context.Background()
 5-	repo := filepath.Base(root)
 6-	meta := engine.Meta{
 7-		Run: dir.Base(), Workflow: m.Name, Repo: repo, Ref: f.ref,
 8-		Kind: "manual", Host: "local", Root: filepath.Join(dir.Path(), repo),
 9-	}
10 	gitDir, err := checkout.GitDir(root)
11 	if err != nil {
12-		return meta, err
13+		return engine.Meta{}, err
14 	}
15 	origin := checkout.OriginURL(ctx, gitDir)
16+	// the repo's name on the forge, not the directory's (a jj workspace or a
17+	// clone may be named differently)
18+	repo := checkout.RepoName(origin)
19+	if repo == "" {
20+		repo = filepath.Base(root)
21+	}
22+	meta := engine.Meta{
23+		Run: dir.Base(), Workflow: m.Name, Repo: repo, Ref: f.ref,
24+		Kind: "manual", Host: "local", Root: filepath.Join(dir.Path(), repo),
25+	}
26 	src := &checkout.Source{MirrorsDir: filepath.Join(cacheDir(), "mirrors"), URL: checkout.URLFunc(os.Getenv("CI_CLONE_URL"), origin)}
27 	main := checkout.Main{Repo: repo}
28 	if f.clean {
M internal/checkout/checkout.go
+15, -6
 1@@ -102,15 +102,24 @@ func Clone(ctx context.Context, from, sha, dest, origin string) error {
 2 	return nil
 3 }
 4 
 5-// GitDir returns the git directory of a local repository: .git for git and
 6-// colocated jj repos, .jj/repo/store/git for jj repos without colocation.
 7+// GitDir returns the git directory behind a local repository: `jj git root`
 8+// for jj repos (which also covers secondary workspaces and non-colocated
 9+// repos), else .git.
10 func GitDir(root string) (string, error) {
11-	for _, p := range []string{filepath.Join(root, ".git"), filepath.Join(root, ".jj", "repo", "store", "git")} {
12-		if _, err := os.Stat(p); err == nil {
13-			return p, nil
14+	if _, err := os.Stat(filepath.Join(root, ".jj")); err == nil {
15+		cmd := exec.Command("jj", "git", "root")
16+		cmd.Dir = root
17+		out, err := cmd.Output()
18+		if err != nil {
19+			return "", fmt.Errorf("%s: jj git root: %w", root, err)
20 		}
21+		return strings.TrimSpace(string(out)), nil
22 	}
23-	return "", fmt.Errorf("%s: no git directory (.git or .jj/repo/store/git)", root)
24+	p := filepath.Join(root, ".git")
25+	if _, err := os.Stat(p); err != nil {
26+		return "", fmt.Errorf("%s: no .git or .jj", root)
27+	}
28+	return p, nil
29 }
30 
31 // Inside reports whether p is dir or below it (lexically).
M internal/checkout/checkout_test.go
+5, -0
 1@@ -174,6 +174,11 @@ func TestURLs(t *testing.T) {
 2 			t.Errorf("SiblingURL(%q) = %q, %v", of, got, err)
 3 		}
 4 	}
 5+	for in, want := range map[string]string{"git@h:sovrn.git": "sovrn", "ssh://h/u/moods/": "moods", "https://h/x.git": "x", "": ""} {
 6+		if got := RepoName(in); got != want {
 7+			t.Errorf("RepoName(%q) = %q", in, got)
 8+		}
 9+	}
10 	if u, _ := URLFunc("git@h:{repo}", "x")("data"); u != "git@h:data" {
11 		t.Errorf("template: %s", u)
12 	}
M internal/checkout/layout.go
+11, -0
 1@@ -117,6 +117,17 @@ func SiblingURL(of, repo string) (string, error) {
 2 	return of[:i+1] + repo + suffix, nil
 3 }
 4 
 5+// RepoName returns the last path element of a clone URL without .git
 6+// (git@host:sovrn.git -> sovrn), or "".
 7+func RepoName(url string) string {
 8+	url = strings.TrimSuffix(strings.TrimSuffix(url, "/"), ".git")
 9+	i := strings.LastIndexAny(url, "/:")
10+	if i < 0 || i == len(url)-1 {
11+		return ""
12+	}
13+	return url[i+1:]
14+}
15+
16 // URLFunc returns the clone-URL resolver: a template with {repo} (from
17 // CI_CLONE_URL) if set, else a sibling of origin.
18 func URLFunc(template, origin string) func(string) (string, error) {
M internal/engine/env.go
+20, -0
 1@@ -2,6 +2,7 @@ package engine
 2 
 3 import (
 4 	"os"
 5+	"path/filepath"
 6 	"sort"
 7 	"strconv"
 8 	"strings"
 9@@ -22,6 +23,11 @@ func (e *Engine) actionEnv(p Pending, n int, resultFile, inputsFile, triggerFile
10 	for k, v := range e.M.Environment {
11 		env[k] = v
12 	}
13+	// ci-result (and ci) of the engine running this action come first, so
14+	// recipes can report results even in repos that don't pin the engine.
15+	if dir := helperDir(); dir != "" {
16+		env["PATH"] = dir + string(os.PathListSeparator) + env["PATH"]
17+	}
18 	m := e.st.Meta
19 	set := func(k, v string) { env[k] = v }
20 
21@@ -87,5 +93,19 @@ func (e *Engine) stateDir() string {
22 	return e.Dir.State()
23 }
24 
25+// helperDir is the directory of the running engine binary if ci-result is
26+// next to it (the Nix package installs it as a symlink), else "".
27+func helperDir() string {
28+	exe, err := os.Executable()
29+	if err != nil {
30+		return ""
31+	}
32+	dir := filepath.Dir(exe)
33+	if _, err := os.Stat(filepath.Join(dir, "ci-result")); err != nil {
34+		return ""
35+	}
36+	return dir
37+}
38+
39 // osEnviron is os.Environ, replaceable in tests.
40 var osEnviron = os.Environ