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
18 changes: 18 additions & 0 deletions docs/src/content/docs/authoring/blocks/GitClone.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,24 @@ Set `showFileTree={false}` if you don't want the cloned repository to appear in

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.

### Repositories With No Commits

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.

GitClone detects this case for both sources. When the repo has no commits, the block:

- renders in the warning color rather than reporting a plain success,
- withholds its outputs (including `clone_path`) and does not register the repository with the workspace, so blocks that depend on it stay blocked,
- 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.

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.

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.

<Aside type="note">
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.
</Aside>

### Accepted Git URL Formats

The GitClone block accepts the following URL formats:
Expand Down
80 changes: 80 additions & 0 deletions electron/main/ipc/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
deleteBranch,
createPullRequest,
createMergeRequest,
seedDefaultBranch,
unbornBranchName,
isValidGitURL,
parseOwnerRepoFromURL,
type CreatePullRequestParams,
Expand Down Expand Up @@ -368,6 +370,29 @@ export function registerGitHandlers(): void {
// Count tracked files using `git ls-files` (fast, ~10ms)
const fileCount = yield* countFiles(paths.absolutePath)

// Report the ref the clone actually landed on rather than letting the
// renderer assume one. Cloning without an explicit `ref` follows the
// remote's default branch, which is not always "main" — and that ref
// becomes the base branch of any pull request opened against this
// checkout, so guessing it wrong fails the PR at the very last step.
const gitClient = yield* GitClient
// Best-effort, like every other caller: a failed query must not fail
// the whole clone after it already landed on disk, which would lose
// the outputs and skip worktree registration. A repo we cannot read
// counts as having history, so nobody is offered a seeded branch by
// mistake.
const hasCommits = yield* gitClient
.hasCommits(paths.absolutePath)
.pipe(Effect.orElseSucceed(() => true))
const clonedRef = hasCommits
? (yield* gitClient
.getCurrentBranch(paths.absolutePath)
.pipe(Effect.orElseSucceed(() => "")))
: // An empty repo has no branch yet; HEAD still names the one the
// remote advertised, which is what a seeded first commit should
// become.
((yield* unbornBranchName(paths.absolutePath)) ?? "")
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Register the worktree path
sessionManager.registerWorkTreePath(paths.absolutePath)
log.debug("registered worktree, returning result")
Expand Down Expand Up @@ -401,6 +426,8 @@ export function registerGitHandlers(): void {
absolutePath: paths.absolutePath,
relativePath: paths.relativePath,
fileCount,
ref: clonedRef,
hasCommits,
status: "success" as const,
outputs,
}
Expand Down Expand Up @@ -476,6 +503,7 @@ export function registerGitHandlers(): void {
ref: info.branch,
refType: info.refType,
commitSha: info.commitSha,
hasCommits: info.hasCommits,
outputs,
}
})
Expand Down Expand Up @@ -553,6 +581,58 @@ export function registerGitHandlers(): void {
},
)

// Seed an empty repository with its default branch. Offered by <GitClone>
// when it clones (or is pointed at) a repo that has no commits: without a
// branch on the remote there is nothing for a later pull request to target,
// and the failure would otherwise surface only after the runbook's work had
// been committed and pushed.
ipcMain.handle(
"git:init-default-branch",
async (
event,
params: {
worktreePath: string
branch: string
provider?: "github" | "gitlab"
},
) => {
const sendLog = makeSendLog(event)

const program = Effect.gen(function* () {
const repoPath = yield* validateSessionPath(params.worktreePath)
const provider = params.provider ?? "github"
const token = yield* getSessionTokenForProvider(
provider,
() =>
new GitError({
command: "resolve git token",
stderr: `No ${provider} token available in session. Authenticate with the matching Git Auth block before creating the default branch.`,
exitCode: 1,
}),
)

const branch = params.branch.trim() || "main"
return yield* seedDefaultBranch(token, { repoPath, branch, provider }, sendLog)
})

const exit = await runtime.runPromiseExit(program)

if (Exit.isSuccess(exit)) {
event.sender.send("git:status", { status: "success", exitCode: 0 })
return { branch: exit.value.branch }
}

const failure = Cause.failureOption(exit.cause)
const message =
failure._tag === "Some"
? errorMessage(failure.value)
: Cause.pretty(exit.cause)
event.sender.send("git:error", { message })
event.sender.send("git:status", { status: "fail", exitCode: 1 })
return { error: message }
},
)

