Skip to content

Commit bfb2476

Browse files
odgrimclaude
andauthored
feat(GitClone): select an existing local checkout instead of cloning (#193)
<GitClone> now has a repository-source picker: clone from a remote (unchanged) or point at a checkout the user already has on disk. Users often have the repo already — a long-lived infrastructure-live checkout, a work in progress branch, a monorepo they don't want to re-download. Picking a directory inspects it without touching it: resolve the work tree root via `git rev-parse --show-toplevel` (so any subdirectory of the checkout works), read the origin remote and current branch, count tracked files. That runs as the user types or browses, so a wrong directory says so immediately. Confirming with "Use This Repo" registers the checkout as a session worktree and emits the same outputs a clone does, so nothing downstream can tell the two apart: same clone_path/repo_owner/repo_name, same $REPO_FILES, same workspace file tree, same <GitPullRequest> flow (opening against the checkout's own remote and current branch). Backend: - src/domain/git/local-repo.ts: inspectLocalRepo() — root resolution, remote/ref metadata, tracked-file count, and typed user-facing failures for a missing directory, a file, or a directory that isn't a git work tree. A repo with no commits is still selectable. - GitClient.getRepoRoot() added to the service, the CLI layer, and the test stub. - git:local-repo IPC handler. The live preview passes register:false; only the user confirming registers the worktree and resolves GitHub numeric IDs. That split keeps a half-typed path from granting anything. - workspace.ts: resolveValidatedWorktree() now accepts already-registered worktrees. Without this, workspace:register / workspace:set-active silently rejected any checkout outside the session working directory — which is where local checkouts normally live — so the active-worktree selection didn't stick and REPO_FILES could point at the wrong repo. It does not widen the grant: an unregistered path outside the session still fails. Frontend: SourceSelect (styled after GitAuth's ProviderSelect), LocalRepoForm with a native folder picker, and source-aware copy in the result panel. New props: source ('clone' | 'local'), hideSourceSelect, prefilledRepoDir — the last one starts the block on the local source. Selecting a checkout requires no credentials; only cloning still waits on a linked auth block. Instruction mode renders `cd <path>` instead of a git clone command. The runbook test framework adopts a checkout for source="local" rather than cloning, counting tracked files instead of walking .git. Tests: 11 domain tests for inspectLocalRepo, 12 component tests driving the real hook against a mocked IPC boundary, 2 instruction-mode tests, and 2 CLI executor tests against a real `git init` fixture. Docs cover the new source, props, and output behaviour. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent d3ac06e commit bfb2476

23 files changed

Lines changed: 1570 additions & 169 deletions

File tree

cli/test/executor.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
2+
import { execFileSync } from "node:child_process"
23
import * as fs from "node:fs"
34
import * as path from "node:path"
45
import * as os from "node:os"
@@ -74,3 +75,59 @@ describe("TestExecutor — config-error surfacing", () => {
7475
await executor.init()
7576
})
7677
})
78+
79+
// ---------------------------------------------------------------------------
80+
// GitClone with source="local": adopt a checkout that already exists instead
81+
// of cloning it.
82+
// ---------------------------------------------------------------------------
83+
84+
describe("TestExecutor — GitClone local checkout", () => {
85+
let tmp: string
86+
87+
const runLocalGitClone = async (repoDir: string) => {
88+
const rb = path.join(tmp, "runbook.mdx")
89+
fs.writeFileSync(
90+
rb,
91+
`# Local checkout\n\n<GitClone id="repo" source="local" prefilledRepoDir="${repoDir}" />\n`,
92+
)
93+
const executor = new TestExecutor(rb, tmp, "generated", { timeout: 30_000, verbose: false })
94+
await executor.init()
95+
return executor.runTest({
96+
name: "local",
97+
steps: [{ block: "repo", expect: "success" }],
98+
})
99+
}
100+
101+
beforeEach(() => {
102+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "rb-exec-local-"))
103+
})
104+
afterEach(() => {
105+
fs.rmSync(tmp, { recursive: true, force: true })
106+
})
107+
108+
it("adopts an existing checkout and emits clone_path", async () => {
109+
const repo = path.join(tmp, "checkout")
110+
fs.mkdirSync(repo)
111+
execFileSync("git", ["init", "-q"], { cwd: repo })
112+
fs.writeFileSync(path.join(repo, "main.tf"), "# tf\n")
113+
execFileSync("git", ["add", "."], { cwd: repo })
114+
115+
const result = await runLocalGitClone(repo)
116+
117+
expect(result.stepResults[0]?.actualStatus).toBe("success")
118+
// git init resolves through symlinks on macOS (/var → /private/var), so
119+
// compare against the repo's own resolved path.
120+
expect(result.stepResults[0]?.outputs?.clone_path).toBe(fs.realpathSync(repo))
121+
expect(result.stepResults[0]?.outputs?.file_count).toBe("1")
122+
})
123+
124+
it("fails when the directory is not a git repository", async () => {
125+
const notARepo = path.join(tmp, "notes")
126+
fs.mkdirSync(notARepo)
127+
128+
const result = await runLocalGitClone(notARepo)
129+
130+
expect(result.stepResults[0]?.actualStatus).toBe("fail")
131+
expect(result.stepResults[0]?.error).toMatch(/Not a git repository/)
132+
})
133+
})

