fix: initialize new repositories with a main branch (#2297)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user