ipcMain.handle("git:pull-request", async (event, params: GitPrParams) => {
const sendLog = makeSendLog(event)

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:local-repo", "git:push", "git:pull-request", "git:merge-request", "git:delete-branch",
"git:clone", "git:local-repo", "git:push", "git:init-default-branch", "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
19 changes: 18 additions & 1 deletion electron/shared/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,13 +491,28 @@ export interface IpcChannelMap {
// Git Operations
"git:clone": {
params: GitCloneRequest
result: { status: string; error?: string; fileCount?: number; absolutePath?: string; relativePath?: string; outputs?: Record<string, string> }
result: {
status: string
error?: string
fileCount?: number
absolutePath?: string
relativePath?: string
/** Branch the clone landed on — the base branch of any PR opened against it. */
ref?: string
/** False for a repo with no commits: it has no branch a PR could target. */
hasCommits?: boolean
outputs?: Record<string, string>
}
}
"git:local-repo": {
params: GitLocalRepoRequest
result: GitLocalRepoResponse
}
"git:push": { params: { worktreePath: string; branchName: string; provider?: "github" | "gitlab" }; result: { ok: true } | { error: string } }
"git:init-default-branch": {
params: { worktreePath: string; branch: string; provider?: "github" | "gitlab" }
result: { branch: string } | { error: string }
}
"git:pull-request": { params: PullRequestRequest; result: { url: string; number: number } | { error: string } }
"git:merge-request": { params: PullRequestRequest; result: { url: string; number: number } | { error: string } }
"git:delete-branch": { params: { worktreePath: string; branch: string }; result: { ok: true } }
Expand Down Expand Up @@ -852,6 +867,8 @@ export interface GitLocalRepoResponse {
ref?: string
refType?: "branch" | "tag" | "detached"
commitSha?: string
/** False for a repo with no commits: it has no branch a PR could target. */
hasCommits?: boolean
outputs?: Record<string, string>
}

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "runbooks",
"version": "0.21.1",
"version": "0.21.2",
"private": true,
"description": "Gruntwork Runbooks",
"author": {
Expand Down
50 changes: 49 additions & 1 deletion src/domain/git/local-repo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ describe("inspectLocalRepo", () => {
expect(info.relativePath).toBe("checkouts/infra")
})

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

expect(info.branch).toBe("")
expect(info.fileCount).toBe(0)
expect(info.owner).toBeUndefined()
// The empty branch alone can't be trusted to mean "empty repo" — callers
// need this flag to know a base branch has to be seeded before a PR.
expect(info.hasCommits).toBe(false)
})

it("treats a repo it can't query as having history, never as empty", async () => {
const info = await inspect("/home/me/odd", {
dirs: ["/home/me/odd"],
commands: [lsFiles([])],
git: {
getRepoRoot: () => Effect.succeed("/home/me/odd"),
getInfo: () => Effect.succeed({ branch: "main", refType: "branch" as const }),
hasCommits: () =>
Effect.fail(
new GitError({ command: "git rev-parse", stderr: "boom", exitCode: 1 }),
),
},
})

// Guessing "empty" here would offer to seed a branch over a repo whose
// state we simply failed to read.
expect(info.hasCommits).toBe(true)
})

it("falls back to a non-origin remote when there is no origin", async () => {
const info = await inspect("/home/me/fork", {
dirs: ["/home/me/fork"],
commands: [
{ command: "git", args: ["remote"], outputLines: ["upstream"], exitCode: 0 },
{
command: "git",
args: ["remote", "get-url", "upstream"],
outputLines: ["git@github.com:acme/infra.git"],
exitCode: 0,
},
lsFiles(["main.tf"]),
],
git: {
getRepoRoot: () => Effect.succeed("/home/me/fork"),
// getInfo only looks at origin, which this checkout doesn't have.
getInfo: () => Effect.succeed({ branch: "main", refType: "branch" as const }),
},
})

expect(info.remoteUrl).toBe("git@github.com:acme/infra.git")
expect(info.owner).toBe("acme")
expect(info.repo).toBe("infra")
})

it("falls back to a non-origin remote when there is no origin", async () => {
Expand Down
16 changes: 16 additions & 0 deletions src/domain/git/local-repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ export interface LocalRepoInfo {
readonly branch: string
readonly refType: GitInfo["refType"]
readonly commitSha?: string
/**
* False when the repo has no commits yet (unborn HEAD). Such a repo has no
* branch to open a pull request against, so blocks that need a base ref have
* to seed one before they can do anything useful.
*/
readonly hasCommits: boolean
/** Owner/repo parsed from the remote URL, when parseable. */
readonly owner?: string
readonly repo?: string
Expand Down Expand Up @@ -128,6 +134,15 @@ export const inspectLocalRepo = (

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

return {
absolutePath,
Expand All @@ -137,6 +152,7 @@ export const inspectLocalRepo = (
branch: info.branch,
refType: info.refType,
commitSha: info.commitSha,
hasCommits,
owner: parsed?.owner,
repo: parsed?.repo,
} satisfies LocalRepoInfo
Expand Down
Loading
Loading