Skip to content

Commit 351470d

Browse files
odgrimclaude
andauthored
Fix/gitclone local checkout outputs (#196)
* fix(GitClone): produce the same outputs from a local checkout as from a clone Two ways a local checkout silently came up short of the clone path's outputs, leaving downstream blocks stuck on "Waiting for outputs from: … (repo_owner, org_id, repo_name, repo_id)". Remote resolution only ever looked at `origin` (GitClient.getInfo). A checkout that names its remote something else — a fork whose upstream is the interesting one, a repo re-pointed after `git init` — yielded no remote at all, so there was no repo_owner/repo_name, and no org_id/repo_id either, since those are looked up from the owner and name. inspectLocalRepo now falls back to whichever remote the repo does have. `origin` is still preferred, so remote order decides nothing in the common case. The org/repo ids are resolved once, at confirm time, from the session token. Because selecting a checkout deliberately didn't wait on the linked auth block, confirming before authenticating produced a block permanently missing org_id and repo_id. Confirming now waits for the auth block exactly as cloning does; the path field, the folder picker, and the inline "is this a git repo?" check stay usable while it's pending, since none of those need credentials. Docs updated: the local source no longer claims to need no credentials, and the no-remote caution now names the outputs that go missing with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(GitClone): stop the local-checkout preview from reading as a finished block The "is this a git repo?" check rendered in the same green success styling as the completed state — check icon, root, remote, branch, file count — so a checked directory looked like a block that had run. It hasn't: nothing is registered and no outputs exist until "Use This Repo" is confirmed, and a block that has produced no outputs leaves every consumer reporting each referenced name as missing ("Waiting for outputs from: clone_repo (repo_owner, org_id, repo_name, repo_id)") — which reads as the outputs being unsupported rather than the block being unfinished. The preview is now informational rather than success-styled, green being reserved for the completed state, and says outright that the directory is not in use yet. The no-remote warning names the outputs that go missing with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * uptick to 0.21.1 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(GitClone): address review — docs contradictions and interrupt-safe git queries Three findings from the review of #195. The docs said choosing a directory "reads it only" and in the same sentence that it "registers the repo exactly as a clone would" — the contradiction at the heart of the reported bug. Inspection and confirmation are now described as the separate steps they are, including that a block which hasn't been confirmed has produced nothing and leaves its consumers reporting missing outputs. The integration paragraph still said a pull request opens against the checkout's `origin` remote, which contradicts the new fallback two paragraphs below it. It now says "the checkout's remote". firstRemoteUrl spawned git without ever killing the child, so an interrupted fiber would orphan it. Rather than plumbing a Scope through inspectLocalRepo and the handler — which would leave this helper inconsistent with countFiles three lines away, which has the same property and is shared with the clone path — the shared readGitLines helper wraps its drain in Effect.ensuring(kill). Same guarantee, no signature change, no interruption path exists today anyway: on the normal path the process has already exited and the signal is a no-op. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Add a bootstrap for new repositories with no commits * uptick to 0.21.2 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(GitClone): address review — spellcheck, best-effort hasCommits, unique input id - docs: "colour" → "color" in GitClone.mdx. cspell runs US English and it was the only occurrence, so the whole Documentation Tests job failed before build or linkcheck ran. - electron/main/ipc/git.ts: wrap the clone flow's hasCommits query in Effect.orElseSucceed(() => true), as every other caller already does. Bare, a failed query failed the whole git:clone program after the clone had already landed on disk — the user saw an error, the worktree was never registered, and the outputs were lost. Defaulting to true also matches the domain rule that an unreadable repo counts as having history, so nobody is offered a seeded branch by mistake. - EmptyRepoWarning: derive the branch input's id from useId() instead of the literal "empty-repo-branch". Two GitClone blocks on empty repos in one runbook produced duplicate ids, so both labels pointed at the first input. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 4d6af58 commit 351470d

15 files changed

Lines changed: 1001 additions & 69 deletions

File tree

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,24 @@ Set `showFileTree={false}` if you don't want the cloned repository to appear in
128128

129129
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.
130130

131+
### Repositories With No Commits
132+
133+
A repository that was created but never pushed to has no branches at all. Nothing downstream can open a pull request against it: the base branch a pull request needs does not exist, and the provider rejects the request as invalid — after the runbook has already committed and pushed its work.
134+
135+
GitClone detects this case for both sources. When the repo has no commits, the block:
136+
137+
- renders in the warning color rather than reporting a plain success,
138+
- withholds its outputs (including `clone_path`) and does not register the repository with the workspace, so blocks that depend on it stay blocked,
139+
- offers a **Create default branch** button that pushes a single empty commit to the branch the remote advertises as its default. The name is editable before you press it.
140+
141+
Once the branch exists, the block releases its outputs and registers the workspace exactly as it would have for a repository that already had commits, and the runbook continues.
142+
143+
The seeded commit is deliberately empty. It gives the default branch something to point at, so the branch the runbook pushes later shares an ancestor with it and opens as a reviewable diff instead of an unrelated root commit. Files already written into the work tree are left untracked, not swept into it.
144+
145+
<Aside type="note">
146+
Seeding requires a linked auth block, since it pushes to the remote. The branch is created locally, committed, and pushed to `origin` in one step.
147+
</Aside>
148+
131149
### Accepted Git URL Formats
132150

133151
The GitClone block accepts the following URL formats:

electron/main/ipc/git.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import {
1717
deleteBranch,
1818
createPullRequest,
1919
createMergeRequest,
20+
seedDefaultBranch,
21+
unbornBranchName,
2022
isValidGitURL,
2123
parseOwnerRepoFromURL,
2224
type CreatePullRequestParams,
@@ -368,6 +370,29 @@ export function registerGitHandlers(): void {
368370
// Count tracked files using `git ls-files` (fast, ~10ms)
369371
const fileCount = yield* countFiles(paths.absolutePath)
370372

373+
// Report the ref the clone actually landed on rather than letting the
374+
// renderer assume one. Cloning without an explicit `ref` follows the
375+
// remote's default branch, which is not always "main" — and that ref
376+
// becomes the base branch of any pull request opened against this
377+
// checkout, so guessing it wrong fails the PR at the very last step.
378+
const gitClient = yield* GitClient
379+
// Best-effort, like every other caller: a failed query must not fail
380+
// the whole clone after it already landed on disk, which would lose
381+
// the outputs and skip worktree registration. A repo we cannot read
382+
// counts as having history, so nobody is offered a seeded branch by
383+
// mistake.
384+
const hasCommits = yield* gitClient
385+
.hasCommits(paths.absolutePath)
386+
.pipe(Effect.orElseSucceed(() => true))
387+
const clonedRef = hasCommits
388+
? (yield* gitClient
389+
.getCurrentBranch(paths.absolutePath)
390+
.pipe(Effect.orElseSucceed(() => "")))
391+
: // An empty repo has no branch yet; HEAD still names the one the
392+
// remote advertised, which is what a seeded first commit should
393+
// become.
394+
((yield* unbornBranchName(paths.absolutePath)) ?? "")
395+
371396
// Register the worktree path
372397
sessionManager.registerWorkTreePath(paths.absolutePath)
373398
log.debug("registered worktree, returning result")
@@ -401,6 +426,8 @@ export function registerGitHandlers(): void {
401426
absolutePath: paths.absolutePath,
402427
relativePath: paths.relativePath,
403428
fileCount,
429+
ref: clonedRef,
430+
hasCommits,
404431
status: "success" as const,
405432
outputs,
406433
}
@@ -476,6 +503,7 @@ export function registerGitHandlers(): void {
476503
ref: info.branch,
477504
refType: info.refType,
478505
commitSha: info.commitSha,
506+
hasCommits: info.hasCommits,
479507
outputs,
480508
}
481509
})
@@ -553,6 +581,58 @@ export function registerGitHandlers(): void {
553581
},
554582
)
555583

