back interdiff of round #1 and #0

spindle: move the clone step out of nixery into a shared package for all spindle engines #827

merged
opened by evan.jarrett.net targeting master from evan.jarrett.net/core: spindle-clone
files
appview
db
pages
templates
repo
compare
settings
user
fragments
pipelines
pulls
repo
nix
spindle
ERROR
spindle/engines/nixery/setup_steps.go

Failed to calculate interdiff for this file.

REVERTED
spindle/workflow/clone.go
···
-
package workflow
-
-
import (
-
"fmt"
-
"strings"
-
-
"tangled.org/core/api/tangled"
-
"tangled.org/core/workflow"
-
)
-
-
type CloneOptions struct {
-
Workflow tangled.Pipeline_Workflow
-
TriggerMetadata tangled.Pipeline_TriggerMetadata
-
DevMode bool
-
WorkspaceDir string
-
}
-
-
type CloneInfo struct {
-
Commands []string
-
RepoURL string
-
CommitSHA string
-
Skip bool
-
}
-
-
// GetCloneInfo generates git clone commands and metadata from pipeline trigger metadata
-
func GetCloneInfo(opts CloneOptions) (*CloneInfo, error) {
-
if opts.Workflow.Clone != nil && opts.Workflow.Clone.Skip {
-
return &CloneInfo{Skip: true}, nil
-
}
-
-
commitSHA, err := extractCommitSHA(opts.TriggerMetadata)
-
if err != nil {
-
return nil, fmt.Errorf("failed to extract commit SHA: %w", err)
-
}
-
-
repoURL := buildRepoURL(opts.TriggerMetadata, opts.DevMode)
-
-
workspaceDir := opts.WorkspaceDir
-
if workspaceDir == "" {
-
workspaceDir = "/tangled/workspace"
-
}
-
-
initCmd := fmt.Sprintf("git init %s", workspaceDir)
-
remoteCmd := fmt.Sprintf("git remote add origin %s", repoURL)
-
-
var cloneOpts tangled.Pipeline_CloneOpts
-
if opts.Workflow.Clone != nil {
-
cloneOpts = *opts.Workflow.Clone
-
}
-
fetchArgs := buildFetchArgs(cloneOpts, commitSHA)
-
fetchCmd := fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " "))
-
-
checkoutCmd := "git checkout FETCH_HEAD"
-
-
commands := []string{
-
initCmd,
-
fmt.Sprintf("cd %s", workspaceDir),
-
remoteCmd,
-
fetchCmd,
-
checkoutCmd,
-
}
-
-
return &CloneInfo{
-
Commands: commands,
-
RepoURL: repoURL,
-
CommitSHA: commitSHA,
-
Skip: false,
-
}, nil
-
}
-
-
// extractCommitSHA extracts the commit SHA from trigger metadata based on trigger type
-
func extractCommitSHA(tr tangled.Pipeline_TriggerMetadata) (string, error) {
-
switch workflow.TriggerKind(tr.Kind) {
-
case workflow.TriggerKindPush:
-
if tr.Push == nil {
-
return "", fmt.Errorf("push trigger metadata is nil")
-
}
-
return tr.Push.NewSha, nil
-
-
case workflow.TriggerKindPullRequest:
-
if tr.PullRequest == nil {
-
return "", fmt.Errorf("pull request trigger metadata is nil")
-
}
-
return tr.PullRequest.SourceSha, nil
-
-
case workflow.TriggerKindManual:
-
// Manual triggers don't have an explicit SHA in the metadata
-
// For now, return empty string - could be enhanced to fetch from default branch
-
// TODO: Implement manual trigger SHA resolution (fetch default branch HEAD)
-
return "", nil
-
-
default:
-
return "", fmt.Errorf("unknown trigger kind: %s", tr.Kind)
-
}
-
}
-
-
// buildRepoURL constructs the repository URL from trigger metadata
-
func buildRepoURL(tr tangled.Pipeline_TriggerMetadata, devMode bool) string {
-
if tr.Repo == nil {
-
return ""
-
}
-
-
// Determine protocol
-
scheme := "https://"
-
if devMode {
-
scheme = "http://"
-
}
-
-
// Get host from knot
-
host := tr.Repo.Knot
-
-
// In dev mode, replace localhost with host.docker.internal for Docker networking
-
if devMode && strings.Contains(host, "localhost") {
-
host = strings.ReplaceAll(host, "localhost", "host.docker.internal")
-
}
-
-
// Build URL: {scheme}{knot}/{did}/{repo}
-
return fmt.Sprintf("%s%s/%s/%s", scheme, host, tr.Repo.Did, tr.Repo.Repo)
-
}
-
-
// buildFetchArgs constructs the arguments for git fetch based on clone options
-
func buildFetchArgs(clone tangled.Pipeline_CloneOpts, sha string) []string {
-
args := []string{}
-
-
// Set fetch depth (default to 1 for shallow clone)
-
depth := clone.Depth
-
if depth == 0 {
-
depth = 1
-
}
-
args = append(args, fmt.Sprintf("--depth=%d", depth))
-
-
// Add submodules if requested
-
if clone.Submodules {
-
args = append(args, "--recurse-submodules=yes")
-
}
-
-
// Add remote and SHA
-
args = append(args, "origin")
-
if sha != "" {
-
args = append(args, sha)
-
}
-
-
return args
-
}
REVERTED
spindle/workflow/clone_test.go
···
-
package workflow
-
-
import (
-
"strings"
-
"testing"
-
-
"tangled.org/core/api/tangled"
-
"tangled.org/core/workflow"
-
)
-
-
func TestGetCloneInfo_PushTrigger(t *testing.T) {
-
cfg := CloneOptions{
-
Workflow: tangled.Pipeline_Workflow{
-
Clone: &tangled.Pipeline_CloneOpts{
-
Depth: 1,
-
Submodules: false,
-
Skip: false,
-
},
-
},
-
TriggerMetadata: tangled.Pipeline_TriggerMetadata{
-
Kind: string(workflow.TriggerKindPush),
-
Push: &tangled.Pipeline_PushTriggerData{
-
NewSha: "abc123",
-
OldSha: "def456",
-
Ref: "refs/heads/main",
-
},
-
Repo: &tangled.Pipeline_TriggerRepo{
-
Knot: "example.com",
-
Did: "did:plc:user123",
-
Repo: "my-repo",
-
},
-
},
-
DevMode: false,
-
WorkspaceDir: "/tangled/workspace",
-
}
-
-
info, err := GetCloneInfo(cfg)
-
if err != nil {
-
t.Fatalf("GetCloneInfo failed: %v", err)
-
}
-
-
if info.Skip {
-
t.Error("Expected Skip to be false")
-
}
-
-
if info.CommitSHA != "abc123" {
-
t.Errorf("Expected CommitSHA 'abc123', got '%s'", info.CommitSHA)
-
}
-
-
expectedURL := "https://example.com/did:plc:user123/my-repo"
-
if info.RepoURL != expectedURL {
-
t.Errorf("Expected RepoURL '%s', got '%s'", expectedURL, info.RepoURL)
-
}
-
-
if len(info.Commands) != 5 {
-
t.Errorf("Expected 5 commands, got %d", len(info.Commands))
-
}
-
-
// Verify commands contain expected git operations
-
allCmds := strings.Join(info.Commands, " ")
-
if !strings.Contains(allCmds, "git init") {
-
t.Error("Commands should contain 'git init'")
-
}
-
if !strings.Contains(allCmds, "git remote add origin") {
-
t.Error("Commands should contain 'git remote add origin'")
-
}
-
if !strings.Contains(allCmds, "git fetch") {
-
t.Error("Commands should contain 'git fetch'")
-
}
-
if !strings.Contains(allCmds, "abc123") {
-
t.Error("Commands should contain commit SHA")
-
}
-
if !strings.Contains(allCmds, "git checkout FETCH_HEAD") {
-
t.Error("Commands should contain 'git checkout FETCH_HEAD'")
-
}
-
}
-
-
func TestGetCloneInfo_PullRequestTrigger(t *testing.T) {
-
cfg := CloneOptions{
-
Workflow: tangled.Pipeline_Workflow{
-
Clone: &tangled.Pipeline_CloneOpts{
-
Depth: 1,
-
Skip: false,
-
},
-
},
-
TriggerMetadata: tangled.Pipeline_TriggerMetadata{
-
Kind: string(workflow.TriggerKindPullRequest),
-
PullRequest: &tangled.Pipeline_PullRequestTriggerData{
-
SourceSha: "pr-sha-789",
-
SourceBranch: "feature-branch",
-
TargetBranch: "main",
-
Action: "opened",
-
},
-
Repo: &tangled.Pipeline_TriggerRepo{
-
Knot: "example.com",
-
Did: "did:plc:user123",
-
Repo: "my-repo",
-
},
-
},
-
DevMode: false,
-
WorkspaceDir: "/tangled/workspace",
-
}
-
-
info, err := GetCloneInfo(cfg)
-
if err != nil {
-
t.Fatalf("GetCloneInfo failed: %v", err)
-
}
-
-
if info.CommitSHA != "pr-sha-789" {
-
t.Errorf("Expected CommitSHA 'pr-sha-789', got '%s'", info.CommitSHA)
-
}
-
-
allCmds := strings.Join(info.Commands, " ")
-
if !strings.Contains(allCmds, "pr-sha-789") {
-
t.Error("Commands should contain PR commit SHA")
-
}
-
}
-
-
func TestGetCloneInfo_ManualTrigger(t *testing.T) {
-
cfg := CloneOptions{
-
Workflow: tangled.Pipeline_Workflow{
-
Clone: &tangled.Pipeline_CloneOpts{
-
Depth: 1,
-
Skip: false,
-
},
-
},
-
TriggerMetadata: tangled.Pipeline_TriggerMetadata{
-
Kind: string(workflow.TriggerKindManual),
-
Manual: &tangled.Pipeline_ManualTriggerData{
-
Inputs: nil,
-
},
-
Repo: &tangled.Pipeline_TriggerRepo{
-
Knot: "example.com",
-
Did: "did:plc:user123",
-
Repo: "my-repo",
-
},
-
},
-
DevMode: false,
-
WorkspaceDir: "/tangled/workspace",
-
}
-
-
info, err := GetCloneInfo(cfg)
-
if err != nil {
-
t.Fatalf("GetCloneInfo failed: %v", err)
-
}
-
-
// Manual triggers don't have a SHA yet (TODO)
-
if info.CommitSHA != "" {
-
t.Errorf("Expected empty CommitSHA for manual trigger, got '%s'", info.CommitSHA)
-
}
-
}
-
-
func TestGetCloneInfo_SkipFlag(t *testing.T) {
-
cfg := CloneOptions{
-
Workflow: tangled.Pipeline_Workflow{
-
Clone: &tangled.Pipeline_CloneOpts{
-
Skip: true,
-
},
-
},
-
TriggerMetadata: tangled.Pipeline_TriggerMetadata{
-
Kind: string(workflow.TriggerKindPush),
-
Push: &tangled.Pipeline_PushTriggerData{
-
NewSha: "abc123",
-
},
-
Repo: &tangled.Pipeline_TriggerRepo{
-
Knot: "example.com",
-
Did: "did:plc:user123",
-
Repo: "my-repo",
-
},
-
},
-
}
-
-
info, err := GetCloneInfo(cfg)
-
if err != nil {
-
t.Fatalf("GetCloneInfo failed: %v", err)
-
}
-
-
if !info.Skip {
-
t.Error("Expected Skip to be true")
-
}
-
-
if len(info.Commands) != 0 {
-
t.Errorf("Expected no commands when Skip is true, got %d commands", len(info.Commands))
-
}
-
}
-
-
func TestGetCloneInfo_DevMode(t *testing.T) {
-
cfg := CloneOptions{
-
Workflow: tangled.Pipeline_Workflow{
-
Clone: &tangled.Pipeline_CloneOpts{
-
Depth: 1,
-
Skip: false,
-
},
-
},
-
TriggerMetadata: tangled.Pipeline_TriggerMetadata{
-
Kind: string(workflow.TriggerKindPush),
-
Push: &tangled.Pipeline_PushTriggerData{
-
NewSha: "abc123",
-
},
-
Repo: &tangled.Pipeline_TriggerRepo{
-
Knot: "localhost:3000",
-
Did: "did:plc:user123",
-
Repo: "my-repo",
-
},
-
},
-
DevMode: true,
-
WorkspaceDir: "/tangled/workspace",
-
}
-
-
info, err := GetCloneInfo(cfg)
-
if err != nil {
-
t.Fatalf("GetCloneInfo failed: %v", err)
-
}
-
-
// In dev mode, should use http:// and replace localhost with host.docker.internal
-
expectedURL := "http://host.docker.internal:3000/did:plc:user123/my-repo"
-
if info.RepoURL != expectedURL {
-
t.Errorf("Expected dev mode URL '%s', got '%s'", expectedURL, info.RepoURL)
-
}
-
}
-
-
func TestGetCloneInfo_DepthAndSubmodules(t *testing.T) {
-
cfg := CloneOptions{
-
Workflow: tangled.Pipeline_Workflow{
-
Clone: &tangled.Pipeline_CloneOpts{
-
Depth: 10,
-
Submodules: true,
-
Skip: false,
-
},
-
},
-
TriggerMetadata: tangled.Pipeline_TriggerMetadata{
-
Kind: string(workflow.TriggerKindPush),
-
Push: &tangled.Pipeline_PushTriggerData{
-
NewSha: "abc123",
-
},
-
Repo: &tangled.Pipeline_TriggerRepo{
-
Knot: "example.com",
-
Did: "did:plc:user123",
-
Repo: "my-repo",
-
},
-
},
-
DevMode: false,
-
WorkspaceDir: "/tangled/workspace",
-
}
-
-
info, err := GetCloneInfo(cfg)
-
if err != nil {
-
t.Fatalf("GetCloneInfo failed: %v", err)
-
}
-
-
allCmds := strings.Join(info.Commands, " ")
-
if !strings.Contains(allCmds, "--depth=10") {
-
t.Error("Commands should contain '--depth=10'")
-
}
-
-
if !strings.Contains(allCmds, "--recurse-submodules=yes") {
-
t.Error("Commands should contain '--recurse-submodules=yes'")
-
}
-
}
-
-
func TestGetCloneInfo_DefaultDepth(t *testing.T) {
-
cfg := CloneOptions{
-
Workflow: tangled.Pipeline_Workflow{
-
Clone: &tangled.Pipeline_CloneOpts{
-
Depth: 0, // Default should be 1
-
Skip: false,
-
},
-
},
-
TriggerMetadata: tangled.Pipeline_TriggerMetadata{
-
Kind: string(workflow.TriggerKindPush),
-
Push: &tangled.Pipeline_PushTriggerData{
-
NewSha: "abc123",
-
},
-
Repo: &tangled.Pipeline_TriggerRepo{
-
Knot: "example.com",
-
Did: "did:plc:user123",
-
Repo: "my-repo",
-
},
-
},
-
WorkspaceDir: "/tangled/workspace",
-
}
-
-
info, err := GetCloneInfo(cfg)
-
if err != nil {
-
t.Fatalf("GetCloneInfo failed: %v", err)
-
}
-
-
allCmds := strings.Join(info.Commands, " ")
-
if !strings.Contains(allCmds, "--depth=1") {
-
t.Error("Commands should default to '--depth=1'")
-
}
-
}
-
-
func TestGetCloneInfo_NilPushData(t *testing.T) {
-
cfg := CloneOptions{
-
Workflow: tangled.Pipeline_Workflow{
-
Clone: &tangled.Pipeline_CloneOpts{
-
Depth: 1,
-
Skip: false,
-
},
-
},
-
TriggerMetadata: tangled.Pipeline_TriggerMetadata{
-
Kind: string(workflow.TriggerKindPush),
-
Push: nil, // Nil push data should return error
-
Repo: &tangled.Pipeline_TriggerRepo{
-
Knot: "example.com",
-
Did: "did:plc:user123",
-
Repo: "my-repo",
-
},
-
},
-
WorkspaceDir: "/tangled/workspace",
-
}
-
-
_, err := GetCloneInfo(cfg)
-
if err == nil {
-
t.Error("Expected error when push data is nil")
-
}
-
-
if !strings.Contains(err.Error(), "push trigger metadata is nil") {
-
t.Errorf("Expected error about nil push metadata, got: %v", err)
-
}
-
}
-
-
func TestGetCloneInfo_NilPRData(t *testing.T) {
-
cfg := CloneOptions{
-
Workflow: tangled.Pipeline_Workflow{
-
Clone: &tangled.Pipeline_CloneOpts{
-
Depth: 1,
-
Skip: false,
-
},
-
},
-
TriggerMetadata: tangled.Pipeline_TriggerMetadata{
-
Kind: string(workflow.TriggerKindPullRequest),
-
PullRequest: nil, // Nil PR data should return error
-
Repo: &tangled.Pipeline_TriggerRepo{
-
Knot: "example.com",
-
Did: "did:plc:user123",
-
Repo: "my-repo",
-
},
-
},
-
WorkspaceDir: "/tangled/workspace",
-
}
-
-
_, err := GetCloneInfo(cfg)
-
if err == nil {
-
t.Error("Expected error when pull request data is nil")
-
}
-
-
if !strings.Contains(err.Error(), "pull request trigger metadata is nil") {
-
t.Errorf("Expected error about nil PR metadata, got: %v", err)
-
}
-
}
-
-
func TestGetCloneInfo_CustomWorkspace(t *testing.T) {
-
cfg := CloneOptions{
-
Workflow: tangled.Pipeline_Workflow{
-
Clone: &tangled.Pipeline_CloneOpts{
-
Depth: 1,
-
Skip: false,
-
},
-
},
-
TriggerMetadata: tangled.Pipeline_TriggerMetadata{
-
Kind: string(workflow.TriggerKindPush),
-
Push: &tangled.Pipeline_PushTriggerData{
-
NewSha: "abc123",
-
},
-
Repo: &tangled.Pipeline_TriggerRepo{
-
Knot: "example.com",
-
Did: "did:plc:user123",
-
Repo: "my-repo",
-
},
-
},
-
DevMode: false,
-
WorkspaceDir: "/custom/path",
-
}
-
-
info, err := GetCloneInfo(cfg)
-
if err != nil {
-
t.Fatalf("GetCloneInfo failed: %v", err)
-
}
-
-
allCmds := strings.Join(info.Commands, " ")
-
if !strings.Contains(allCmds, "/custom/path") {
-
t.Error("Commands should use custom workspace directory")
-
}
-
}
-
-
func TestGetCloneInfo_DefaultWorkspace(t *testing.T) {
-
cfg := CloneOptions{
-
Workflow: tangled.Pipeline_Workflow{
-
Clone: &tangled.Pipeline_CloneOpts{
-
Depth: 1,
-
Skip: false,
-
},
-
},
-
TriggerMetadata: tangled.Pipeline_TriggerMetadata{
-
Kind: string(workflow.TriggerKindPush),
-
Push: &tangled.Pipeline_PushTriggerData{
-
NewSha: "abc123",
-
},
-
Repo: &tangled.Pipeline_TriggerRepo{
-
Knot: "example.com",
-
Did: "did:plc:user123",
-
Repo: "my-repo",
-
},
-
},
-
DevMode: false,
-
WorkspaceDir: "", // Empty should default to /tangled/workspace
-
}
-
-
info, err := GetCloneInfo(cfg)
-
if err != nil {
-
t.Fatalf("GetCloneInfo failed: %v", err)
-
}
-
-
allCmds := strings.Join(info.Commands, " ")
-
if !strings.Contains(allCmds, "/tangled/workspace") {
-
t.Error("Commands should default to /tangled/workspace")
-
}
-
}
NEW
appview/db/pipeline.go
···
// this is a mega query, but the most useful one:
// get N pipelines, for each one get the latest status of its N workflows
-
func GetPipelineStatuses(e Execer, limit int, filters ...filter) ([]models.Pipeline, error) {
+
func GetPipelineStatuses(e Execer, filters ...filter) ([]models.Pipeline, error) {
var conditions []string
var args []any
for _, filter := range filters {
···
join
triggers t ON p.trigger_id = t.id
%s
-
order by p.created desc
-
limit %d
-
`, whereClause, limit)
+
`, whereClause)
rows, err := e.Query(query, args...)
if err != nil {
NEW
appview/pages/templates/repo/compare/compare.html
···
{{ end }}
{{ define "mainLayout" }}
-
<div class="px-1 flex-grow col-span-full flex flex-col gap-4">
+
<div class="px-1 col-span-full flex flex-col gap-4">
{{ block "contentLayout" . }}
{{ block "content" . }}{{ end }}
{{ end }}
NEW
appview/pages/templates/repo/settings/general.html
···
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
</button>
</div>
-
</fieldset>
+
<fieldset>
</form>
{{ end }}
NEW
appview/pages/templates/user/fragments/editBio.html
···
class="py-1 px-1 w-full"
name="pronouns"
placeholder="they/them"
+
pattern="[a-zA-Z]{1,6}[\/\s\-][a-zA-Z]{1,6}"
value="{{ $pronouns }}"
>
</div>
NEW
appview/pipelines/pipelines.go
···
ps, err := db.GetPipelineStatuses(
p.db,
-
30,
db.FilterEq("repo_owner", repoInfo.OwnerDid),
db.FilterEq("repo_name", repoInfo.Name),
db.FilterEq("knot", repoInfo.Knot),
···
ps, err := db.GetPipelineStatuses(
p.db,
-
1,
db.FilterEq("repo_owner", repoInfo.OwnerDid),
db.FilterEq("repo_name", repoInfo.Name),
db.FilterEq("knot", repoInfo.Knot),
···
ps, err := db.GetPipelineStatuses(
p.db,
-
1,
db.FilterEq("repo_owner", repoInfo.OwnerDid),
db.FilterEq("repo_name", repoInfo.Name),
db.FilterEq("knot", repoInfo.Knot),
NEW
appview/pulls/pulls.go
···
ps, err := db.GetPipelineStatuses(
s.db,
-
len(shas),
db.FilterEq("repo_owner", repoInfo.OwnerDid),
db.FilterEq("repo_name", repoInfo.Name),
db.FilterEq("knot", repoInfo.Knot),
···
repoInfo := f.RepoInfo(user)
ps, err := db.GetPipelineStatuses(
s.db,
-
len(shas),
db.FilterEq("repo_owner", repoInfo.OwnerDid),
db.FilterEq("repo_name", repoInfo.Name),
db.FilterEq("knot", repoInfo.Knot),
NEW
appview/repo/compare.go
···
}
// if user is navigating to one of
-
// /compare/{base}...{head}
// /compare/{base}/{head}
-
var base, head string
-
rest := chi.URLParam(r, "*")
-
-
var parts []string
-
if strings.Contains(rest, "...") {
-
parts = strings.SplitN(rest, "...", 2)
-
} else if strings.Contains(rest, "/") {
-
parts = strings.SplitN(rest, "/", 2)
-
}
-
-
if len(parts) == 2 {
-
base = parts[0]
-
head = parts[1]
+
// /compare/{base}...{head}
+
base := chi.URLParam(r, "base")
+
head := chi.URLParam(r, "head")
+
if base == "" && head == "" {
+
rest := chi.URLParam(r, "*") // master...feature/xyz
+
parts := strings.SplitN(rest, "...", 2)
+
if len(parts) == 2 {
+
base = parts[0]
+
head = parts[1]
+
}
}
base, _ = url.PathUnescape(base)
NEW
appview/repo/repo_util.go
···
package repo
import (
+
"crypto/rand"
+
"math/big"
"slices"
"sort"
"strings"
···
return
}
+
func randomString(n int) string {
+
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
+
result := make([]byte, n)
+
+
for i := 0; i < n; i++ {
+
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
+
result[i] = letters[n.Int64()]
+
}
+
+
return string(result)
+
}
+
// grab pipelines from DB and munge that into a hashmap with commit sha as key
//
// golang is so blessed that it requires 35 lines of imperative code for this
···
ps, err := db.GetPipelineStatuses(
d,
-
len(shas),
db.FilterEq("repo_owner", repoInfo.OwnerDid),
db.FilterEq("repo_name", repoInfo.Name),
db.FilterEq("knot", repoInfo.Knot),
NEW
appview/repo/router.go
···
// for example:
// /compare/master...some/feature
// /compare/master...example.com:another/feature <- this is a fork
+
r.Get("/{base}/{head}", rp.Compare)
r.Get("/*", rp.Compare)
})
NEW
nix/pkgs/knot-unwrapped.nix
···
sqlite-lib,
src,
}: let
-
version = "1.11.0-alpha";
+
version = "1.9.1-alpha";
in
buildGoApplication {
pname = "knot";
NEW
spindle/engines/nixery/engine.go
···
setup := &setupSteps{}
setup.addStep(nixConfStep())
-
setup.addStep(cloneStep(twf, *tpl.TriggerMetadata, e.cfg.Server.Dev))
+
setup.addStep(models.BuildCloneStep(twf, *tpl.TriggerMetadata, workspaceDir, e.cfg.Server.Dev))
// this step could be empty
if s := dependencyStep(dwf.Dependencies); s != nil {
setup.addStep(*s)
NEW
spindle/models/clone.go
···
+
package models
+
+
import (
+
"fmt"
+
"strings"
+
+
"tangled.org/core/api/tangled"
+
"tangled.org/core/workflow"
+
)
+
+
type CloneStep struct {
+
name string
+
kind StepKind
+
commands []string
+
}
+
+
func (s CloneStep) Name() string {
+
return s.name
+
}
+
+
func (s CloneStep) Commands() []string {
+
return s.commands
+
}
+
+
func (s CloneStep) Command() string {
+
return strings.Join(s.commands, "\n")
+
}
+
+
func (s CloneStep) Kind() StepKind {
+
return s.kind
+
}
+
+
// BuildCloneStep generates git clone commands.
+
// The shared builder handles:
+
// - git init
+
// - git remote add origin <url>
+
// - git fetch --depth=<d> --recurse-submodules=<yes|no> <sha>
+
// - git checkout FETCH_HEAD
+
// And supports all trigger types (push, PR, manual) and clone options.
+
func BuildCloneStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerMetadata, workspaceDir string, dev bool) CloneStep {
+
if twf.Clone != nil && twf.Clone.Skip {
+
return CloneStep{}
+
}
+
+
commitSHA, err := extractCommitSHA(tr)
+
if err != nil {
+
return CloneStep{
+
kind: StepKindSystem,
+
name: "Clone repository into workspace (error)",
+
commands: []string{fmt.Sprintf("echo 'Failed to get clone info: %s' && exit 1", err.Error())},
+
}
+
}
+
+
repoURL := buildRepoURL(tr, dev)
+
+
if workspaceDir == "" {
+
workspaceDir = "/tangled/workspace"
+
}
+
+
initCmd := fmt.Sprintf("git init %s", workspaceDir)
+
remoteCmd := fmt.Sprintf("git remote add origin %s", repoURL)
+
+
var cloneOpts tangled.Pipeline_CloneOpts
+
if twf.Clone != nil {
+
cloneOpts = *twf.Clone
+
}
+
fetchArgs := buildFetchArgs(cloneOpts, commitSHA)
+
fetchCmd := fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " "))
+
checkoutCmd := "git checkout FETCH_HEAD"
+
+
return CloneStep{
+
kind: StepKindSystem,
+
name: "Clone repository into workspace",
+
commands: []string{
+
initCmd,
+
fmt.Sprintf("cd %s", workspaceDir),
+
remoteCmd,
+
fetchCmd,
+
checkoutCmd,
+
},
+
}
+
}
+
+
// extractCommitSHA extracts the commit SHA from trigger metadata based on trigger type
+
func extractCommitSHA(tr tangled.Pipeline_TriggerMetadata) (string, error) {
+
switch workflow.TriggerKind(tr.Kind) {
+
case workflow.TriggerKindPush:
+
if tr.Push == nil {
+
return "", fmt.Errorf("push trigger metadata is nil")
+
}
+
return tr.Push.NewSha, nil
+
+
case workflow.TriggerKindPullRequest:
+
if tr.PullRequest == nil {
+
return "", fmt.Errorf("pull request trigger metadata is nil")
+
}
+
return tr.PullRequest.SourceSha, nil
+
+
case workflow.TriggerKindManual:
+
// Manual triggers don't have an explicit SHA in the metadata
+
// For now, return empty string - could be enhanced to fetch from default branch
+
// TODO: Implement manual trigger SHA resolution (fetch default branch HEAD)
+
return "", nil
+
+
default:
+
return "", fmt.Errorf("unknown trigger kind: %s", tr.Kind)
+
}
+
}
+
+
// buildRepoURL constructs the repository URL from trigger metadata
+
func buildRepoURL(tr tangled.Pipeline_TriggerMetadata, devMode bool) string {
+
if tr.Repo == nil {
+
return ""
+
}
+
+
// Determine protocol
+
scheme := "https://"
+
if devMode {
+
scheme = "http://"
+
}
+
+
// Get host from knot
+
host := tr.Repo.Knot
+
+
// In dev mode, replace localhost with host.docker.internal for Docker networking
+
if devMode && strings.Contains(host, "localhost") {
+
host = strings.ReplaceAll(host, "localhost", "host.docker.internal")
+
}
+
+
// Build URL: {scheme}{knot}/{did}/{repo}
+
return fmt.Sprintf("%s%s/%s/%s", scheme, host, tr.Repo.Did, tr.Repo.Repo)
+
}
+
+
// buildFetchArgs constructs the arguments for git fetch based on clone options
+
func buildFetchArgs(clone tangled.Pipeline_CloneOpts, sha string) []string {
+
args := []string{}
+
+
// Set fetch depth (default to 1 for shallow clone)
+
depth := clone.Depth
+
if depth == 0 {
+
depth = 1
+
}
+
args = append(args, fmt.Sprintf("--depth=%d", depth))
+
+
// Add submodules if requested
+
if clone.Submodules {
+
args = append(args, "--recurse-submodules=yes")
+
}
+
+
// Add remote and SHA
+
args = append(args, "origin")
+
if sha != "" {
+
args = append(args, sha)
+
}
+
+
return args
+
}
NEW
spindle/models/clone_test.go
···
+
package models
+
+
import (
+
"strings"
+
"testing"
+
+
"tangled.org/core/api/tangled"
+
"tangled.org/core/workflow"
+
)
+
+
func TestBuildCloneStep_PushTrigger(t *testing.T) {
+
twf := tangled.Pipeline_Workflow{
+
Clone: &tangled.Pipeline_CloneOpts{
+
Depth: 1,
+
Submodules: false,
+
Skip: false,
+
},
+
}
+
tr := tangled.Pipeline_TriggerMetadata{
+
Kind: string(workflow.TriggerKindPush),
+
Push: &tangled.Pipeline_PushTriggerData{
+
NewSha: "abc123",
+
OldSha: "def456",
+
Ref: "refs/heads/main",
+
},
+
Repo: &tangled.Pipeline_TriggerRepo{
+
Knot: "example.com",
+
Did: "did:plc:user123",
+
Repo: "my-repo",
+
},
+
}
+
+
step := BuildCloneStep(twf, tr, "/tangled/workspace", false)
+
+
if step.Kind() != StepKindSystem {
+
t.Errorf("Expected StepKindSystem, got %v", step.Kind())
+
}
+
+
if step.Name() != "Clone repository into workspace" {
+
t.Errorf("Expected 'Clone repository into workspace', got '%s'", step.Name())
+
}
+
+
commands := step.Commands()
+
if len(commands) != 5 {
+
t.Errorf("Expected 5 commands, got %d", len(commands))
+
}
+
+
// Verify commands contain expected git operations
+
allCmds := strings.Join(commands, " ")
+
if !strings.Contains(allCmds, "git init") {
+
t.Error("Commands should contain 'git init'")
+
}
+
if !strings.Contains(allCmds, "git remote add origin") {
+
t.Error("Commands should contain 'git remote add origin'")
+
}
+
if !strings.Contains(allCmds, "git fetch") {
+
t.Error("Commands should contain 'git fetch'")
+
}
+
if !strings.Contains(allCmds, "abc123") {
+
t.Error("Commands should contain commit SHA")
+
}
+
if !strings.Contains(allCmds, "git checkout FETCH_HEAD") {
+
t.Error("Commands should contain 'git checkout FETCH_HEAD'")
+
}
+
if !strings.Contains(allCmds, "https://example.com/did:plc:user123/my-repo") {
+
t.Error("Commands should contain expected repo URL")
+
}
+
}
+
+
func TestBuildCloneStep_PullRequestTrigger(t *testing.T) {
+
twf := tangled.Pipeline_Workflow{
+
Clone: &tangled.Pipeline_CloneOpts{
+
Depth: 1,
+
Skip: false,
+
},
+
}
+
tr := tangled.Pipeline_TriggerMetadata{
+
Kind: string(workflow.TriggerKindPullRequest),
+
PullRequest: &tangled.Pipeline_PullRequestTriggerData{
+
SourceSha: "pr-sha-789",
+
SourceBranch: "feature-branch",
+
TargetBranch: "main",
+
Action: "opened",
+
},
+
Repo: &tangled.Pipeline_TriggerRepo{
+
Knot: "example.com",
+
Did: "did:plc:user123",
+
Repo: "my-repo",
+
},
+
}
+
+
step := BuildCloneStep(twf, tr, "/tangled/workspace", false)
+
+
allCmds := strings.Join(step.Commands(), " ")
+
if !strings.Contains(allCmds, "pr-sha-789") {
+
t.Error("Commands should contain PR commit SHA")
+
}
+
}
+
+
func TestBuildCloneStep_ManualTrigger(t *testing.T) {
+
twf := tangled.Pipeline_Workflow{
+
Clone: &tangled.Pipeline_CloneOpts{
+
Depth: 1,
+
Skip: false,
+
},
+
}
+
tr := tangled.Pipeline_TriggerMetadata{
+
Kind: string(workflow.TriggerKindManual),
+
Manual: &tangled.Pipeline_ManualTriggerData{
+
Inputs: nil,
+
},
+
Repo: &tangled.Pipeline_TriggerRepo{
+
Knot: "example.com",
+
Did: "did:plc:user123",
+
Repo: "my-repo",
+
},
+
}
+
+
step := BuildCloneStep(twf, tr, "/tangled/workspace", false)
+
+
// Manual triggers don't have a SHA yet (TODO), so git fetch won't include a SHA
+
allCmds := strings.Join(step.Commands(), " ")
+
// Should still have basic git commands
+
if !strings.Contains(allCmds, "git init") {
+
t.Error("Commands should contain 'git init'")
+
}
+
if !strings.Contains(allCmds, "git fetch") {
+
t.Error("Commands should contain 'git fetch'")
+
}
+
}
+
+
func TestBuildCloneStep_SkipFlag(t *testing.T) {
+
twf := tangled.Pipeline_Workflow{
+
Clone: &tangled.Pipeline_CloneOpts{
+
Skip: true,
+
},
+
}
+
tr := tangled.Pipeline_TriggerMetadata{
+
Kind: string(workflow.TriggerKindPush),
+
Push: &tangled.Pipeline_PushTriggerData{
+
NewSha: "abc123",
+
},
+
Repo: &tangled.Pipeline_TriggerRepo{
+
Knot: "example.com",
+
Did: "did:plc:user123",
+
Repo: "my-repo",
+
},
+
}
+
+
step := BuildCloneStep(twf, tr, "/tangled/workspace", false)
+
+
// Empty step when skip is true
+
if step.Name() != "" {
+
t.Error("Expected empty step name when Skip is true")
+
}
+
if len(step.Commands()) != 0 {
+
t.Errorf("Expected no commands when Skip is true, got %d commands", len(step.Commands()))
+
}
+
}
+
+
func TestBuildCloneStep_DevMode(t *testing.T) {
+
twf := tangled.Pipeline_Workflow{
+
Clone: &tangled.Pipeline_CloneOpts{
+
Depth: 1,
+
Skip: false,
+
},
+
}
+
tr := tangled.Pipeline_TriggerMetadata{
+
Kind: string(workflow.TriggerKindPush),
+
Push: &tangled.Pipeline_PushTriggerData{
+
NewSha: "abc123",
+
},
+
Repo: &tangled.Pipeline_TriggerRepo{
+
Knot: "localhost:3000",
+
Did: "did:plc:user123",
+
Repo: "my-repo",
+
},
+
}
+
+
step := BuildCloneStep(twf, tr, "/tangled/workspace", true)
+
+
// In dev mode, should use http:// and replace localhost with host.docker.internal
+
allCmds := strings.Join(step.Commands(), " ")
+
expectedURL := "http://host.docker.internal:3000/did:plc:user123/my-repo"
+
if !strings.Contains(allCmds, expectedURL) {
+
t.Errorf("Expected dev mode URL '%s' in commands", expectedURL)
+
}
+
}
+
+
func TestBuildCloneStep_DepthAndSubmodules(t *testing.T) {
+
twf := tangled.Pipeline_Workflow{
+
Clone: &tangled.Pipeline_CloneOpts{
+
Depth: 10,
+
Submodules: true,
+
Skip: false,
+
},
+
}
+
tr := tangled.Pipeline_TriggerMetadata{
+
Kind: string(workflow.TriggerKindPush),
+
Push: &tangled.Pipeline_PushTriggerData{
+
NewSha: "abc123",
+
},
+
Repo: &tangled.Pipeline_TriggerRepo{
+
Knot: "example.com",
+
Did: "did:plc:user123",
+
Repo: "my-repo",
+
},
+
}
+
+
step := BuildCloneStep(twf, tr, "/tangled/workspace", false)
+
+
allCmds := strings.Join(step.Commands(), " ")
+
if !strings.Contains(allCmds, "--depth=10") {
+
t.Error("Commands should contain '--depth=10'")
+
}
+
+
if !strings.Contains(allCmds, "--recurse-submodules=yes") {
+
t.Error("Commands should contain '--recurse-submodules=yes'")
+
}
+
}
+
+
func TestBuildCloneStep_DefaultDepth(t *testing.T) {
+
twf := tangled.Pipeline_Workflow{
+
Clone: &tangled.Pipeline_CloneOpts{
+
Depth: 0, // Default should be 1
+
Skip: false,
+
},
+
}
+
tr := tangled.Pipeline_TriggerMetadata{
+
Kind: string(workflow.TriggerKindPush),
+
Push: &tangled.Pipeline_PushTriggerData{
+
NewSha: "abc123",
+
},
+
Repo: &tangled.Pipeline_TriggerRepo{
+
Knot: "example.com",
+
Did: "did:plc:user123",
+
Repo: "my-repo",
+
},
+
}
+
+
step := BuildCloneStep(twf, tr, "/tangled/workspace", false)
+
+
allCmds := strings.Join(step.Commands(), " ")
+
if !strings.Contains(allCmds, "--depth=1") {
+
t.Error("Commands should default to '--depth=1'")
+
}
+
}
+
+
func TestBuildCloneStep_NilPushData(t *testing.T) {
+
twf := tangled.Pipeline_Workflow{
+
Clone: &tangled.Pipeline_CloneOpts{
+
Depth: 1,
+
Skip: false,
+
},
+
}
+
tr := tangled.Pipeline_TriggerMetadata{
+
Kind: string(workflow.TriggerKindPush),
+
Push: nil, // Nil push data should create error step
+
Repo: &tangled.Pipeline_TriggerRepo{
+
Knot: "example.com",
+
Did: "did:plc:user123",
+
Repo: "my-repo",
+
},
+
}
+
+
step := BuildCloneStep(twf, tr, "/tangled/workspace", false)
+
+
// Should return an error step
+
if !strings.Contains(step.Name(), "error") {
+
t.Error("Expected error in step name when push data is nil")
+
}
+
+
allCmds := strings.Join(step.Commands(), " ")
+
if !strings.Contains(allCmds, "Failed to get clone info") {
+
t.Error("Commands should contain error message")
+
}
+
if !strings.Contains(allCmds, "exit 1") {
+
t.Error("Commands should exit with error")
+
}
+
}
+
+
func TestBuildCloneStep_NilPRData(t *testing.T) {
+
twf := tangled.Pipeline_Workflow{
+
Clone: &tangled.Pipeline_CloneOpts{
+
Depth: 1,
+
Skip: false,
+
},
+
}
+
tr := tangled.Pipeline_TriggerMetadata{
+
Kind: string(workflow.TriggerKindPullRequest),
+
PullRequest: nil, // Nil PR data should create error step
+
Repo: &tangled.Pipeline_TriggerRepo{
+
Knot: "example.com",
+
Did: "did:plc:user123",
+
Repo: "my-repo",
+
},
+
}
+
+
step := BuildCloneStep(twf, tr, "/tangled/workspace", false)
+
+
// Should return an error step
+
if !strings.Contains(step.Name(), "error") {
+
t.Error("Expected error in step name when pull request data is nil")
+
}
+
+
allCmds := strings.Join(step.Commands(), " ")
+
if !strings.Contains(allCmds, "Failed to get clone info") {
+
t.Error("Commands should contain error message")
+
}
+
}
+
+
func TestBuildCloneStep_CustomWorkspace(t *testing.T) {
+
twf := tangled.Pipeline_Workflow{
+
Clone: &tangled.Pipeline_CloneOpts{
+
Depth: 1,
+
Skip: false,
+
},
+
}
+
tr := tangled.Pipeline_TriggerMetadata{
+
Kind: string(workflow.TriggerKindPush),
+
Push: &tangled.Pipeline_PushTriggerData{
+
NewSha: "abc123",
+
},
+
Repo: &tangled.Pipeline_TriggerRepo{
+
Knot: "example.com",
+
Did: "did:plc:user123",
+
Repo: "my-repo",
+
},
+
}
+
+
step := BuildCloneStep(twf, tr, "/custom/path", false)
+
+
allCmds := strings.Join(step.Commands(), " ")
+
if !strings.Contains(allCmds, "/custom/path") {
+
t.Error("Commands should use custom workspace directory")
+
}
+
}
+
+
func TestBuildCloneStep_DefaultWorkspace(t *testing.T) {
+
twf := tangled.Pipeline_Workflow{
+
Clone: &tangled.Pipeline_CloneOpts{
+
Depth: 1,
+
Skip: false,
+
},
+
}
+
tr := tangled.Pipeline_TriggerMetadata{
+
Kind: string(workflow.TriggerKindPush),
+
Push: &tangled.Pipeline_PushTriggerData{
+
NewSha: "abc123",
+
},
+
Repo: &tangled.Pipeline_TriggerRepo{
+
Knot: "example.com",
+
Did: "did:plc:user123",
+
Repo: "my-repo",
+
},
+
}
+
+
step := BuildCloneStep(twf, tr, "", false) // Empty should default to /tangled/workspace
+
+
allCmds := strings.Join(step.Commands(), " ")
+
if !strings.Contains(allCmds, "/tangled/workspace") {
+
t.Error("Commands should default to /tangled/workspace")
+
}
+
}