cli/test/executor.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1271,6 +1271,14 @@ export class TestExecutor {
12711271
private runGitClone(block: ParsedComponent, step: TestStep, start: number): StepResult {
12721272
const result = makeStepResult(`gitClone:${block.id}`, step.expect)
12731273

1274+
// A block pointed at an existing checkout clones nothing — it just adopts
1275+
// the directory, mirroring the app's "Use local checkout" source.
1276+
const repoDir = extractProp(block.props, "prefilledRepoDir")
1277+
const source = extractProp(block.props, "source")
1278+
if (source === "local" || (!source && repoDir)) {
1279+
return this.runGitCloneLocal(block, step, start, result, repoDir)
1280+
}
1281+
12741282
const cloneURL = extractProp(block.props, "prefilledUrl")
12751283
const ref = extractProp(block.props, "prefilledRef")
12761284
const repoPath = extractProp(block.props, "prefilledRepoPath")
@@ -1383,6 +1391,78 @@ export class TestExecutor {
13831391
return result
13841392
}
13851393

1394+
/**
1395+
* GitClone with `source="local"`: adopt an existing checkout instead of
1396+
* cloning it. Emits the same `clone_path` output and becomes the active
1397+
* worktree, so the rest of the runbook behaves identically either way.
1398+
*/
1399+
private runGitCloneLocal(
1400+
block: ParsedComponent,
1401+
step: TestStep,
1402+
start: number,
1403+
result: StepResult,
1404+
repoDir: string | undefined,
1405+
): StepResult {
1406+
if (!repoDir) {
1407+
this.blockStates.set(block.id, "skipped")
1408+
result.actualStatus = "skipped"
1409+
result.passed = this.matchesExpectedStatus(step.expect, "skipped")
1410+
result.duration = Date.now() - start
1411+
if (this.options.verbose) console.log("--- No prefilledRepoDir specified ---")
1412+
return result
1413+
}
1414+
1415+
const resolved = path.isAbsolute(repoDir) ? repoDir : path.join(this.workingDir, repoDir)
1416+
1417+
let repoRoot: string
1418+
try {
1419+
repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
1420+
cwd: resolved,
1421+
timeout: 30000,
1422+
stdio: "pipe",
1423+
})
1424+
.toString()
1425+
.trim()
1426+
} catch (e: unknown) {
1427+
result.passed = this.matchesExpectedStatus(step.expect, "fail")
1428+
result.actualStatus = "fail"
1429+
result.error = `Not a git repository: ${resolved} (${String(e)})`
1430+
result.duration = Date.now() - start
1431+
return result
1432+
}
1433+
1434+
// Count TRACKED files, as the app does for a local checkout: walking the
1435+
// directory would count .git internals and ignored build output, which say
1436+
// nothing about the repo the user picked.
1437+
let fileCount = 0
1438+
try {
1439+
const tracked = execFileSync("git", ["ls-files"], {
1440+
cwd: repoRoot,
1441+
timeout: 30000,
1442+
stdio: "pipe",
1443+
}).toString()
1444+
fileCount = tracked.split("\n").filter((line) => line.trim() !== "").length
1445+
} catch {
1446+
// Best-effort: a repo with no commits still counts as usable.
1447+
}
1448+
1449+
result.outputs = { clone_path: repoRoot, file_count: String(fileCount) }
1450+
this.blockOutputs.set(block.id, new Map(Object.entries(result.outputs)))
1451+
1452+
this.activeWorkTreePath = repoRoot
1453+
this.blockStates.set(block.id, "success")
1454+
result.actualStatus = "success"
1455+
result.passed = this.matchesExpectedStatus(step.expect, "success")
1456+
result.duration = Date.now() - start
1457+
1458+
if (this.options.verbose) {
1459+
console.log(`--- Using local checkout ${repoRoot}: ${fileCount} files ---`)
1460+
console.log("--- Result: ✓ success ---")
1461+
}
1462+
1463+
return result
1464+
}
1465+
13861466
// -----------------------------------------------------------------------
13871467
// Helpers
13881468
// -----------------------------------------------------------------------

