Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions cli/test/executor.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
import { execFileSync } from "node:child_process"
import * as fs from "node:fs"
import * as path from "node:path"
import * as os from "node:os"
Expand Down Expand Up @@ -74,3 +75,59 @@ describe("TestExecutor — config-error surfacing", () => {
await executor.init()
})
})

// ---------------------------------------------------------------------------
// GitClone with source="local": adopt a checkout that already exists instead
// of cloning it.
// ---------------------------------------------------------------------------

describe("TestExecutor — GitClone local checkout", () => {
let tmp: string

const runLocalGitClone = async (repoDir: string) => {
const rb = path.join(tmp, "runbook.mdx")
fs.writeFileSync(
rb,
`# Local checkout\n\n<GitClone id="repo" source="local" prefilledRepoDir="${repoDir}" />\n`,
)
const executor = new TestExecutor(rb, tmp, "generated", { timeout: 30_000, verbose: false })
await executor.init()
return executor.runTest({
name: "local",
steps: [{ block: "repo", expect: "success" }],
})
}

beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "rb-exec-local-"))
})
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true })
})

it("adopts an existing checkout and emits clone_path", async () => {
const repo = path.join(tmp, "checkout")
fs.mkdirSync(repo)
execFileSync("git", ["init", "-q"], { cwd: repo })
fs.writeFileSync(path.join(repo, "main.tf"), "# tf\n")
execFileSync("git", ["add", "."], { cwd: repo })

const result = await runLocalGitClone(repo)

expect(result.stepResults[0]?.actualStatus).toBe("success")
// git init resolves through symlinks on macOS (/var → /private/var), so
// compare against the repo's own resolved path.
expect(result.stepResults[0]?.outputs?.clone_path).toBe(fs.realpathSync(repo))
expect(result.stepResults[0]?.outputs?.file_count).toBe("1")
})

it("fails when the directory is not a git repository", async () => {
const notARepo = path.join(tmp, "notes")
fs.mkdirSync(notARepo)

const result = await runLocalGitClone(notARepo)

expect(result.stepResults[0]?.actualStatus).toBe("fail")
expect(result.stepResults[0]?.error).toMatch(/Not a git repository/)
})
})
80 changes: 80 additions & 0 deletions cli/test/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1271,6 +1271,14 @@ export class TestExecutor {
private runGitClone(block: ParsedComponent, step: TestStep, start: number): StepResult {
const result = makeStepResult(`gitClone:${block.id}`, step.expect)

// A block pointed at an existing checkout clones nothing — it just adopts
// the directory, mirroring the app's "Use local checkout" source.
const repoDir = extractProp(block.props, "prefilledRepoDir")
const source = extractProp(block.props, "source")
if (source === "local" || (!source && repoDir)) {
return this.runGitCloneLocal(block, step, start, result, repoDir)
}

const cloneURL = extractProp(block.props, "prefilledUrl")
const ref = extractProp(block.props, "prefilledRef")
const repoPath = extractProp(block.props, "prefilledRepoPath")
Expand Down Expand Up @@ -1383,6 +1391,78 @@ export class TestExecutor {
return result
}

/**
* GitClone with `source="local"`: adopt an existing checkout instead of
* cloning it. Emits the same `clone_path` output and becomes the active
* worktree, so the rest of the runbook behaves identically either way.
*/
private runGitCloneLocal(
block: ParsedComponent,
step: TestStep,
start: number,
result: StepResult,
repoDir: string | undefined,
): StepResult {
if (!repoDir) {
this.blockStates.set(block.id, "skipped")
result.actualStatus = "skipped"
result.passed = this.matchesExpectedStatus(step.expect, "skipped")
result.duration = Date.now() - start
if (this.options.verbose) console.log("--- No prefilledRepoDir specified ---")
return result
}

const resolved = path.isAbsolute(repoDir) ? repoDir : path.join(this.workingDir, repoDir)

let repoRoot: string
try {
repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
cwd: resolved,
timeout: 30000,
stdio: "pipe",
})
.toString()
.trim()
} catch (e: unknown) {
result.passed = this.matchesExpectedStatus(step.expect, "fail")
result.actualStatus = "fail"
result.error = `Not a git repository: ${resolved} (${String(e)})`
result.duration = Date.now() - start
return result
}

// Count TRACKED files, as the app does for a local checkout: walking the
// directory would count .git internals and ignored build output, which say
// nothing about the repo the user picked.
let fileCount = 0
try {
const tracked = execFileSync("git", ["ls-files"], {
cwd: repoRoot,
timeout: 30000,
stdio: "pipe",
}).toString()
fileCount = tracked.split("\n").filter((line) => line.trim() !== "").length
} catch {
// Best-effort: a repo with no commits still counts as usable.
}

result.outputs = { clone_path: repoRoot, file_count: String(fileCount) }
this.blockOutputs.set(block.id, new Map(Object.entries(result.outputs)))

this.activeWorkTreePath = repoRoot
this.blockStates.set(block.id, "success")
result.actualStatus = "success"
result.passed = this.matchesExpectedStatus(step.expect, "success")
result.duration = Date.now() - start

if (this.options.verbose) {
console.log(`--- Using local checkout ${repoRoot}: ${fileCount} files ---`)
console.log("--- Result: ✓ success ---")
}

return result
}

// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
Expand Down
39 changes: 38 additions & 1 deletion docs/src/content/docs/authoring/blocks/GitClone.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ title: <GitClone>

import { Aside } from '@astrojs/starlight/components';

The `<GitClone>` block provides a streamlined way to clone git repositories. It works with any git upstream, and includes optional GitHub integration for browsing organizations, repositories, and branches when a GitHub token is available.
The `<GitClone>` block provides a streamlined way to bring a git repository into a runbook: clone it fresh, or point at a checkout the user already has on disk. It works with any git upstream, and includes optional GitHub integration for browsing organizations, repositories, and branches when a GitHub token is available.

Compared to using the [Command](/authoring/blocks/command/) block to clone a git repository, the GitClone block provides a purpose-built UI for cloning a git repository, the ability to search the GitHub API for orgs, repos, branches, and tags, and automatically shows the [file workspace](/authoring/workspace), where users can see the contents of the cloned repository, along with any changes to it.

Expand Down Expand Up @@ -39,6 +39,36 @@ When paired with a `<GitHubAuth>` block, the GitClone block enables a "Browse Gi
/>
```

### Using a Local Checkout

Users often already have the repository cloned — a long-lived `infrastructure-live` checkout, a work in progress branch, a monorepo they never want to re-download. The block's source picker lets them choose **Use local checkout** and select that directory instead of cloning.

Selecting a directory reads it only: the block resolves the repository root (so any subdirectory of the checkout works), reads the `origin` remote and current branch, and registers the repo exactly as a clone would. Nothing is fetched, pulled, or modified, and no credentials are needed — the files are already there.

```mdx
<GitClone
id="repo"
title="Select Your Infrastructure Repo"
prefilledRepoDir="~/dev/infrastructure-live"
/>
```

Setting `prefilledRepoDir` starts the block on the local source. Use `source` to choose the starting source explicitly, and `hideSourceSelect` to remove the choice altogether:

```mdx
{/* Local checkouts only — no cloning offered */}
<GitClone id="repo" source="local" hideSourceSelect />

