fix: initialize new repositories with a main branch (#2297)

This commit is contained in:
Carlos Florêncio
2026-08-05 22:34:26 +01:00
committed by GitHub
parent 21ccac0cb2
commit 7cd0989fe8
11 changed files with 370 additions and 90 deletions
@@ -27,7 +27,26 @@ func init() {
}
// CommandContext creates a Git initialization command bound to targetDirectory.
func CommandContext(ctx context.Context, _ string, targetDirectory *os.File) (*exec.Cmd, error) {
func CommandContext(ctx context.Context, targetPath string, targetDirectory *os.File) (*exec.Cmd, error) {
return commandContext(ctx, targetPath, targetDirectory, "init", "--initial-branch=main")
}
// CommitCommandContext creates a Git commit command bound to targetDirectory.
func CommitCommandContext(
ctx context.Context,
targetPath string,
targetDirectory *os.File,
args ...string,
) (*exec.Cmd, error) {
command, err := commandContext(ctx, targetPath, targetDirectory, args...)
if err != nil {
return nil, err
}
command.Env = withIsolatedCommitEnvironment(command.Env)
return command, nil
}
func commandContext(ctx context.Context, _ string, targetDirectory *os.File, gitArgs ...string) (*exec.Cmd, error) {
gitPath, err := subproc.GitExecutablePath()
if err != nil {
return nil, fmt.Errorf("find git: %w", err)
@@ -42,22 +61,19 @@ func CommandContext(ctx context.Context, _ string, targetDirectory *os.File) (*e
if err != nil {
return nil, fmt.Errorf("resolve kandev executable: %w", err)
}
command := exec.CommandContext(ctx, executable, helperArgument, gitPath)
commandArgs := append([]string{helperArgument, gitPath}, gitArgs...)
command := exec.CommandContext(ctx, executable, commandArgs...)
command.ExtraFiles = []*os.File{targetDirectory}
command.Env = withHelperEnvironment(os.Environ())
return command, nil
}
func runInheritedDirectory(gitPath string) int {
func runInheritedDirectory(gitPath string, gitArgs []string) int {
if err := unix.Fchdir(inheritedDirectoryFD); err != nil {
fmt.Fprintf(os.Stderr, "git init helper: enter inherited directory: %v\n", err)
return 1
}
if err := subproc.ExecGit(
gitPath,
[]string{"init", "--initial-branch=main"},
withoutHelperEnvironment(os.Environ()),
); err != nil {
if err := subproc.ExecGit(gitPath, gitArgs, withoutHelperEnvironment(os.Environ())); err != nil {
fmt.Fprintf(os.Stderr, "git init helper: execute git: %v\n", err)
return 1
}
@@ -73,7 +73,7 @@ func TestRunHelperRejectsNonDirectoryDescriptor(t *testing.T) {
t.Cleanup(func() { _ = readPipe.Close() })
t.Cleanup(func() { _ = writePipe.Close() })
command := exec.Command(os.Args[0], helperArgument, os.Args[0])
command := exec.Command(os.Args[0], helperArgument, os.Args[0], "init", "--initial-branch=main")
command.ExtraFiles = []*os.File{readPipe}
command.Env = withHelperEnvironment(os.Environ())
output, err := command.CombinedOutput()
@@ -18,7 +18,15 @@ func CommandContext(ctx context.Context, targetPath string, _ *os.File) (*exec.C
return command, nil
}
func runInheritedDirectory(string) int {
// CommitCommandContext creates a Git commit command for targetPath.
func CommitCommandContext(ctx context.Context, targetPath string, _ *os.File, args ...string) (*exec.Cmd, error) {
command := subproc.NewGitCommand(ctx, args...)
command.Dir = targetPath
command.Env = withIsolatedCommitEnvironment(os.Environ())
return command, nil
}
func runInheritedDirectory(string, []string) int {
fmt.Fprintln(os.Stderr, "git init helper is unsupported on this platform")
return 2
}
+32 -4
View File
@@ -1,4 +1,4 @@
// Package gitinit runs Git initialization inside an inherited directory descriptor.
// Package gitinit runs Git commands inside an inherited directory descriptor.
package gitinit
import (
@@ -16,11 +16,11 @@ func runHelper(args []string) (int, bool) {
if len(args) == 0 || args[0] != helperArgument {
return 0, false
}
if len(args) != 2 {
fmt.Fprintln(os.Stderr, "git init helper requires the Git executable path")
if len(args) < 3 {
fmt.Fprintln(os.Stderr, "git init helper requires Git arguments")
return 2, true
}
return runInheritedDirectory(args[1]), true
return runInheritedDirectory(args[1], args[2:]), true
}
func withHelperEnvironment(environment []string) []string {
@@ -38,3 +38,31 @@ func withoutHelperEnvironment(environment []string) []string {
}
return filtered
}
func withIsolatedCommitEnvironment(environment []string) []string {
filtered := make([]string, 0, len(environment)+2)
for _, entry := range environment {
key, _, found := strings.Cut(entry, "=")
if found && isGitCommitEnvironmentKey(key) {
continue
}
filtered = append(filtered, entry)
}
return append(filtered,
"GIT_CONFIG_NOSYSTEM=1",
"GIT_CONFIG_GLOBAL="+os.DevNull,
)
}
func isGitCommitEnvironmentKey(key string) bool {
upperKey := strings.ToUpper(key)
if strings.HasPrefix(upperKey, "GIT_CONFIG_") || upperKey == "GIT_CONFIG" {
return true
}
switch upperKey {
case "GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL":
return true
default:
return false
}
}
@@ -209,6 +209,26 @@ func initializeGitRepository(ctx context.Context, targetPath string, targetDirec
if err != nil {
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(output)))
}
hooksPath, err := os.MkdirTemp("", "kandev-initial-commit-hooks-")
if err != nil {
return fmt.Errorf("prepare initial commit hooks: %w", err)
}
defer func() { _ = os.Remove(hooksPath) }()
commit, err := gitinit.CommitCommandContext(ctx, targetPath, targetDirectory,
"-c", "user.name=Kandev Quick Chat",
"-c", "user.email=quickchat@kandev.local",
"-c", "commit.gpgsign=false",
"-c", "core.hooksPath="+hooksPath,
"commit", "--allow-empty", "--no-verify", "-m", "Initial commit",
)
if err != nil {
return fmt.Errorf("prepare initial commit: %w", err)
}
output, err = subproc.RunGitCombinedOutputClass(ctx, subproc.GitLifecycle, commit)
if err != nil {
return fmt.Errorf("create initial commit: %w: %s", err, strings.TrimSpace(string(output)))
}
return nil
}
@@ -6,6 +6,7 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
@@ -14,7 +15,25 @@ import (
"github.com/kandev/kandev/internal/task/repository"
)
func TestServiceInitializeLocalRepositoryCreatesCommitlessMainRepository(t *testing.T) {
func TestServiceInitializeLocalRepositoryCreatesMainRepository(t *testing.T) {
for key, value := range map[string]string{
"GIT_AUTHOR_NAME": "Host Author",
"GIT_AUTHOR_EMAIL": "host-author@example.com",
"GIT_COMMITTER_NAME": "Host Committer",
"GIT_COMMITTER_EMAIL": "host-committer@example.com",
} {
t.Setenv(key, value)
}
if runtime.GOOS != "windows" {
hooksPath := t.TempDir()
hook := filepath.Join(hooksPath, "prepare-commit-msg")
if err := os.WriteFile(hook, []byte("#!/bin/sh\nexit 42\n"), 0o755); err != nil {
t.Fatalf("WriteFile prepare-commit-msg: %v", err)
}
t.Setenv("GIT_CONFIG_COUNT", "1")
t.Setenv("GIT_CONFIG_KEY_0", "core.hooksPath")
t.Setenv("GIT_CONFIG_VALUE_0", hooksPath)
}
svc, eventBus, repo := createTestService(t)
ctx := context.Background()
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}); err != nil {
@@ -49,10 +68,20 @@ func TestServiceInitializeLocalRepositoryCreatesCommitlessMainRepository(t *test
t.Fatalf("target entries = %+v, want only .git directory", entries)
}
if branch := runGitOutput(t, wantPath, "symbolic-ref", "--short", "HEAD"); branch != "main" {
t.Fatalf("unborn branch = %q, want main", branch)
t.Fatalf("current branch = %q, want main", branch)
}
if command := exec.Command("git", "-C", wantPath, "rev-parse", "--verify", "HEAD"); command.Run() == nil {
t.Fatal("rev-parse HEAD succeeded, want repository with no commits")
command := exec.Command("git", "-C", wantPath, "show-ref", "--verify", "--quiet", "refs/heads/main")
if err := command.Run(); err != nil {
t.Fatalf("show-ref refs/heads/main: %v", err)
}
if commitCount := runGitOutput(t, wantPath, "rev-list", "--count", "HEAD"); commitCount != "1" {
t.Fatalf("commit count = %q, want one empty initial commit", commitCount)
}
if tree := runGitOutput(t, wantPath, "ls-tree", "-r", "--name-only", "HEAD"); tree != "" {
t.Fatalf("initial commit tree = %q, want empty tree", tree)
}
if commit := runGitOutput(t, wantPath, "show", "-s", "--format=%an <%ae>%n%s", "HEAD"); commit != "Kandev Quick Chat <quickchat@kandev.local>\nInitial commit" {
t.Fatalf("initial commit = %q, want fixed Kandev identity and message", commit)
}
stored, err := repo.GetRepository(ctx, created.ID)
if err != nil {
@@ -203,7 +232,13 @@ func TestInitializeGitRepositoryInitializesTheOpenDirectory(t *testing.T) {
t.Fatalf("staging entries = %+v, want only a .git directory", entries)
}
if branch := runGitOutput(t, staging, "symbolic-ref", "--short", "HEAD"); branch != "main" {
t.Fatalf("unborn branch = %q, want main", branch)
t.Fatalf("current branch = %q, want main", branch)
}
if commitCount := runGitOutput(t, staging, "rev-list", "--count", "HEAD"); commitCount != "1" {
t.Fatalf("commit count = %q, want one empty initial commit", commitCount)
}
if tree := runGitOutput(t, staging, "ls-tree", "-r", "--name-only", "HEAD"); tree != "" {
t.Fatalf("initial commit tree = %q, want empty tree", tree)
}
}
@@ -74,7 +74,7 @@ async function createRepository(page: Page, name: string, targetPath: string): P
await expect(page.getByTestId("create-local-repository-dialog")).not.toBeVisible();
}
function expectUnbornMainRepository(repositoryPath: string): void {
function expectMainRepository(repositoryPath: string): void {
expect(fs.statSync(path.join(repositoryPath, ".git")).isDirectory()).toBe(true);
expect(
execFileSync("git", ["symbolic-ref", "--short", "HEAD"], {
@@ -82,9 +82,31 @@ function expectUnbornMainRepository(repositoryPath: string): void {
encoding: "utf8",
}).trim(),
).toBe("main");
const branchRef = spawnSync("git", ["show-ref", "--verify", "--quiet", "refs/heads/main"], {
cwd: repositoryPath,
});
expect(branchRef.error).toBeUndefined();
expect(branchRef.status).toBe(0);
const head = spawnSync("git", ["rev-parse", "--verify", "HEAD"], { cwd: repositoryPath });
expect(head.error).toBeUndefined();
expect(head.status).not.toBe(0);
expect(head.status).toBe(0);
const commitCount = spawnSync("git", ["rev-list", "--count", "HEAD"], {
cwd: repositoryPath,
encoding: "utf8",
});
expect(commitCount.error).toBeUndefined();
expect(commitCount.status).toBe(0);
expect(String(commitCount.stdout).trim()).toBe("1");
const tree = spawnSync("git", ["ls-tree", "-r", "--name-only", "HEAD"], {
cwd: repositoryPath,
encoding: "utf8",
});
expect(tree.error).toBeUndefined();
expect(tree.status).toBe(0);
expect(String(tree.stdout).trim()).toBe("");
}
function taskIdFromUrl(page: Page): string {
@@ -94,13 +116,13 @@ function taskIdFromUrl(page: Page): string {
}
test.describe("Create task with a new local repository", () => {
test("initializes, registers, selects, and starts from an unborn main repository", async ({
test("initializes, registers, selects, and starts from a real main repository", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const repositoryName = "desktop-unborn-main";
const repositoryName = "desktop-real-main";
const repositoryPath = path.join(
backend.tmpDir,
"missing-manual-parent",
@@ -132,7 +154,13 @@ test.describe("Create task with a new local repository", () => {
expect(fs.statSync(path.dirname(repositoryPath)).isDirectory()).toBe(true);
await expect(testPage.getByTestId("repo-chip-trigger")).toContainText(repositoryName);
await expect(testPage.getByTestId("branch-chip-trigger").first()).toContainText("main");
const branchSelector = testPage.getByTestId("branch-chip-trigger").first();
await expect(branchSelector).toBeEnabled({ timeout: 10_000 });
await branchSelector.click();
const mainOption = testPage.getByRole("option", { name: /^main\b/ }).first();
await expect(mainOption).toBeVisible();
await mainOption.click();
await expect(branchSelector).toContainText("main");
await expect(testPage.getByTestId("executor-profile-selector")).toContainText(
directExecutor!.name,
);
@@ -162,7 +190,7 @@ test.describe("Create task with a new local repository", () => {
executor_type: expect.stringMatching(/^(local|local_pc)$/),
executor_profile_id: directProfile!.id,
});
expectUnbornMainRepository(repositoryPath);
expectMainRepository(repositoryPath);
});
test("leaves a conflicting target untouched and allows retry with a new name", async ({
@@ -198,6 +226,6 @@ test.describe("Create task with a new local repository", () => {
await nameInput.fill(retryName);
await createRepository(testPage, retryName, retryPath);
await expect(testPage.getByTestId("repo-chip-trigger")).toContainText(retryName);
expectUnbornMainRepository(retryPath);
expectMainRepository(retryPath);
});
});
@@ -108,7 +108,7 @@ async function expectDrawerGeometry(page: Page, drawer: Locator): Promise<void>
).toBe(false);
}
function expectUnbornMainRepository(repositoryPath: string): void {
function expectMainRepository(repositoryPath: string): void {
const symbolicRef = spawnSync("git", ["symbolic-ref", "--short", "HEAD"], {
cwd: repositoryPath,
encoding: "utf8",
@@ -117,9 +117,31 @@ function expectUnbornMainRepository(repositoryPath: string): void {
expect(symbolicRef.status).toBe(0);
expect(String(symbolicRef.stdout).trim()).toBe("main");
const branchRef = spawnSync("git", ["show-ref", "--verify", "--quiet", "refs/heads/main"], {
cwd: repositoryPath,
});
expect(branchRef.error).toBeUndefined();
expect(branchRef.status).toBe(0);
const head = spawnSync("git", ["rev-parse", "--verify", "HEAD"], { cwd: repositoryPath });
expect(head.error).toBeUndefined();
expect(head.status).not.toBe(0);
expect(head.status).toBe(0);
const commitCount = spawnSync("git", ["rev-list", "--count", "HEAD"], {
cwd: repositoryPath,
encoding: "utf8",
});
expect(commitCount.error).toBeUndefined();
expect(commitCount.status).toBe(0);
expect(String(commitCount.stdout).trim()).toBe("1");
const tree = spawnSync("git", ["ls-tree", "-r", "--name-only", "HEAD"], {
cwd: repositoryPath,
encoding: "utf8",
});
expect(tree.error).toBeUndefined();
expect(tree.status).toBe(0);
expect(String(tree.stdout).trim()).toBe("");
}
test.describe("Create task with a new local repository on mobile", () => {
@@ -129,7 +151,7 @@ test.describe("Create task with a new local repository on mobile", () => {
seedData,
backend,
}) => {
const repositoryName = "mobile-unborn-main";
const repositoryName = "mobile-real-main";
const parentName = "mobile-created-parent";
const parentPath = path.join(backend.tmpDir, parentName);
const repositoryPath = path.join(parentPath, repositoryName);
@@ -176,7 +198,13 @@ test.describe("Create task with a new local repository on mobile", () => {
await expect(drawer).not.toBeVisible();
await expect(testPage.getByTestId("repo-chip-trigger")).toContainText(repositoryName);
await expect(testPage.getByTestId("branch-chip-trigger").first()).toContainText("main");
const branchSelector = testPage.getByTestId("branch-chip-trigger").first();
await expect(branchSelector).toBeEnabled({ timeout: 10_000 });
await branchSelector.tap();
const mainOption = testPage.getByRole("option", { name: /^main\b/ }).first();
await expect(mainOption).toBeVisible();
await mainOption.tap();
await expect(branchSelector).toContainText("main");
await expect(testPage.getByTestId("executor-profile-selector")).toContainText(
directExecutor!.name,
);
@@ -204,6 +232,6 @@ test.describe("Create task with a new local repository on mobile", () => {
executor_profile_id: directProfile!.id,
});
expect(fs.statSync(path.join(repositoryPath, ".git")).isDirectory()).toBe(true);
expectUnbornMainRepository(repositoryPath);
expectMainRepository(repositoryPath);
});
});
+46 -37
View File
@@ -1,18 +1,19 @@
---
spec: docs/specs/create-local-repository/spec.md
created: 2026-07-21
status: completed
updated: 2026-08-05
status: implemented
---
# Implementation Plan: Create a Local Repository During Task Creation
## Overview
Add an explicit backend operation that owns directory creation, commitless Git initialization, and
workspace repository persistence. Then add a selector action that opens a shared directory-browser
form, merges the returned repository into workspace state, selects it in the originating task row,
and moves the first task to a compatible direct local executor. Desktop and mobile use the same
mutation/state logic with purpose-built Dialog and Drawer shells.
The original implementation owns directory creation, Git initialization, and workspace repository
persistence, then merges the returned repository into the task-create selector on desktop and
mobile. This follow-up changes the initialization contract so the new repository has one empty root
commit on `main`; the existing branch endpoint can therefore return a real local branch and the
selector can offer it as a base without a frontend production-code change.
## Backend
@@ -24,7 +25,10 @@ mutation/state logic with purpose-built Dialog and Drawer shells.
- Validate that `Name` is a single non-empty host path segment and `ParentPath` is an existing,
writable absolute directory. Canonicalize the parent before joining the target, atomically create
the absent target, and never modify an existing path.
- Run `git init --initial-branch=main` without creating files, configuring an author, or committing.
- Run `git init --initial-branch=main`, then create one empty, unsigned initial commit on `main`
without adding working-tree files. Use the existing classified Git subprocess seam and fixed
Kandev commit identity so the operation does not depend on the host user's Git identity; both
commands remain bound to the opened staging directory on descriptor-safe platforms.
- Register the canonical target through the existing `CreateRepository` path with `source_type=local`
and `default_branch=main`, preserving validation and `repository.created` publication.
- On a partial failure, leave no repository row and remove only the target created by this request
@@ -75,6 +79,14 @@ mutation/state logic with purpose-built Dialog and Drawer shells.
`apps/web/components/task-create-dialog-handlers.test.ts` for action visibility, row targeting,
cache merge, success, conflict, retry, and cancel behavior.
### Branch selector follow-up
- Keep the existing repository and branch picker wiring unchanged. Once the backend creates
`refs/heads/main`, `useBranches` and `Pill` will expose `main` as a selectable local option for
the newly returned repository.
- Update the existing desktop and mobile creation E2E assertions to open the branch picker, verify
that `main` is enabled and listed, and select it as the task's base branch.
## Mobile Design Contract
- Desktop keeps the repository search popover and opens a compact creation Dialog from its visible
@@ -93,8 +105,8 @@ mutation/state logic with purpose-built Dialog and Drawer shells.
- **Initialization success:**
`apps/backend/internal/task/service/local_repository_initialization_test.go` uses `t.TempDir()` and
real Git to assert canonical path, unborn `main`, the absence of `HEAD` commits, persisted workspace
record, and repository-created event.
real Git to assert canonical path, a real `refs/heads/main` pointing at one empty initial commit,
no user files in the working tree, the persisted workspace record, and the repository-created event.
- **Input and conflict safety:** the same service test table covers invalid names, relative/missing/
non-directory parents, existing empty/non-empty targets, and no mutation or persistence.
- **Partial failure cleanup:** service tests inject or induce Git/persistence failures and assert no
@@ -110,15 +122,15 @@ mutation/state logic with purpose-built Dialog and Drawer shells.
## E2E Tests
- Desktop: add `apps/web/e2e/tests/task/create-task-new-local-repository.spec.ts`. Open **New Task**,
create a repository under the isolated backend home, assert it is selected with `main`, submit the
task, and verify the task is bound to the persisted repository, uses a direct local executor, and
the filesystem has no commit. Add a target-conflict case that proves the existing directory remains
unchanged.
- Mobile: add `apps/web/e2e/tests/task/mobile-create-task-new-local-repository.spec.ts`. Enter through
`MobileKanbanPage.mobileFab`, complete the same create-and-submit outcome, and assert Drawer
containment, a scrollable internal list, 44px action rows, visible safe-area footer, focus/dismiss
behavior, and zero document horizontal overflow.
- Desktop: update `apps/web/e2e/tests/task/create-task-new-local-repository.spec.ts`. Open **New
Task**, create a repository under the isolated backend home, assert it is selected with `main`,
open the branch picker and select the listed local `main`, submit the task, and verify the task is
bound to the persisted repository, uses the existing direct local executor policy, and the
filesystem has one empty commit with no user files. Keep the target-conflict case unchanged.
- Mobile: update `apps/web/e2e/tests/task/mobile-create-task-new-local-repository.spec.ts`. Enter
through `MobileKanbanPage.mobileFab`, complete the same create-and-select outcome, open the branch
picker and select `main`, and retain the Drawer containment, internal scroll, 44px action rows,
safe-area footer, focus/dismiss, and zero-horizontal-overflow assertions.
## Implementation Waves
@@ -134,34 +146,31 @@ Wave 3 (after integrated backend and frontend):
- [x] [task-03-e2e-and-verification](task-03-e2e-and-verification.md) - done
Wave 4 (follow-up):
- [x] [task-04-real-main-branch](task-04-real-main-branch.md) - done
The frontend task is sequential because the shared picker, task-create handlers, and state wiring are
one behavior and edit overlapping files. E2E follows the integrated product path.
## Verification
## Verification Results
```bash
make fmt
make typecheck
(cd apps/backend && go test ./internal/task/service ./internal/task/handlers)
(cd apps && pnpm --filter @kandev/web test -- --run \
components/create-local-repository-surface.test.tsx \
components/task-create-dialog-pill.test.tsx \
components/task-create-dialog-repo-chips.test.tsx \
components/task-create-dialog-handlers.test.ts)
(cd apps/web && pnpm e2e:run tests/task/create-task-new-local-repository.spec.ts)
(cd apps/web && pnpm e2e:run --no-build --project mobile-chrome \
tests/task/mobile-create-task-new-local-repository.spec.ts)
make test
make lint
```
Task 04 completed with the requested change-aware checks:
- `(cd apps && pnpm install --frozen-lockfile)` passed.
- `(cd apps/backend && go test ./internal/task/service ./internal/task/handlers)` passed.
- `(cd apps/backend && go test ./internal/task/gitinit)` passed.
- `(cd apps/web && pnpm e2e:run tests/task/create-task-new-local-repository.spec.ts)` passed (2 tests).
- `(cd apps/web && pnpm e2e:run --project mobile-chrome tests/task/mobile-create-task-new-local-repository.spec.ts)` passed (1 test).
- Changed E2E files passed Prettier checks and `git diff --check` passed.
## Risks
- Filesystem and database persistence cannot share a transaction. The service must narrowly own and
test partial-failure cleanup without ever deleting a pre-existing target.
- An unborn Git repository cannot support the existing branch/worktree flow. The form must switch
the first task to a direct local executor and must not silently fall back to worktree, container, or
remote execution.
- The initial commit changes the repository history contract. The commit must remain empty and use a
deterministic, non-interactive identity so branch availability does not introduce user files,
signing prompts, or a dependency on host Git configuration.
- The repository list may already be hydrated while the event arrives asynchronously. Selection must
use the returned DTO directly and cache insertion must be idempotent.
- Nested overlays inside the full-height mobile task dialog can break scroll and focus ownership. The
@@ -0,0 +1,105 @@
---
id: "04-real-main-branch"
title: "Create a real main branch"
status: done
wave: 4
depends_on: ["01-backend-initialization", "02-task-create-selector", "03-e2e-and-verification"]
plan: "plan.md"
spec: "../../specs/create-local-repository/spec.md"
---
# Task 04: Create a Real Main Branch
The original create-local-repository flow intentionally left Git at an unborn `main` HEAD. This
follow-up supersedes that narrow filesystem contract while preserving the existing path-trust,
cleanup, repository-cache, executor-selection, and responsive picker behavior.
## Acceptance
- A newly initialized repository contains no user files and exactly one empty initial commit
reachable from a local `main` branch (`refs/heads/main`); the repository DTO and persisted
`default_branch` remain `main`.
- The existing task-create branch picker receives the new repository's local `main` branch, keeps
the branch control enabled, and lets the user select `main` as the base branch on desktop and on
the existing mobile flow.
- Git initialization or initial-commit failure still leaves no persisted repository and performs the
existing request-owned cleanup; existing conflict, retry, and mobile geometry coverage remains
green.
## Verification
```bash
(cd apps && pnpm install --frozen-lockfile)
(cd apps/backend && go test ./internal/task/service ./internal/task/handlers)
(cd apps/web && pnpm e2e:run tests/task/create-task-new-local-repository.spec.ts)
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/task/mobile-create-task-new-local-repository.spec.ts)
git diff --check
```
The frontend E2E commands use the managed runner so the production Vite build and Go backend are
rebuilt before the branch-selector assertions. If the worktree already has dependencies installed,
the bootstrap command is a no-op.
## Files Likely Touched
- `apps/backend/internal/task/service/local_repository_initialization.go`
- `apps/backend/internal/task/service/local_repository_initialization_test.go`
- `apps/backend/internal/task/gitinit/command_descriptor.go`
- `apps/backend/internal/task/gitinit/command_descriptor_test.go`
- `apps/backend/internal/task/gitinit/command_path.go`
- `apps/backend/internal/task/gitinit/helper.go`
- `apps/web/e2e/tests/task/create-task-new-local-repository.spec.ts`
- `apps/web/e2e/tests/task/mobile-create-task-new-local-repository.spec.ts`
- `docs/specs/create-local-repository/spec.md`
- `docs/plans/create-local-repository/plan.md`
## Dependencies
The original local-repository initialization and task-create selector implementation are already
landed. No new API route, database migration, frontend component, or responsive surface is needed.
## Inputs
- Spec: updated `What`, `Data Model`, `Failure Modes`, `Persistence Guarantees`, and `Scenarios`.
- Existing Git initialization: `apps/backend/internal/task/gitinit/` and
`initializeGitRepository` in `local_repository_initialization.go`.
- Existing branch listing: `listGitBranches` in
`apps/backend/internal/task/service/repository_discovery.go`.
- Existing selector wiring: `useBranches` in
`apps/web/hooks/domains/workspace/use-repository-branches.ts` and the branch `Pill` in
`apps/web/components/task-create-dialog-workspace-repo-chips.tsx`.
- Mobile contract: existing Drawer and mobile E2E coverage in
`mobile-create-task-new-local-repository.spec.ts`; no layout change is planned.
## Implementation Notes
- Keep `git init --initial-branch=main` on the existing descriptor-safe path.
- After initialization, run a classified lifecycle Git commit in the request-owned staging
directory using `--allow-empty --no-verify`, `commit.gpgsign=false`, and fixed Kandev author/committer
config. Do not create `.gitkeep`, README, or any other working-tree file.
- Update the service assertions and E2E helper names from “unborn/commitless” to a real local main
branch and empty baseline commit. The E2E tests should explicitly open the branch picker and assert
a selectable `main` option rather than relying only on the chip's prefilled text.
## Parallelism
Sequential. The backend behavior and both E2E assertions describe one cross-layer contract, and the
E2E tests share the existing creation flow.
## Output Contract
Report the exact Git command behavior, files changed, targeted backend and desktop/mobile E2E
results, `git diff --check`, cleanup evidence, and any platform-specific blockers. Before marking
this task done, reconcile the actual diff with Files Likely Touched and synchronize this task's
status, the plan checkbox, and `Verification Results` in `plan.md`.
## Results
- Workspace bootstrap: `(cd apps && pnpm install --frozen-lockfile)` passed.
- Backend service and handler tests: `(cd apps/backend && go test ./internal/task/service ./internal/task/handlers)` passed.
- Descriptor helper tests: `(cd apps/backend && go test ./internal/task/gitinit)` passed.
- Desktop Playwright: `(cd apps/web && pnpm e2e:run tests/task/create-task-new-local-repository.spec.ts)` passed, 2 tests.
- Mobile Playwright: `(cd apps/web && pnpm e2e:run --project mobile-chrome tests/task/mobile-create-task-new-local-repository.spec.ts)` passed, 1 test.
- Changed E2E files passed Prettier formatting checks.
- `git diff --check` passed.
- Cleanup, persistence, conflict/retry, descriptor replacement, empty-tree, fixed-identity, and no-user-file assertions passed.
+25 -22
View File
@@ -1,6 +1,7 @@
---
status: shipped
created: 2026-07-21
updated: 2026-08-05
owner: kandev
---
@@ -25,18 +26,17 @@ repository on the machine running Kandev, select it, and continue composing the
directory browsing behavior as the **None** source mode's starting-folder picker. It also lets
the user create and enter one new child folder in the currently displayed directory.
- Confirmation creates `<parent>/<name>` only when that target does not already exist, initializes a
local Git repository with unborn default branch `main`, and does not create files or commits.
local Git repository with default branch `main`, and creates one empty initial commit on that
branch. The working tree contains no user files.
- Git initialization remains bound to the exact request-owned directory identity that the backend
opened and verified. Replacing its pathname before or during initialization must not cause Git to
modify the replacement path.
- A successfully initialized repository is persisted as a local repository in the active workspace,
added to the in-memory workspace repository list, selected in the originating row, and displays
`main` as that row's branch. Other task fields and repository rows are unchanged.
- A commitless repository can run only with a direct local executor. Confirmation automatically
selects a compatible `local` or `local_pc` executor profile for the first task and explains the
change. If no direct local profile is available, confirmation is blocked before filesystem
mutation. After the repository has a commit, later tasks may use any otherwise compatible
executor through the normal task flow.
- The task-create flow continues to select a compatible `local` or `local_pc` executor profile for
the first task when the selected profile cannot run the newly created local repository, and
explains the change. Executor selection is unchanged by the initial baseline commit.
- Multi-repository task creation does not expose the action because those tasks require the worktree
executor. Users can add the repository to a multi-repository task after it has its first commit.
- Initialization is an explicit local-path trust grant for the exact canonical repository path. It
@@ -66,9 +66,10 @@ No new database entity is introduced. A successful initialization creates one ex
| `default_branch` | `main`. |
The filesystem repository and the `repositories` row are created as one product operation when the
repository form is confirmed, before the task is submitted. A successful response means both exist;
a failed response must not leave a repository row. The filesystem cleanup behavior for a partial
failure is described under Failure modes.
repository form is confirmed, before the task is submitted. The created Git repository contains one
empty root commit reachable from `refs/heads/main`, but no user files in the working tree. A
successful response means both exist; a failed response must not leave a repository row. The
filesystem cleanup behavior for a partial failure is described under Failure modes.
## API Surface
@@ -125,16 +126,17 @@ to the created canonical target path and the active workspace.
| The nearest existing parent ancestor cannot be trusted, read, or written | The form stays open, shows an error, and no repository is selected or persisted. |
| Target already exists, including an empty directory | The request returns conflict and does not modify the target. |
| The request-owned staging pathname is replaced during Git initialization | The operation fails without modifying the replacement path. Cleanup remains limited to a request-owned directory whose identity can still be verified. |
| `git` is unavailable or initialization fails | The request fails, no repository row is persisted, and Kandev removes only the target directory created by this request when cleanup is safe. A cleanup failure is logged and the error remains visible. |
| Repository persistence fails after Git initialization | The request fails and performs the same best-effort cleanup of the request-owned target; no repository row is returned. |
| `git` is unavailable, initialization fails, or the initial commit fails | The request fails, no repository row is persisted, and Kandev removes only the target directory created by this request when cleanup is safe. A cleanup failure is logged and the error remains visible. |
| Repository persistence fails after Git initialization or the initial commit | The request fails and performs the same best-effort cleanup of the request-owned target; no repository row is returned. |
| Frontend state refresh fails after a successful response | The returned repository is still selected directly and merged into the active workspace cache without waiting for a second list request. |
| No direct local executor profile is available | The form explains that an empty repository requires local execution and does not call the initialization endpoint. |
| No compatible direct local executor profile is available for the existing task-create policy | The form explains the requirement and does not call the initialization endpoint. |
## Persistence Guarantees
The initialized Git repository is ordinary user-owned filesystem content and is never tied to task
deletion. Its existing `repositories` record survives backend restart. Canceling, archiving, or
deleting the task does not delete the repository or its workspace registration.
The initialized Git repository, including its empty baseline commit, is ordinary user-owned
filesystem content and is never tied to task deletion. Its existing `repositories` record survives
backend restart. Canceling, archiving, or deleting the task does not delete the repository or its
workspace registration.
## Mobile Design Contract
@@ -158,17 +160,17 @@ deleting the task does not delete the repository or its workspace registration.
- **GIVEN** the task dialog has a local repository row, **WHEN** the user chooses **Create new
repository**, selects `/work/projects`, enters `alpha`, and confirms, **THEN**
`/work/projects/alpha` contains a commitless Git repository whose unborn branch is `main`, the
workspace has a matching local repository record, the originating task row selects `alpha` /
`main`, and the first task uses a direct local executor.
`/work/projects/alpha` contains a Git repository with one empty initial commit on `main` and no
user files, the workspace has a matching local repository record, the branch endpoint returns a
local `main` option, and the originating task row selects `alpha` / `main`.
- **GIVEN** a worktree or container executor profile is selected and a direct local profile is
available, **WHEN** repository initialization succeeds, **THEN** the form selects the compatible
direct local profile and tells the user why the executor changed.
- **GIVEN** no direct local executor profile is available, **WHEN** the user opens the creation form,
**THEN** the primary action explains the requirement and no directory or repository is created.
- **GIVEN** a task with multiple repository rows, **WHEN** the user opens any repository picker,
**THEN** **Create new repository** is not offered because an unborn repository cannot use the
required worktree executor.
**THEN** **Create new repository** is not offered because the action remains limited to the
existing single-row task-create flow.
- **GIVEN** `/work/projects/alpha` already exists, **WHEN** the user tries to create `alpha` under
`/work/projects`, **THEN** the UI reports the conflict and Kandev does not modify or register the
existing path.
@@ -199,9 +201,10 @@ deleting the task does not delete the repository or its workspace registration.
- Cloning, importing, or converting an existing directory; existing paths continue through the
existing local repository selection and settings flows.
- README, license, `.gitignore`, language template, remote, visibility, or hosting-provider setup.
- Choosing the initial branch name or creating an initial commit.
- Choosing the initial branch name; new repositories always start on `main` with the empty baseline
commit described above.
- Worktree, container, or remote execution before the repository has its first commit.
- Creating an unborn repository as part of a multi-repository task.
- Creating a new repository as part of a multi-repository task.
- Adding the creation action to Quick Chat, repository settings, project repository pickers, or MCP
tools in this iteration.
- Automatically deleting a successfully created repository when its originating task is canceled or