docs/src/content/docs/authoring/blocks/GitClone.mdx

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ title: <GitClone>
44

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

7-
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.
7+
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.
88

99
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.
1010

@@ -39,6 +39,36 @@ When paired with a `<GitHubAuth>` block, the GitClone block enables a "Browse Gi
3939
/>
4040
```
4141

42+
### Using a Local Checkout
43+
44+
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.
45+
46+
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.
47+
48+
```mdx
49+
<GitClone
50+
id="repo"
51+
title="Select Your Infrastructure Repo"
52+
prefilledRepoDir="~/dev/infrastructure-live"
53+
/>
54+
```
55+
56+
Setting `prefilledRepoDir` starts the block on the local source. Use `source` to choose the starting source explicitly, and `hideSourceSelect` to remove the choice altogether:
57+
58+
```mdx
59+
{/* Local checkouts only — no cloning offered */}
60+
<GitClone id="repo" source="local" hideSourceSelect />
61+
62+
{/* Cloning only, as before */}
63+
<GitClone id="repo" source="clone" hideSourceSelect prefilledUrl="https://github.com/acme/infra" />
64+
```
65+
66+
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.
67+
68+
<Aside type="caution">
69+
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.
70+
</Aside>
71+
4272
### Pre-filled Values
4373

4474
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.
@@ -68,6 +98,9 @@ You can pre-populate the URL, ref, sparse checkout path, and local path fields.
6898
| `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. |
6999
| `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. |
70100
| `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. |
101+
| `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`. |
102+
| `hideSourceSelect` | `boolean` | No | Hides the source picker and locks the block to `source`. Defaults to `false`. |
103+
| `prefilledRepoDir` | `string` | No | Pre-fills the local checkout directory. Any directory inside the checkout works — the repository root is resolved from it. |
71104

72105
### Ref Selection
73106

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

90123
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).
91124

125+
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.
126+
92127
### Accepted Git URL Formats
93128

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

206+
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).
207+
171208
Reference outputs in downstream blocks using template variables:
172209

