forked from tangled.org/core
Monorepo for Tangled — https://tangled.org

spindle: move the clone step out of nixery into a shared function for all spindle engines

Signed-off-by: Evan Jarrett <evan@evanjarrett.com>

evan.jarrett.net d1a8d62b a60331db

verified
Changed files
+583 -58
spindle
engines
workflow
+19 -58
spindle/engines/nixery/setup_steps.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 - "path" 6 5 "strings" 7 6 8 7 "tangled.org/core/api/tangled" 9 - "tangled.org/core/workflow" 8 + "tangled.org/core/spindle/workflow" 10 9 ) 11 10 12 11 func nixConfStep() Step { ··· 19 18 } 20 19 } 21 20 22 - // cloneOptsAsSteps processes clone options and adds corresponding steps 23 - // to the beginning of the workflow's step list if cloning is not skipped. 24 - // 25 - // the steps to do here are: 21 + // cloneStep uses the shared clone step builder to generate git clone commands. 22 + // The shared builder handles: 26 23 // - git init 27 24 // - git remote add origin <url> 28 25 // - git fetch --depth=<d> --recurse-submodules=<yes|no> <sha> 29 26 // - git checkout FETCH_HEAD 27 + // And supports all trigger types (push, PR, manual) and clone options. 30 28 func cloneStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerMetadata, dev bool) Step { 31 - if twf.Clone.Skip { 32 - return Step{} 29 + info, err := workflow.GetCloneInfo(workflow.CloneOptions{ 30 + Workflow: twf, 31 + TriggerMetadata: tr, 32 + DevMode: dev, 33 + WorkspaceDir: workspaceDir, 34 + }) 35 + if err != nil { 36 + return Step{ 37 + command: fmt.Sprintf("echo 'Failed to get clone info: %s' && exit 1", err.Error()), 38 + name: "Clone repository into workspace (error)", 39 + } 33 40 } 34 41 35 - var commands []string 36 - 37 - // initialize git repo in workspace 38 - commands = append(commands, "git init") 39 - 40 - // add repo as git remote 41 - scheme := "https://" 42 - if dev { 43 - scheme = "http://" 44 - tr.Repo.Knot = strings.ReplaceAll(tr.Repo.Knot, "localhost", "host.docker.internal") 42 + if info.Skip { 43 + return Step{} 45 44 } 46 - url := scheme + path.Join(tr.Repo.Knot, tr.Repo.Did, tr.Repo.Repo) 47 - commands = append(commands, fmt.Sprintf("git remote add origin %s", url)) 48 45 49 - // run git fetch 50 - { 51 - var fetchArgs []string 52 - 53 - // default clone depth is 1 54 - depth := 1 55 - if twf.Clone.Depth > 1 { 56 - depth = int(twf.Clone.Depth) 57 - } 58 - fetchArgs = append(fetchArgs, fmt.Sprintf("--depth=%d", depth)) 59 - 60 - // optionally recurse submodules 61 - if twf.Clone.Submodules { 62 - fetchArgs = append(fetchArgs, "--recurse-submodules=yes") 63 - } 64 - 65 - // set remote to fetch from 66 - fetchArgs = append(fetchArgs, "origin") 67 - 68 - // set revision to checkout 69 - switch workflow.TriggerKind(tr.Kind) { 70 - case workflow.TriggerKindManual: 71 - // TODO: unimplemented 72 - case workflow.TriggerKindPush: 73 - fetchArgs = append(fetchArgs, tr.Push.NewSha) 74 - case workflow.TriggerKindPullRequest: 75 - fetchArgs = append(fetchArgs, tr.PullRequest.SourceSha) 76 - } 77 - 78 - commands = append(commands, fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " "))) 79 - } 80 - 81 - // run git checkout 82 - commands = append(commands, "git checkout FETCH_HEAD") 83 - 84 - cloneStep := Step{ 85 - command: strings.Join(commands, "\n"), 46 + return Step{ 47 + command: strings.Join(info.Commands, "\n"), 86 48 name: "Clone repository into workspace", 87 49 } 88 - return cloneStep 89 50 } 90 51 91 52 // dependencyStep processes dependencies defined in the workflow.
+144
spindle/workflow/clone.go
··· 1 + package workflow 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "tangled.org/core/api/tangled" 8 + "tangled.org/core/workflow" 9 + ) 10 + 11 + type CloneOptions struct { 12 + Workflow tangled.Pipeline_Workflow 13 + TriggerMetadata tangled.Pipeline_TriggerMetadata 14 + DevMode bool 15 + WorkspaceDir string 16 + } 17 + 18 + type CloneInfo struct { 19 + Commands []string 20 + RepoURL string 21 + CommitSHA string 22 + Skip bool 23 + } 24 + 25 + // GetCloneInfo generates git clone commands and metadata from pipeline trigger metadata 26 + func GetCloneInfo(opts CloneOptions) (*CloneInfo, error) { 27 + if opts.Workflow.Clone != nil && opts.Workflow.Clone.Skip { 28 + return &CloneInfo{Skip: true}, nil 29 + } 30 + 31 + commitSHA, err := extractCommitSHA(opts.TriggerMetadata) 32 + if err != nil { 33 + return nil, fmt.Errorf("failed to extract commit SHA: %w", err) 34 + } 35 + 36 + repoURL := buildRepoURL(opts.TriggerMetadata, opts.DevMode) 37 + 38 + workspaceDir := opts.WorkspaceDir 39 + if workspaceDir == "" { 40 + workspaceDir = "/tangled/workspace" 41 + } 42 + 43 + initCmd := fmt.Sprintf("git init %s", workspaceDir) 44 + remoteCmd := fmt.Sprintf("git remote add origin %s", repoURL) 45 + 46 + var cloneOpts tangled.Pipeline_CloneOpts 47 + if opts.Workflow.Clone != nil { 48 + cloneOpts = *opts.Workflow.Clone 49 + } 50 + fetchArgs := buildFetchArgs(cloneOpts, commitSHA) 51 + fetchCmd := fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " ")) 52 + 53 + checkoutCmd := "git checkout FETCH_HEAD" 54 + 55 + commands := []string{ 56 + initCmd, 57 + fmt.Sprintf("cd %s", workspaceDir), 58 + remoteCmd, 59 + fetchCmd, 60 + checkoutCmd, 61 + } 62 + 63 + return &CloneInfo{ 64 + Commands: commands, 65 + RepoURL: repoURL, 66 + CommitSHA: commitSHA, 67 + Skip: false, 68 + }, nil 69 + } 70 + 71 + // extractCommitSHA extracts the commit SHA from trigger metadata based on trigger type 72 + func extractCommitSHA(tr tangled.Pipeline_TriggerMetadata) (string, error) { 73 + switch workflow.TriggerKind(tr.Kind) { 74 + case workflow.TriggerKindPush: 75 + if tr.Push == nil { 76 + return "", fmt.Errorf("push trigger metadata is nil") 77 + } 78 + return tr.Push.NewSha, nil 79 + 80 + case workflow.TriggerKindPullRequest: 81 + if tr.PullRequest == nil { 82 + return "", fmt.Errorf("pull request trigger metadata is nil") 83 + } 84 + return tr.PullRequest.SourceSha, nil 85 + 86 + case workflow.TriggerKindManual: 87 + // Manual triggers don't have an explicit SHA in the metadata 88 + // For now, return empty string - could be enhanced to fetch from default branch 89 + // TODO: Implement manual trigger SHA resolution (fetch default branch HEAD) 90 + return "", nil 91 + 92 + default: 93 + return "", fmt.Errorf("unknown trigger kind: %s", tr.Kind) 94 + } 95 + } 96 + 97 + // buildRepoURL constructs the repository URL from trigger metadata 98 + func buildRepoURL(tr tangled.Pipeline_TriggerMetadata, devMode bool) string { 99 + if tr.Repo == nil { 100 + return "" 101 + } 102 + 103 + // Determine protocol 104 + scheme := "https://" 105 + if devMode { 106 + scheme = "http://" 107 + } 108 + 109 + // Get host from knot 110 + host := tr.Repo.Knot 111 + 112 + // In dev mode, replace localhost with host.docker.internal for Docker networking 113 + if devMode && strings.Contains(host, "localhost") { 114 + host = strings.ReplaceAll(host, "localhost", "host.docker.internal") 115 + } 116 + 117 + // Build URL: {scheme}{knot}/{did}/{repo} 118 + return fmt.Sprintf("%s%s/%s/%s", scheme, host, tr.Repo.Did, tr.Repo.Repo) 119 + } 120 + 121 + // buildFetchArgs constructs the arguments for git fetch based on clone options 122 + func buildFetchArgs(clone tangled.Pipeline_CloneOpts, sha string) []string { 123 + args := []string{} 124 + 125 + // Set fetch depth (default to 1 for shallow clone) 126 + depth := clone.Depth 127 + if depth == 0 { 128 + depth = 1 129 + } 130 + args = append(args, fmt.Sprintf("--depth=%d", depth)) 131 + 132 + // Add submodules if requested 133 + if clone.Submodules { 134 + args = append(args, "--recurse-submodules=yes") 135 + } 136 + 137 + // Add remote and SHA 138 + args = append(args, "origin") 139 + if sha != "" { 140 + args = append(args, sha) 141 + } 142 + 143 + return args 144 + }
+420
spindle/workflow/clone_test.go
··· 1 + package workflow 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + 7 + "tangled.org/core/api/tangled" 8 + "tangled.org/core/workflow" 9 + ) 10 + 11 + func TestGetCloneInfo_PushTrigger(t *testing.T) { 12 + cfg := CloneOptions{ 13 + Workflow: tangled.Pipeline_Workflow{ 14 + Clone: &tangled.Pipeline_CloneOpts{ 15 + Depth: 1, 16 + Submodules: false, 17 + Skip: false, 18 + }, 19 + }, 20 + TriggerMetadata: tangled.Pipeline_TriggerMetadata{ 21 + Kind: string(workflow.TriggerKindPush), 22 + Push: &tangled.Pipeline_PushTriggerData{ 23 + NewSha: "abc123", 24 + OldSha: "def456", 25 + Ref: "refs/heads/main", 26 + }, 27 + Repo: &tangled.Pipeline_TriggerRepo{ 28 + Knot: "example.com", 29 + Did: "did:plc:user123", 30 + Repo: "my-repo", 31 + }, 32 + }, 33 + DevMode: false, 34 + WorkspaceDir: "/tangled/workspace", 35 + } 36 + 37 + info, err := GetCloneInfo(cfg) 38 + if err != nil { 39 + t.Fatalf("GetCloneInfo failed: %v", err) 40 + } 41 + 42 + if info.Skip { 43 + t.Error("Expected Skip to be false") 44 + } 45 + 46 + if info.CommitSHA != "abc123" { 47 + t.Errorf("Expected CommitSHA 'abc123', got '%s'", info.CommitSHA) 48 + } 49 + 50 + expectedURL := "https://example.com/did:plc:user123/my-repo" 51 + if info.RepoURL != expectedURL { 52 + t.Errorf("Expected RepoURL '%s', got '%s'", expectedURL, info.RepoURL) 53 + } 54 + 55 + if len(info.Commands) != 5 { 56 + t.Errorf("Expected 5 commands, got %d", len(info.Commands)) 57 + } 58 + 59 + // Verify commands contain expected git operations 60 + allCmds := strings.Join(info.Commands, " ") 61 + if !strings.Contains(allCmds, "git init") { 62 + t.Error("Commands should contain 'git init'") 63 + } 64 + if !strings.Contains(allCmds, "git remote add origin") { 65 + t.Error("Commands should contain 'git remote add origin'") 66 + } 67 + if !strings.Contains(allCmds, "git fetch") { 68 + t.Error("Commands should contain 'git fetch'") 69 + } 70 + if !strings.Contains(allCmds, "abc123") { 71 + t.Error("Commands should contain commit SHA") 72 + } 73 + if !strings.Contains(allCmds, "git checkout FETCH_HEAD") { 74 + t.Error("Commands should contain 'git checkout FETCH_HEAD'") 75 + } 76 + } 77 + 78 + func TestGetCloneInfo_PullRequestTrigger(t *testing.T) { 79 + cfg := CloneOptions{ 80 + Workflow: tangled.Pipeline_Workflow{ 81 + Clone: &tangled.Pipeline_CloneOpts{ 82 + Depth: 1, 83 + Skip: false, 84 + }, 85 + }, 86 + TriggerMetadata: tangled.Pipeline_TriggerMetadata{ 87 + Kind: string(workflow.TriggerKindPullRequest), 88 + PullRequest: &tangled.Pipeline_PullRequestTriggerData{ 89 + SourceSha: "pr-sha-789", 90 + SourceBranch: "feature-branch", 91 + TargetBranch: "main", 92 + Action: "opened", 93 + }, 94 + Repo: &tangled.Pipeline_TriggerRepo{ 95 + Knot: "example.com", 96 + Did: "did:plc:user123", 97 + Repo: "my-repo", 98 + }, 99 + }, 100 + DevMode: false, 101 + WorkspaceDir: "/tangled/workspace", 102 + } 103 + 104 + info, err := GetCloneInfo(cfg) 105 + if err != nil { 106 + t.Fatalf("GetCloneInfo failed: %v", err) 107 + } 108 + 109 + if info.CommitSHA != "pr-sha-789" { 110 + t.Errorf("Expected CommitSHA 'pr-sha-789', got '%s'", info.CommitSHA) 111 + } 112 + 113 + allCmds := strings.Join(info.Commands, " ") 114 + if !strings.Contains(allCmds, "pr-sha-789") { 115 + t.Error("Commands should contain PR commit SHA") 116 + } 117 + } 118 + 119 + func TestGetCloneInfo_ManualTrigger(t *testing.T) { 120 + cfg := CloneOptions{ 121 + Workflow: tangled.Pipeline_Workflow{ 122 + Clone: &tangled.Pipeline_CloneOpts{ 123 + Depth: 1, 124 + Skip: false, 125 + }, 126 + }, 127 + TriggerMetadata: tangled.Pipeline_TriggerMetadata{ 128 + Kind: string(workflow.TriggerKindManual), 129 + Manual: &tangled.Pipeline_ManualTriggerData{ 130 + Inputs: nil, 131 + }, 132 + Repo: &tangled.Pipeline_TriggerRepo{ 133 + Knot: "example.com", 134 + Did: "did:plc:user123", 135 + Repo: "my-repo", 136 + }, 137 + }, 138 + DevMode: false, 139 + WorkspaceDir: "/tangled/workspace", 140 + } 141 + 142 + info, err := GetCloneInfo(cfg) 143 + if err != nil { 144 + t.Fatalf("GetCloneInfo failed: %v", err) 145 + } 146 + 147 + // Manual triggers don't have a SHA yet (TODO) 148 + if info.CommitSHA != "" { 149 + t.Errorf("Expected empty CommitSHA for manual trigger, got '%s'", info.CommitSHA) 150 + } 151 + } 152 + 153 + func TestGetCloneInfo_SkipFlag(t *testing.T) { 154 + cfg := CloneOptions{ 155 + Workflow: tangled.Pipeline_Workflow{ 156 + Clone: &tangled.Pipeline_CloneOpts{ 157 + Skip: true, 158 + }, 159 + }, 160 + TriggerMetadata: tangled.Pipeline_TriggerMetadata{ 161 + Kind: string(workflow.TriggerKindPush), 162 + Push: &tangled.Pipeline_PushTriggerData{ 163 + NewSha: "abc123", 164 + }, 165 + Repo: &tangled.Pipeline_TriggerRepo{ 166 + Knot: "example.com", 167 + Did: "did:plc:user123", 168 + Repo: "my-repo", 169 + }, 170 + }, 171 + } 172 + 173 + info, err := GetCloneInfo(cfg) 174 + if err != nil { 175 + t.Fatalf("GetCloneInfo failed: %v", err) 176 + } 177 + 178 + if !info.Skip { 179 + t.Error("Expected Skip to be true") 180 + } 181 + 182 + if len(info.Commands) != 0 { 183 + t.Errorf("Expected no commands when Skip is true, got %d commands", len(info.Commands)) 184 + } 185 + } 186 + 187 + func TestGetCloneInfo_DevMode(t *testing.T) { 188 + cfg := CloneOptions{ 189 + Workflow: tangled.Pipeline_Workflow{ 190 + Clone: &tangled.Pipeline_CloneOpts{ 191 + Depth: 1, 192 + Skip: false, 193 + }, 194 + }, 195 + TriggerMetadata: tangled.Pipeline_TriggerMetadata{ 196 + Kind: string(workflow.TriggerKindPush), 197 + Push: &tangled.Pipeline_PushTriggerData{ 198 + NewSha: "abc123", 199 + }, 200 + Repo: &tangled.Pipeline_TriggerRepo{ 201 + Knot: "localhost:3000", 202 + Did: "did:plc:user123", 203 + Repo: "my-repo", 204 + }, 205 + }, 206 + DevMode: true, 207 + WorkspaceDir: "/tangled/workspace", 208 + } 209 + 210 + info, err := GetCloneInfo(cfg) 211 + if err != nil { 212 + t.Fatalf("GetCloneInfo failed: %v", err) 213 + } 214 + 215 + // In dev mode, should use http:// and replace localhost with host.docker.internal 216 + expectedURL := "http://host.docker.internal:3000/did:plc:user123/my-repo" 217 + if info.RepoURL != expectedURL { 218 + t.Errorf("Expected dev mode URL '%s', got '%s'", expectedURL, info.RepoURL) 219 + } 220 + } 221 + 222 + func TestGetCloneInfo_DepthAndSubmodules(t *testing.T) { 223 + cfg := CloneOptions{ 224 + Workflow: tangled.Pipeline_Workflow{ 225 + Clone: &tangled.Pipeline_CloneOpts{ 226 + Depth: 10, 227 + Submodules: true, 228 + Skip: false, 229 + }, 230 + }, 231 + TriggerMetadata: tangled.Pipeline_TriggerMetadata{ 232 + Kind: string(workflow.TriggerKindPush), 233 + Push: &tangled.Pipeline_PushTriggerData{ 234 + NewSha: "abc123", 235 + }, 236 + Repo: &tangled.Pipeline_TriggerRepo{ 237 + Knot: "example.com", 238 + Did: "did:plc:user123", 239 + Repo: "my-repo", 240 + }, 241 + }, 242 + DevMode: false, 243 + WorkspaceDir: "/tangled/workspace", 244 + } 245 + 246 + info, err := GetCloneInfo(cfg) 247 + if err != nil { 248 + t.Fatalf("GetCloneInfo failed: %v", err) 249 + } 250 + 251 + allCmds := strings.Join(info.Commands, " ") 252 + if !strings.Contains(allCmds, "--depth=10") { 253 + t.Error("Commands should contain '--depth=10'") 254 + } 255 + 256 + if !strings.Contains(allCmds, "--recurse-submodules=yes") { 257 + t.Error("Commands should contain '--recurse-submodules=yes'") 258 + } 259 + } 260 + 261 + func TestGetCloneInfo_DefaultDepth(t *testing.T) { 262 + cfg := CloneOptions{ 263 + Workflow: tangled.Pipeline_Workflow{ 264 + Clone: &tangled.Pipeline_CloneOpts{ 265 + Depth: 0, // Default should be 1 266 + Skip: false, 267 + }, 268 + }, 269 + TriggerMetadata: tangled.Pipeline_TriggerMetadata{ 270 + Kind: string(workflow.TriggerKindPush), 271 + Push: &tangled.Pipeline_PushTriggerData{ 272 + NewSha: "abc123", 273 + }, 274 + Repo: &tangled.Pipeline_TriggerRepo{ 275 + Knot: "example.com", 276 + Did: "did:plc:user123", 277 + Repo: "my-repo", 278 + }, 279 + }, 280 + WorkspaceDir: "/tangled/workspace", 281 + } 282 + 283 + info, err := GetCloneInfo(cfg) 284 + if err != nil { 285 + t.Fatalf("GetCloneInfo failed: %v", err) 286 + } 287 + 288 + allCmds := strings.Join(info.Commands, " ") 289 + if !strings.Contains(allCmds, "--depth=1") { 290 + t.Error("Commands should default to '--depth=1'") 291 + } 292 + } 293 + 294 + func TestGetCloneInfo_NilPushData(t *testing.T) { 295 + cfg := CloneOptions{ 296 + Workflow: tangled.Pipeline_Workflow{ 297 + Clone: &tangled.Pipeline_CloneOpts{ 298 + Depth: 1, 299 + Skip: false, 300 + }, 301 + }, 302 + TriggerMetadata: tangled.Pipeline_TriggerMetadata{ 303 + Kind: string(workflow.TriggerKindPush), 304 + Push: nil, // Nil push data should return error 305 + Repo: &tangled.Pipeline_TriggerRepo{ 306 + Knot: "example.com", 307 + Did: "did:plc:user123", 308 + Repo: "my-repo", 309 + }, 310 + }, 311 + WorkspaceDir: "/tangled/workspace", 312 + } 313 + 314 + _, err := GetCloneInfo(cfg) 315 + if err == nil { 316 + t.Error("Expected error when push data is nil") 317 + } 318 + 319 + if !strings.Contains(err.Error(), "push trigger metadata is nil") { 320 + t.Errorf("Expected error about nil push metadata, got: %v", err) 321 + } 322 + } 323 + 324 + func TestGetCloneInfo_NilPRData(t *testing.T) { 325 + cfg := CloneOptions{ 326 + Workflow: tangled.Pipeline_Workflow{ 327 + Clone: &tangled.Pipeline_CloneOpts{ 328 + Depth: 1, 329 + Skip: false, 330 + }, 331 + }, 332 + TriggerMetadata: tangled.Pipeline_TriggerMetadata{ 333 + Kind: string(workflow.TriggerKindPullRequest), 334 + PullRequest: nil, // Nil PR data should return error 335 + Repo: &tangled.Pipeline_TriggerRepo{ 336 + Knot: "example.com", 337 + Did: "did:plc:user123", 338 + Repo: "my-repo", 339 + }, 340 + }, 341 + WorkspaceDir: "/tangled/workspace", 342 + } 343 + 344 + _, err := GetCloneInfo(cfg) 345 + if err == nil { 346 + t.Error("Expected error when pull request data is nil") 347 + } 348 + 349 + if !strings.Contains(err.Error(), "pull request trigger metadata is nil") { 350 + t.Errorf("Expected error about nil PR metadata, got: %v", err) 351 + } 352 + } 353 + 354 + func TestGetCloneInfo_CustomWorkspace(t *testing.T) { 355 + cfg := CloneOptions{ 356 + Workflow: tangled.Pipeline_Workflow{ 357 + Clone: &tangled.Pipeline_CloneOpts{ 358 + Depth: 1, 359 + Skip: false, 360 + }, 361 + }, 362 + TriggerMetadata: tangled.Pipeline_TriggerMetadata{ 363 + Kind: string(workflow.TriggerKindPush), 364 + Push: &tangled.Pipeline_PushTriggerData{ 365 + NewSha: "abc123", 366 + }, 367 + Repo: &tangled.Pipeline_TriggerRepo{ 368 + Knot: "example.com", 369 + Did: "did:plc:user123", 370 + Repo: "my-repo", 371 + }, 372 + }, 373 + DevMode: false, 374 + WorkspaceDir: "/custom/path", 375 + } 376 + 377 + info, err := GetCloneInfo(cfg) 378 + if err != nil { 379 + t.Fatalf("GetCloneInfo failed: %v", err) 380 + } 381 + 382 + allCmds := strings.Join(info.Commands, " ") 383 + if !strings.Contains(allCmds, "/custom/path") { 384 + t.Error("Commands should use custom workspace directory") 385 + } 386 + } 387 + 388 + func TestGetCloneInfo_DefaultWorkspace(t *testing.T) { 389 + cfg := CloneOptions{ 390 + Workflow: tangled.Pipeline_Workflow{ 391 + Clone: &tangled.Pipeline_CloneOpts{ 392 + Depth: 1, 393 + Skip: false, 394 + }, 395 + }, 396 + TriggerMetadata: tangled.Pipeline_TriggerMetadata{ 397 + Kind: string(workflow.TriggerKindPush), 398 + Push: &tangled.Pipeline_PushTriggerData{ 399 + NewSha: "abc123", 400 + }, 401 + Repo: &tangled.Pipeline_TriggerRepo{ 402 + Knot: "example.com", 403 + Did: "did:plc:user123", 404 + Repo: "my-repo", 405 + }, 406 + }, 407 + DevMode: false, 408 + WorkspaceDir: "", // Empty should default to /tangled/workspace 409 + } 410 + 411 + info, err := GetCloneInfo(cfg) 412 + if err != nil { 413 + t.Fatalf("GetCloneInfo failed: %v", err) 414 + } 415 + 416 + allCmds := strings.Join(info.Commands, " ") 417 + if !strings.Contains(allCmds, "/tangled/workspace") { 418 + t.Error("Commands should default to /tangled/workspace") 419 + } 420 + }