From 7cd0989fe85579f6a176353b7c9c3d23ec88ce5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Wed, 5 Aug 2026 22:34:26 +0100 Subject: [PATCH] fix: initialize new repositories with a main branch (#2297) --- .../task/gitinit/command_descriptor.go | 32 ++++-- .../task/gitinit/command_descriptor_test.go | 2 +- .../internal/task/gitinit/command_path.go | 10 +- apps/backend/internal/task/gitinit/helper.go | 36 +++++- .../local_repository_initialization.go | 20 ++++ .../local_repository_initialization_test.go | 45 +++++++- .../create-task-new-local-repository.spec.ts | 42 +++++-- ...e-create-task-new-local-repository.spec.ts | 38 ++++++- docs/plans/create-local-repository/plan.md | 83 ++++++++------ .../task-04-real-main-branch.md | 105 ++++++++++++++++++ docs/specs/create-local-repository/spec.md | 47 ++++---- 11 files changed, 370 insertions(+), 90 deletions(-) create mode 100644 docs/plans/create-local-repository/task-04-real-main-branch.md diff --git a/apps/backend/internal/task/gitinit/command_descriptor.go b/apps/backend/internal/task/gitinit/command_descriptor.go index eb6a0712c7..7320de43dc 100644 --- a/apps/backend/internal/task/gitinit/command_descriptor.go +++ b/apps/backend/internal/task/gitinit/command_descriptor.go @@ -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 } diff --git a/apps/backend/internal/task/gitinit/command_descriptor_test.go b/apps/backend/internal/task/gitinit/command_descriptor_test.go index 672e9f955e..c8b887c323 100644 --- a/apps/backend/internal/task/gitinit/command_descriptor_test.go +++ b/apps/backend/internal/task/gitinit/command_descriptor_test.go @@ -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() diff --git a/apps/backend/internal/task/gitinit/command_path.go b/apps/backend/internal/task/gitinit/command_path.go index ea924b1c38..47a78c62b9 100644 --- a/apps/backend/internal/task/gitinit/command_path.go +++ b/apps/backend/internal/task/gitinit/command_path.go @@ -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 } diff --git a/apps/backend/internal/task/gitinit/helper.go b/apps/backend/internal/task/gitinit/helper.go index 755cc3c0f5..372a3fe4eb 100644 --- a/apps/backend/internal/task/gitinit/helper.go +++ b/apps/backend/internal/task/gitinit/helper.go @@ -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 + } +} diff --git a/apps/backend/internal/task/service/local_repository_initialization.go b/apps/backend/internal/task/service/local_repository_initialization.go index 375ab2b190..bb4f6d0356 100644 --- a/apps/backend/internal/task/service/local_repository_initialization.go +++ b/apps/backend/internal/task/service/local_repository_initialization.go @@ -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 } diff --git a/apps/backend/internal/task/service/local_repository_initialization_test.go b/apps/backend/internal/task/service/local_repository_initialization_test.go index 862affed53..e2a73db391 100644 --- a/apps/backend/internal/task/service/local_repository_initialization_test.go +++ b/apps/backend/internal/task/service/local_repository_initialization_test.go @@ -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 \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) } } diff --git a/apps/web/e2e/tests/task/create-task-new-local-repository.spec.ts b/apps/web/e2e/tests/task/create-task-new-local-repository.spec.ts index 40b59767e4..efeb4549a5 100644 --- a/apps/web/e2e/tests/task/create-task-new-local-repository.spec.ts +++ b/apps/web/e2e/tests/task/create-task-new-local-repository.spec.ts @@ -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); }); }); diff --git a/apps/web/e2e/tests/task/mobile-create-task-new-local-repository.spec.ts b/apps/web/e2e/tests/task/mobile-create-task-new-local-repository.spec.ts index 718897b8d9..0197b967c0 100644 --- a/apps/web/e2e/tests/task/mobile-create-task-new-local-repository.spec.ts +++ b/apps/web/e2e/tests/task/mobile-create-task-new-local-repository.spec.ts @@ -108,7 +108,7 @@ async function expectDrawerGeometry(page: Page, drawer: Locator): Promise ).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); }); }); diff --git a/docs/plans/create-local-repository/plan.md b/docs/plans/create-local-repository/plan.md index d1ea56775b..87a83c18fb 100644 --- a/docs/plans/create-local-repository/plan.md +++ b/docs/plans/create-local-repository/plan.md @@ -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 diff --git a/docs/plans/create-local-repository/task-04-real-main-branch.md b/docs/plans/create-local-repository/task-04-real-main-branch.md new file mode 100644 index 0000000000..ab72395a3a --- /dev/null +++ b/docs/plans/create-local-repository/task-04-real-main-branch.md @@ -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. diff --git a/docs/specs/create-local-repository/spec.md b/docs/specs/create-local-repository/spec.md index 3078c666ee..9ff4345f09 100644 --- a/docs/specs/create-local-repository/spec.md +++ b/docs/specs/create-local-repository/spec.md @@ -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 `/` 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