173210
```mdx

electron/main/ipc/git.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
parseOwnerRepoFromURL,
2222
type CreatePullRequestParams,
2323
} from "../../../src/domain/git/operations.ts"
24+
import { inspectLocalRepo } from "../../../src/domain/git/local-repo.ts"
2425
import { getRepo } from "../../../src/domain/github/auth.ts"
2526
import { injectTokenIntoUrl } from "../../../src/domain/git/url.ts"
2627
import { gitSpawnEnv } from "../../../src/domain/git/env.ts"
@@ -30,6 +31,7 @@ import { isContainedIn } from "../../../src/path-validation.ts"
3031
import { PathTraversalError, GitError, GitHubApiError, GitLabApiError } from "../../../src/errors/index.ts"
3132
import { validateSessionPath } from "./path-guard.ts"
3233
import { makeLogger } from "../logger.ts"
34+
import type { GitLocalRepoResponse } from "../../shared/channels.ts"
3335

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

@@ -408,6 +410,92 @@ export function registerGitHandlers(): void {
408410
},
409411
)
410412

413+
// Select an existing local checkout instead of cloning. The user picks the
414+
// directory (native dialog or by typing a path), so registering it as a
415+
// worktree here is the grant that lets the workspace/PR handlers touch a
416+
// repo outside the session working directory.
417+
ipcMain.handle(
418+
"git:local-repo",
419+
async (
420+
_event,
421+
params: { path: string; register?: boolean; provider?: "github" | "gitlab" },
422+
): Promise<GitLocalRepoResponse> => {
423+
const program = Effect.gen(function* () {
424+
const session = yield* sessionManager.getSession()
425+
const info = yield* inspectLocalRepo(params.path, session.workingDir)
426+
427+
if (params.register) {
428+
sessionManager.registerWorkTreePath(info.absolutePath)
429+
log.debug("registered local checkout as worktree:", info.absolutePath)
430+
}
431+
432+
// Same output contract as a clone, so runbooks referencing
433+
// {{ .outputs.<id>.clone_path }} work with either source.
434+
const outputs: Record<string, string> = {
435+
clone_path: info.absolutePath,
436+
...(info.owner && info.repo
437+
? { repo_owner: info.owner, repo_name: info.repo }
438+
: {}),
439+
}
440+
441+
// GitHub numeric IDs, when a token is available — mirrors git:clone.
442+
if (params.register && info.owner && info.repo && params.provider !== "gitlab") {
443+
const token = yield* Effect.either(
444+
getSessionTokenForProvider(
445+
"github",
446+
() =>
447+
new GitError({
448+
command: "resolve git token",
449+
stderr: "no session token",
450+
exitCode: 1,
451+
}),
452+
),
453+
)
454+
if (token._tag === "Right") {
455+
const repoResult = yield* Effect.either(
456+
getRepo(token.right, info.owner, info.repo),
457+
)
458+
if (repoResult._tag === "Right") {
459+
outputs.org_id = String(repoResult.right.ownerId)
460+
outputs.repo_id = String(repoResult.right.id)
461+
} else {
462+
log.debug(
463+
"failed to resolve GitHub org/repo IDs (non-fatal):",
464+
repoResult.left,
465+
)
466+
}
467+
}
468+
}
469+
470+
return {
471+
status: "success" as const,
472+
absolutePath: info.absolutePath,
473+
relativePath: info.relativePath,
474+
fileCount: info.fileCount,
475+
remoteUrl: info.remoteUrl,
476+
ref: info.branch,
477+
refType: info.refType,
478+
commitSha: info.commitSha,
479+
outputs,
480+
}
481+
})
482+
483+
// A bad directory is user input, not an exception: return the message so
484+
// the block renders it inline instead of throwing across IPC.
485+
const exit = await runtime.runPromiseExit(program)
486+
if (Exit.isSuccess(exit)) return exit.value
487+
488+
const failure = Cause.failureOption(exit.cause)
489+
return {
490+
status: "fail" as const,
491+
error:
492+
failure._tag === "Some"
493+
? errorMessage(failure.value)
494+
: Cause.pretty(exit.cause),
495+
}
496+
},
497+
)
498+
411499
ipcMain.handle(
412500
"git:push",
413501
async (

electron/main/ipc/workspace.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,20 @@ import path from "path"
2121
/**
2222
* Resolve a renderer-supplied worktree path and fail if it escapes the session
2323
* working directory (or the runbook directory). Returns the resolved path.
24+
*
25+
* Paths already registered as worktrees pass regardless of location: a local
26+
* checkout the user selected in a <GitClone> block lives wherever they keep
27+
* their repos, and that selection is what granted access in the first place
28+
* (see the git:local-repo handler). This does not widen the grant — an
29+
* unregistered path outside the session still fails.
2430
*/
2531
const resolveValidatedWorktree = (worktreePath: string) =>
2632
Effect.gen(function* () {
2733
const resolved = path.resolve(worktreePath)
2834
const session = yield* sessionManager.getSession()
35+
if (session.registeredWorkTreePaths.includes(resolved)) {
36+
return resolved
37+
}
2938
const runbookDir = runbookConfig.localPath ? path.dirname(runbookConfig.localPath) : null
3039
if (
3140
!isContainedIn(resolved, session.workingDir) &&

electron/preload/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ const ALLOWED_INVOKE_CHANNELS: Set<string> = new Set<InvokeChannel>([
1919
"gitlab:validate", "gitlab:env-credentials", "gitlab:cli-credentials", "gitlab:labels", "gitlab:enumerate-hosts",
2020
"gitlab:host-picked",
2121
"vcs:cli-status", "vcs:invalidate-cache", "vcs:apply-git-schannel",
22-
"git:clone", "git:push", "git:pull-request", "git:merge-request", "git:delete-branch",
22+
"git:clone", "git:local-repo", "git:push", "git:pull-request", "git:merge-request", "git:delete-branch",
2323
"workspace:tree", "workspace:dirs", "workspace:file", "workspace:changes",
2424
"workspace:register", "workspace:set-active",
2525
"generated-files:check", "generated-files:delete",

0 commit comments

Comments
 (0)