584+
// Seed an empty repository with its default branch. Offered by <GitClone>
585+
// when it clones (or is pointed at) a repo that has no commits: without a
586+
// branch on the remote there is nothing for a later pull request to target,
587+
// and the failure would otherwise surface only after the runbook's work had
588+
// been committed and pushed.
589+
ipcMain.handle(
590+
"git:init-default-branch",
591+
async (
592+
event,
593+
params: {
594+
worktreePath: string
595+
branch: string
596+
provider?: "github" | "gitlab"
597+
},
598+
) => {
599+
const sendLog = makeSendLog(event)
600+
601+
const program = Effect.gen(function* () {
602+
const repoPath = yield* validateSessionPath(params.worktreePath)
603+
const provider = params.provider ?? "github"
604+
const token = yield* getSessionTokenForProvider(
605+
provider,
606+
() =>
607+
new GitError({
608+
command: "resolve git token",
609+
stderr: `No ${provider} token available in session. Authenticate with the matching Git Auth block before creating the default branch.`,
610+
exitCode: 1,
611+
}),
612+
)
613+
614+
const branch = params.branch.trim() || "main"
615+
return yield* seedDefaultBranch(token, { repoPath, branch, provider }, sendLog)
616+
})
617+
618+
const exit = await runtime.runPromiseExit(program)
619+
620+
if (Exit.isSuccess(exit)) {
621+
event.sender.send("git:status", { status: "success", exitCode: 0 })
622+
return { branch: exit.value.branch }
623+
}
624+
625+
const failure = Cause.failureOption(exit.cause)
626+
const message =
627+
failure._tag === "Some"
628+
? errorMessage(failure.value)
629+
: Cause.pretty(exit.cause)
630+
event.sender.send("git:error", { message })
631+
event.sender.send("git:status", { status: "fail", exitCode: 1 })
632+
return { error: message }
633+
},
634+
)
635+
556636
ipcMain.handle("git:pull-request", async (event, params: GitPrParams) => {
557637
const sendLog = makeSendLog(event)
558638

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:local-repo", "git:push", "git:pull-request", "git:merge-request", "git:delete-branch",
22+
"git:clone", "git:local-repo", "git:push", "git:init-default-branch", "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",

electron/shared/channels.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -491,13 +491,28 @@ export interface IpcChannelMap {
491491
// Git Operations
492492
"git:clone": {
493493
params: GitCloneRequest
494-
result: { status: string; error?: string; fileCount?: number; absolutePath?: string; relativePath?: string; outputs?: Record<string, string> }
494+
result: {
495+
status: string
496+
error?: string
497+
fileCount?: number
498+
absolutePath?: string
499+
relativePath?: string
500+
/** Branch the clone landed on — the base branch of any PR opened against it. */
501+
ref?: string
502+
/** False for a repo with no commits: it has no branch a PR could target. */
503+
hasCommits?: boolean
504+
outputs?: Record<string, string>
505+
}
495506
}
496507
"git:local-repo": {
497508
params: GitLocalRepoRequest
498509
result: GitLocalRepoResponse
499510
}
500511
"git:push": { params: { worktreePath: string; branchName: string; provider?: "github" | "gitlab" }; result: { ok: true } | { error: string } }
512+
"git:init-default-branch": {
513+
params: { worktreePath: string; branch: string; provider?: "github" | "gitlab" }
514+
result: { branch: string } | { error: string }
515+
}
501516
"git:pull-request": { params: PullRequestRequest; result: { url: string; number: number } | { error: string } }
502517
"git:merge-request": { params: PullRequestRequest; result: { url: string; number: number } | { error: string } }
503518
"git:delete-branch": { params: { worktreePath: string; branch: string }; result: { ok: true } }
@@ -852,6 +867,8 @@ export interface GitLocalRepoResponse {
852867
ref?: string
853868
refType?: "branch" | "tag" | "detached"
854869
commitSha?: string
870+
/** False for a repo with no commits: it has no branch a PR could target. */
871+
hasCommits?: boolean
855872
outputs?: Record<string, string>
856873
}
857874

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "runbooks",
3-
"version": "0.21.1",
3+
"version": "0.21.2",
44
"private": true,
55
"description": "Gruntwork Runbooks",
66
"author": {

src/domain/git/local-repo.test.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ describe("inspectLocalRepo", () => {
122122
expect(info.relativePath).toBe("checkouts/infra")
123123
})
124124

125-
it("succeeds for a repo with no commits yet", async () => {
125+
it("succeeds for a repo with no commits yet, and says so", async () => {
126126
const info = await inspect("/home/me/fresh", {
127127
dirs: ["/home/me/fresh"],
128128
commands: [lsFiles([])],
@@ -132,12 +132,60 @@ describe("inspectLocalRepo", () => {
132132
Effect.fail(
133133
new GitError({ command: "git rev-parse", stderr: "no HEAD", exitCode: 128 }),
134134
),
135+
hasCommits: () => Effect.succeed(false),
135136
},
136137
})
137138

138139
expect(info.branch).toBe("")
139140
expect(info.fileCount).toBe(0)
140141
expect(info.owner).toBeUndefined()
142+
// The empty branch alone can't be trusted to mean "empty repo" — callers
143+
// need this flag to know a base branch has to be seeded before a PR.
144+
expect(info.hasCommits).toBe(false)
145+
})
146+
147+
it("treats a repo it can't query as having history, never as empty", async () => {
148+
const info = await inspect("/home/me/odd", {
149+
dirs: ["/home/me/odd"],
150+
commands: [lsFiles([])],
151+
git: {
152+
getRepoRoot: () => Effect.succeed("/home/me/odd"),
153+
getInfo: () => Effect.succeed({ branch: "main", refType: "branch" as const }),
154+
hasCommits: () =>
155+
Effect.fail(
156+
new GitError({ command: "git rev-parse", stderr: "boom", exitCode: 1 }),
157+
),
158+
},
159+
})
160+
161+
// Guessing "empty" here would offer to seed a branch over a repo whose
162+
// state we simply failed to read.
163+
expect(info.hasCommits).toBe(true)
164+
})
165+
166+
it("falls back to a non-origin remote when there is no origin", async () => {
167+
const info = await inspect("/home/me/fork", {
168+
dirs: ["/home/me/fork"],
169+
commands: [
170+
{ command: "git", args: ["remote"], outputLines: ["upstream"], exitCode: 0 },
171+
{
172+
command: "git",
173+
args: ["remote", "get-url", "upstream"],
174+
outputLines: ["git@github.com:acme/infra.git"],
175+
exitCode: 0,
176+
},
177+
lsFiles(["main.tf"]),
178+
],
179+
git: {
180+
getRepoRoot: () => Effect.succeed("/home/me/fork"),
181+
// getInfo only looks at origin, which this checkout doesn't have.
182+
getInfo: () => Effect.succeed({ branch: "main", refType: "branch" as const }),
183+
},
184+
})
185+
186+
expect(info.remoteUrl).toBe("git@github.com:acme/infra.git")
187+
expect(info.owner).toBe("acme")
188+
expect(info.repo).toBe("infra")
141189
})
142190

143191
it("falls back to a non-origin remote when there is no origin", async () => {

src/domain/git/local-repo.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ export interface LocalRepoInfo {
3232
readonly branch: string
3333
readonly refType: GitInfo["refType"]
3434
readonly commitSha?: string
35+
/**
36+
* False when the repo has no commits yet (unborn HEAD). Such a repo has no
37+
* branch to open a pull request against, so blocks that need a base ref have
38+
* to seed one before they can do anything useful.
39+
*/
40+
readonly hasCommits: boolean
3541
/** Owner/repo parsed from the remote URL, when parseable. */
3642
readonly owner?: string
3743
readonly repo?: string
@@ -128,6 +134,15 @@ export const inspectLocalRepo = (
128134

129135
const fileCount = yield* countFiles(absolutePath)
130136
const parsed = remoteUrl ? parseOwnerRepoFromURL(remoteUrl) : undefined
137+
// Distinguishes "empty repo" from "getInfo failed for some other reason":
138+
// both leave `branch` empty above, but only the former is recoverable by
139+
// seeding a first commit.
140+
// orElseSucceed keeps this best-effort, like the getInfo lookup above: a
141+
// repo we can't query is treated as having history, so an unreadable git
142+
// never gets mistaken for an empty one and offered a seeded branch.
143+
const hasCommits = yield* git
144+
.hasCommits(absolutePath)
145+
.pipe(Effect.orElseSucceed(() => true))
131146

132147
return {
133148
absolutePath,
@@ -137,6 +152,7 @@ export const inspectLocalRepo = (
137152
branch: info.branch,
138153
refType: info.refType,
139154
commitSha: info.commitSha,
155+
hasCommits,
140156
owner: parsed?.owner,
141157
repo: parsed?.repo,
142158
} satisfies LocalRepoInfo

0 commit comments

Comments
 (0)