{/* Cloning only, as before */}
<GitClone id="repo" source="clone" hideSourceSelect prefilledUrl="https://github.com/acme/infra" />
```

Everything downstream behaves the same either way: the same `clone_path`, `repo_owner`, and `repo_name` outputs, the same `$REPO_FILES` variable, the same workspace file tree, and the same [`<GitPullRequest>`](/authoring/blocks/gitpullrequest) integration — a pull request opens against the checkout's `origin` remote and current branch.

<Aside type="caution">
A checkout with no `origin` remote can still be selected, but blocks that open a pull request need one. The block says so inline when it finds no remote.
</Aside>

### Pre-filled Values

You can pre-populate the URL, ref, sparse checkout path, and local path fields. Users can still edit these values before cloning. This follows the same pattern as the `prefilledVariables` prop on the [Inputs](/authoring/blocks/inputs) block.
Expand Down Expand Up @@ -68,6 +98,9 @@ You can pre-populate the URL, ref, sparse checkout path, and local path fields.
| `prefilledLocalPath` | `string` | No | Pre-fills the local path (relative to the current working directory) where files will be cloned. Defaults to the repository name if empty. |
| `usePty` | `boolean` | No | Whether to use a pseudo-terminal (PTY) for the git clone process. Defaults to `true`. PTY enables rich output like progress bars and colors. Set to `false` if your environment doesn't support PTY. |
| `showFileTree` | `boolean` | No | Whether to show the cloned repository's file tree in the workspace panel after cloning. Defaults to `true`. When enabled, the "All files" and "Changed" tabs display the cloned files and any subsequent modifications. |
| `source` | `'clone' \| 'local'` | No | Which source the block starts on: clone a remote repo, or use an existing local checkout. Defaults to `local` when `prefilledRepoDir` is set, otherwise `clone`. |
| `hideSourceSelect` | `boolean` | No | Hides the source picker and locks the block to `source`. Defaults to `false`. |
| `prefilledRepoDir` | `string` | No | Pre-fills the local checkout directory. Any directory inside the checkout works — the repository root is resolved from it. |

### Ref Selection

Expand All @@ -89,6 +122,8 @@ When `showFileTree` is `true` (the default), the GitClone block registers the cl

Set `showFileTree={false}` if you don't want the cloned repository to appear in the workspace panel (e.g., for helper repositories that the user doesn't need to browse).

A local checkout registers with the workspace the same way. Note that the **Changed** tab then shows any uncommitted changes the checkout already had, not just the ones the runbook makes.

### Accepted Git URL Formats

The GitClone block accepts the following URL formats:
Expand Down Expand Up @@ -168,6 +203,8 @@ After a successful clone, the GitClone block produces outputs that can be refere
| `repo_id` | Immutable GitHub numeric ID of the repository (when a GitHub token is available) | `87654321` |
| `repo_url` | The full URL of the cloned repository | `https://github.com/acme-corp/infrastructure-live` |

For a local checkout, `clone_path` is the repository root the user selected, and `repo_owner` / `repo_name` come from its `origin` remote (omitted when the repo has no remote).

Reference outputs in downstream blocks using template variables:

```mdx
Expand Down
88 changes: 88 additions & 0 deletions electron/main/ipc/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
parseOwnerRepoFromURL,
type CreatePullRequestParams,
} from "../../../src/domain/git/operations.ts"
import { inspectLocalRepo } from "../../../src/domain/git/local-repo.ts"
import { getRepo } from "../../../src/domain/github/auth.ts"
import { injectTokenIntoUrl } from "../../../src/domain/git/url.ts"
import { gitSpawnEnv } from "../../../src/domain/git/env.ts"
Expand All @@ -30,6 +31,7 @@ import { isContainedIn } from "../../../src/path-validation.ts"
import { PathTraversalError, GitError, GitHubApiError, GitLabApiError } from "../../../src/errors/index.ts"
import { validateSessionPath } from "./path-guard.ts"
import { makeLogger } from "../logger.ts"
import type { GitLocalRepoResponse } from "../../shared/channels.ts"

const log = makeLogger("ipc:git:clone")

Expand Down Expand Up @@ -408,6 +410,92 @@ export function registerGitHandlers(): void {
},
)

// Select an existing local checkout instead of cloning. The user picks the
// directory (native dialog or by typing a path), so registering it as a
// worktree here is the grant that lets the workspace/PR handlers touch a
// repo outside the session working directory.
ipcMain.handle(
"git:local-repo",
async (
_event,
params: { path: string; register?: boolean; provider?: "github" | "gitlab" },
): Promise<GitLocalRepoResponse> => {
const program = Effect.gen(function* () {
const session = yield* sessionManager.getSession()
const info = yield* inspectLocalRepo(params.path, session.workingDir)

if (params.register) {
sessionManager.registerWorkTreePath(info.absolutePath)
log.debug("registered local checkout as worktree:", info.absolutePath)
}

// Same output contract as a clone, so runbooks referencing
// {{ .outputs.<id>.clone_path }} work with either source.
const outputs: Record<string, string> = {
clone_path: info.absolutePath,
...(info.owner && info.repo
? { repo_owner: info.owner, repo_name: info.repo }
: {}),
}

// GitHub numeric IDs, when a token is available — mirrors git:clone.
if (params.register && info.owner && info.repo && params.provider !== "gitlab") {
const token = yield* Effect.either(
getSessionTokenForProvider(
"github",
() =>
new GitError({
command: "resolve git token",
stderr: "no session token",
exitCode: 1,
}),
),
)
if (token._tag === "Right") {
const repoResult = yield* Effect.either(
getRepo(token.right, info.owner, info.repo),
)
if (repoResult._tag === "Right") {
outputs.org_id = String(repoResult.right.ownerId)
outputs.repo_id = String(repoResult.right.id)
} else {
log.debug(
"failed to resolve GitHub org/repo IDs (non-fatal):",
repoResult.left,
)
}
}
}

return {
status: "success" as const,
absolutePath: info.absolutePath,
relativePath: info.relativePath,
fileCount: info.fileCount,
remoteUrl: info.remoteUrl,
ref: info.branch,
refType: info.refType,
commitSha: info.commitSha,
outputs,
}
})

// A bad directory is user input, not an exception: return the message so
// the block renders it inline instead of throwing across IPC.
const exit = await runtime.runPromiseExit(program)
if (Exit.isSuccess(exit)) return exit.value

const failure = Cause.failureOption(exit.cause)
return {
status: "fail" as const,
error:
failure._tag === "Some"
? errorMessage(failure.value)
: Cause.pretty(exit.cause),
}
},
)

ipcMain.handle(
"git:push",
async (
Expand Down
9 changes: 9 additions & 0 deletions electron/main/ipc/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,20 @@ import path from "path"
/**
* Resolve a renderer-supplied worktree path and fail if it escapes the session
* working directory (or the runbook directory). Returns the resolved path.
*
* Paths already registered as worktrees pass regardless of location: a local
* checkout the user selected in a <GitClone> block lives wherever they keep
* their repos, and that selection is what granted access in the first place
* (see the git:local-repo handler). This does not widen the grant — an
* unregistered path outside the session still fails.
*/
const resolveValidatedWorktree = (worktreePath: string) =>
Effect.gen(function* () {
const resolved = path.resolve(worktreePath)
const session = yield* sessionManager.getSession()
if (session.registeredWorkTreePaths.includes(resolved)) {
return resolved
}
const runbookDir = runbookConfig.localPath ? path.dirname(runbookConfig.localPath) : null
if (
!isContainedIn(resolved, session.workingDir) &&
Expand Down
2 changes: 1 addition & 1 deletion electron/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const ALLOWED_INVOKE_CHANNELS: Set<string> = new Set<InvokeChannel>([
"gitlab:validate", "gitlab:env-credentials", "gitlab:cli-credentials", "gitlab:labels", "gitlab:enumerate-hosts",
"gitlab:host-picked",
"vcs:cli-status", "vcs:invalidate-cache", "vcs:apply-git-schannel",
"git:clone", "git:push", "git:pull-request", "git:merge-request", "git:delete-branch",
"git:clone", "git:local-repo", "git:push", "git:pull-request", "git:merge-request", "git:delete-branch",
"workspace:tree", "workspace:dirs", "workspace:file", "workspace:changes",
"workspace:register", "workspace:set-active",
"generated-files:check", "generated-files:delete",
Expand Down
Loading
Loading