diff --git a/apps/backend/internal/task/gitinit/command_descriptor.go b/apps/backend/internal/task/gitinit/command_descriptor.go new file mode 100644 index 0000000000..75d09daf0b --- /dev/null +++ b/apps/backend/internal/task/gitinit/command_descriptor.go @@ -0,0 +1,64 @@ +//go:build linux || darwin + +package gitinit + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + + "golang.org/x/sys/unix" +) + +const inheritedDirectoryFD = 3 + +// init handles the explicitly marked helper subprocess before a test binary or +// another Kandev entry point can interpret the private helper argument. +func init() { + if os.Getenv(helperEnvironmentVariable) != "1" { + return + } + if code, handled := runHelper(os.Args[1:]); handled { + os.Exit(code) + } +} + +// CommandContext creates a Git initialization command bound to targetDirectory. +func CommandContext(ctx context.Context, _ string, targetDirectory *os.File) (*exec.Cmd, error) { + gitPath, err := exec.LookPath("git") + if err != nil { + return nil, fmt.Errorf("find git: %w", err) + } + if !filepath.IsAbs(gitPath) { + gitPath, err = filepath.Abs(gitPath) + if err != nil { + return nil, fmt.Errorf("resolve git executable: %w", err) + } + } + executable, err := os.Executable() + if err != nil { + return nil, fmt.Errorf("resolve kandev executable: %w", err) + } + command := exec.CommandContext(ctx, executable, helperArgument, gitPath) + command.ExtraFiles = []*os.File{targetDirectory} + command.Env = withHelperEnvironment(os.Environ()) + return command, nil +} + +func runInheritedDirectory(gitPath 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 := unix.Exec( + gitPath, + []string{"git", "init", "--initial-branch=main"}, + withoutHelperEnvironment(os.Environ()), + ); err != nil { + fmt.Fprintf(os.Stderr, "git init helper: execute git: %v\n", err) + return 1 + } + return 0 +} diff --git a/apps/backend/internal/task/gitinit/command_descriptor_test.go b/apps/backend/internal/task/gitinit/command_descriptor_test.go new file mode 100644 index 0000000000..672e9f955e --- /dev/null +++ b/apps/backend/internal/task/gitinit/command_descriptor_test.go @@ -0,0 +1,86 @@ +//go:build linux || darwin + +package gitinit + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestCommandContextInitializesInheritedDirectory(t *testing.T) { + parent := t.TempDir() + path := filepath.Join(parent, "staging") + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatalf("Mkdir staging: %v", err) + } + directory, err := os.Open(path) + if err != nil { + t.Fatalf("Open staging: %v", err) + } + t.Cleanup(func() { _ = directory.Close() }) + + openedPath := filepath.Join(parent, "opened") + if err := os.Rename(path, openedPath); err != nil { + t.Fatalf("Rename staging: %v", err) + } + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatalf("Mkdir replacement: %v", err) + } + + command, err := CommandContext(context.Background(), path, directory) + if err != nil { + t.Fatalf("CommandContext: %v", err) + } + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("git init: %v: %s", err, output) + } + + if info, err := os.Stat(filepath.Join(openedPath, ".git")); err != nil || !info.IsDir() { + t.Fatalf("opened directory .git: info=%v error=%v", info, err) + } + entries, err := os.ReadDir(path) + if err != nil { + t.Fatalf("ReadDir replacement: %v", err) + } + if len(entries) != 0 { + t.Fatalf("replacement entries = %+v, want none", entries) + } +} + +func TestCommandContextRejectsMissingGit(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + directory, err := os.Open(t.TempDir()) + if err != nil { + t.Fatalf("Open directory: %v", err) + } + t.Cleanup(func() { _ = directory.Close() }) + + command, err := CommandContext(context.Background(), directory.Name(), directory) + if err == nil { + t.Fatalf("CommandContext command = %v, want missing Git error", command) + } +} + +func TestRunHelperRejectsNonDirectoryDescriptor(t *testing.T) { + readPipe, writePipe, err := os.Pipe() + if err != nil { + t.Fatalf("Pipe: %v", err) + } + t.Cleanup(func() { _ = readPipe.Close() }) + t.Cleanup(func() { _ = writePipe.Close() }) + + command := exec.Command(os.Args[0], helperArgument, os.Args[0]) + command.ExtraFiles = []*os.File{readPipe} + command.Env = withHelperEnvironment(os.Environ()) + output, err := command.CombinedOutput() + if err == nil { + t.Fatal("helper succeeded with a pipe as fd 3") + } + if !strings.Contains(string(output), "enter inherited directory") { + t.Fatalf("helper output = %q, want inherited directory error", output) + } +} diff --git a/apps/backend/internal/task/gitinit/command_path.go b/apps/backend/internal/task/gitinit/command_path.go new file mode 100644 index 0000000000..0c5eb99289 --- /dev/null +++ b/apps/backend/internal/task/gitinit/command_path.go @@ -0,0 +1,22 @@ +//go:build !linux && !darwin + +package gitinit + +import ( + "context" + "fmt" + "os" + "os/exec" +) + +// CommandContext creates a Git initialization command for platforms without inherited fd support. +func CommandContext(ctx context.Context, targetPath string, _ *os.File) (*exec.Cmd, error) { + command := exec.CommandContext(ctx, "git", "init", "--initial-branch=main") + command.Dir = targetPath + return command, nil +} + +func runInheritedDirectory(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 new file mode 100644 index 0000000000..755cc3c0f5 --- /dev/null +++ b/apps/backend/internal/task/gitinit/helper.go @@ -0,0 +1,40 @@ +// Package gitinit runs Git initialization inside an inherited directory descriptor. +package gitinit + +import ( + "fmt" + "os" + "strings" +) + +const ( + helperArgument = "__git-init-open-directory" + helperEnvironmentVariable = "KANDEV_INTERNAL_GIT_INIT_HELPER" +) + +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") + return 2, true + } + return runInheritedDirectory(args[1]), true +} + +func withHelperEnvironment(environment []string) []string { + filtered := withoutHelperEnvironment(environment) + return append(filtered, helperEnvironmentVariable+"=1") +} + +func withoutHelperEnvironment(environment []string) []string { + prefix := helperEnvironmentVariable + "=" + filtered := make([]string, 0, len(environment)) + for _, entry := range environment { + if !strings.HasPrefix(entry, prefix) { + filtered = append(filtered, entry) + } + } + return filtered +} diff --git a/apps/backend/internal/task/gitinit/helper_test.go b/apps/backend/internal/task/gitinit/helper_test.go new file mode 100644 index 0000000000..de06525ec5 --- /dev/null +++ b/apps/backend/internal/task/gitinit/helper_test.go @@ -0,0 +1,15 @@ +package gitinit + +import "testing" + +func TestRunHelperIgnoresPublicArguments(t *testing.T) { + if code, handled := runHelper([]string{"--help"}); handled || code != 0 { + t.Fatalf("runHelper = (%d, %t), want (0, false)", code, handled) + } +} + +func TestRunHelperRejectsMissingGitPath(t *testing.T) { + if code, handled := runHelper([]string{helperArgument}); !handled || code != 2 { + t.Fatalf("runHelper = (%d, %t), want (2, true)", code, handled) + } +} diff --git a/apps/backend/internal/task/service/local_repository_initialization.go b/apps/backend/internal/task/service/local_repository_initialization.go index 4e9112bc34..2c110af06a 100644 --- a/apps/backend/internal/task/service/local_repository_initialization.go +++ b/apps/backend/internal/task/service/local_repository_initialization.go @@ -9,12 +9,11 @@ import ( "io" "io/fs" "os" - "os/exec" "path/filepath" "runtime" - "strconv" "strings" + "github.com/kandev/kandev/internal/task/gitinit" "github.com/kandev/kandev/internal/task/models" "go.uber.org/zap" ) @@ -201,14 +200,10 @@ func (s *Service) closeLocalRepositoryStaging(staging *localRepositoryStaging) { } func initializeGitRepository(ctx context.Context, targetPath string, targetDirectory *os.File) error { - gitTarget := targetPath - var extraFiles []*os.File - if inheritedPath := inheritedDirectoryPath(3); inheritedPath != "" { - gitTarget = inheritedPath - extraFiles = []*os.File{targetDirectory} + command, err := gitinit.CommandContext(ctx, targetPath, targetDirectory) + if err != nil { + return err } - command := exec.CommandContext(ctx, "git", "init", "--initial-branch=main", gitTarget) - command.ExtraFiles = extraFiles output, err := command.CombinedOutput() if err != nil { return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(output))) @@ -290,17 +285,6 @@ func removeLocalRepositoryRootTree(root *os.Root, name string) error { return root.Remove(name) } -func inheritedDirectoryPath(fd int) string { - switch runtime.GOOS { - case "linux": - return "/proc/self/fd/" + strconv.Itoa(fd) - case "darwin", "freebsd", "openbsd", "netbsd": - return "/dev/fd/" + strconv.Itoa(fd) - default: - return "" - } -} - func localRepositoryTargetMatches(targetPath string, createdTarget fs.FileInfo) bool { currentTarget, err := lstatLocalRepositoryPath(targetPath) return err == nil && currentTarget.IsDir() && os.SameFile(createdTarget, currentTarget) diff --git a/apps/backend/internal/task/service/local_repository_initialization_descriptor_test.go b/apps/backend/internal/task/service/local_repository_initialization_descriptor_test.go new file mode 100644 index 0000000000..f4bd96c6ad --- /dev/null +++ b/apps/backend/internal/task/service/local_repository_initialization_descriptor_test.go @@ -0,0 +1,46 @@ +//go:build linux || darwin + +package service + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestInitializeGitRepositoryDoesNotFollowReplacedDirectoryPath(t *testing.T) { + parent := t.TempDir() + staging := filepath.Join(parent, "staging") + if err := os.Mkdir(staging, 0o700); err != nil { + t.Fatalf("Mkdir staging: %v", err) + } + directory, err := openLocalRepositoryDirectory(staging) + if err != nil { + t.Fatalf("openLocalRepositoryDirectory: %v", err) + } + t.Cleanup(func() { _ = directory.Close() }) + + openedDirectory := filepath.Join(parent, "opened-directory") + if err := os.Rename(staging, openedDirectory); err != nil { + t.Fatalf("Rename staging: %v", err) + } + if err := os.Mkdir(staging, 0o700); err != nil { + t.Fatalf("Mkdir replacement: %v", err) + } + + if initErr := initializeGitRepository(context.Background(), staging, directory); initErr != nil { + t.Fatalf("initializeGitRepository: %v", initErr) + } + + if info, err := os.Stat(filepath.Join(openedDirectory, ".git")); err != nil || !info.IsDir() { + t.Fatalf("opened directory .git: info=%v error=%v", info, err) + } + entries, err := os.ReadDir(staging) + if err != nil { + t.Fatalf("ReadDir replacement: %v", err) + } + if len(entries) != 0 { + t.Fatalf("replacement entries = %+v, want none", entries) + } +} 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 bf24e0f0f8..862affed53 100644 --- a/apps/backend/internal/task/service/local_repository_initialization_test.go +++ b/apps/backend/internal/task/service/local_repository_initialization_test.go @@ -20,7 +20,9 @@ func TestServiceInitializeLocalRepositoryCreatesCommitlessMainRepository(t *test if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}); err != nil { t.Fatalf("CreateWorkspace: %v", err) } - realParent := t.TempDir() + // canonicalTempDir, not t.TempDir: the service stores a symlink-resolved path, + // and on macOS TMPDIR itself sits behind /var -> /private/var. + realParent := canonicalTempDir(t) parentLink := filepath.Join(t.TempDir(), "projects") if err := os.Symlink(realParent, parentLink); err != nil { t.Fatalf("Symlink parent: %v", err) @@ -71,7 +73,7 @@ func TestServiceInitializeLocalRepositoryCreatesMissingParentDirectories(t *test if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}); err != nil { t.Fatalf("CreateWorkspace: %v", err) } - parent := filepath.Join(t.TempDir(), "projects", "team") + parent := filepath.Join(canonicalTempDir(t), "projects", "team") created, err := svc.InitializeLocalRepository(ctx, &InitializeLocalRepositoryRequest{ WorkspaceID: "ws-1", @@ -171,6 +173,40 @@ func TestOpenLocalRepositoryDirectoryDoesNotFollowSymlink(t *testing.T) { } } +// TestInitializeGitRepositoryInitializesTheOpenDirectory pins the platform +// contract of initializeGitRepository directly, rather than only through the +// service happy path: whichever route it takes for this GOOS, the repository +// must land in the directory it was handed. +// +// Regression test. The inherited-descriptor route used to be taken on darwin and +// the BSDs via /dev/fd/N, where fdescfs presents a read-only synthetic entry and +// `git init` dies with "cannot mkdir /dev/fd/3: File exists" — which made this +// package's own happy-path tests, and "create new local repository" in the +// product, fail on every mac. +func TestInitializeGitRepositoryInitializesTheOpenDirectory(t *testing.T) { + staging := t.TempDir() + directory, err := openLocalRepositoryDirectory(staging) + if err != nil { + t.Fatalf("openLocalRepositoryDirectory: %v", err) + } + defer func() { _ = directory.Close() }() + + if initErr := initializeGitRepository(context.Background(), staging, directory); initErr != nil { + t.Fatalf("initializeGitRepository: %v", initErr) + } + + entries, err := os.ReadDir(staging) + if err != nil { + t.Fatalf("ReadDir staging: %v", err) + } + if len(entries) != 1 || entries[0].Name() != ".git" || !entries[0].IsDir() { + 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) + } +} + func TestServiceInitializeLocalRepositoryRejectsExistingTargetWithoutMutation(t *testing.T) { for _, existingContent := range []string{"", "keep me"} { name := "empty" diff --git a/docs/plans/fix-local-repository-darwin-init/plan.md b/docs/plans/fix-local-repository-darwin-init/plan.md new file mode 100644 index 0000000000..38c8cdcf21 --- /dev/null +++ b/docs/plans/fix-local-repository-darwin-init/plan.md @@ -0,0 +1,80 @@ +--- +spec: docs/specs/create-local-repository/spec.md +created: 2026-07-30 +status: completed +--- + +# Implementation Plan: Descriptor-Bound Local Repository Initialization + +## Overview + +`git init /dev/fd/3` cannot enter an inherited directory descriptor on macOS because fdescfs +presents the descriptor without directory search permission. PR #2052 avoids that failure by +setting `exec.Cmd.Dir` to the staging pathname, but doing so lets a pathname replacement redirect +Git before the post-initialization identity check. Replace the platform-specific descriptor path +with a small inherited-fd helper process so Linux and macOS initialize the verified directory +identity without changing the backend process's working directory. + +## Backend + +### Inherited-descriptor Git helper + +- Add `apps/backend/internal/task/gitinit/` with a narrow command boundary for local repository + initialization. +- On Linux and macOS, start the current executable with a private helper argument, an explicit + internal environment marker, and the verified directory inherited as fd 3. Package initialization + intercepts only that marked subprocess before any application or test entry point. The helper + calls `fchdir(3)` and replaces itself with `git init --initial-branch=main`, preserving context + cancellation because Git keeps the helper process identity. +- Windows retains its existing pathname/current-directory behavior. Other compiled Unix targets + keep a compatibility fallback, but remain outside the supported product scope because they do not + implement the required ownership and exclusive-publication guarantees. +- Keep the helper out of public `cmd/kandev` dispatch and CLI help. + +### Task service + +- Update `initializeGitRepository` in + `apps/backend/internal/task/service/local_repository_initialization.go` to request the command + from the helper boundary and remove the `/proc/self/fd` versus `/dev/fd` runtime path selection. +- Preserve combined-output error reporting, unborn `main`, cancellation, post-initialization + identity validation, exclusive publication, and fail-closed cleanup. +- Correct implementation comments so they describe supported Linux/macOS behavior without claiming + that the user-facing feature works on BSD. + +## Tests + +- **Descriptor identity after pathname replacement** + - **Files:** `apps/backend/internal/task/gitinit/command_descriptor_test.go`, + `apps/backend/internal/task/service/local_repository_initialization_descriptor_test.go` + - **How:** open staging directory A, rename it, install replacement B at the old pathname, invoke + the real helper subprocess, and assert `.git` is created only in A while B remains empty. +- **Helper dispatch and failure propagation** + - **Files:** `apps/backend/internal/task/gitinit/*_test.go`, + `apps/backend/internal/task/handlers/repository_handlers_test.go` + - **How:** use a helper-process test to exercise inherited fd 3, verify hidden dispatch, and assert + invalid descriptors or missing Git return a non-zero result with useful stderr. Exercise the + HTTP handler from its own test binary to prove dispatch is not tied to a package-specific + `TestMain`. +- **Existing initialization behavior** + - **File:** `apps/backend/internal/task/service/local_repository_initialization_test.go` + - **How:** retain real-Git coverage for the commitless unborn `main` repository, persistence + rollback, and pathname canonicalization. + +## Implementation Waves + +Wave 1: + +- [x] [task-01-descriptor-bound-git-init](task-01-descriptor-bound-git-init.md) - completed + +The repair is one sequential task because the helper command, process-init trampoline, service +wiring, and identity regression test form one process boundary and touch shared behavior. + +## Risks + +- The helper must never change the long-running backend's process-wide working directory. +- The helper must activate only for a subprocess carrying both the private argument and internal + environment marker, regardless of which application or test binary links the package. +- `fchdir` and descriptor inheritance are supported on Linux and macOS; Windows must keep its + existing non-`ExtraFiles` path. +- A replaced request-owned inode may be impossible to clean up after an attacker moves it, but the + replacement path must remain untouched and no repository row may be persisted. diff --git a/docs/plans/fix-local-repository-darwin-init/task-01-descriptor-bound-git-init.md b/docs/plans/fix-local-repository-darwin-init/task-01-descriptor-bound-git-init.md new file mode 100644 index 0000000000..a92f813efc --- /dev/null +++ b/docs/plans/fix-local-repository-darwin-init/task-01-descriptor-bound-git-init.md @@ -0,0 +1,110 @@ +--- +id: "01-descriptor-bound-git-init" +title: "Descriptor-bound Git initialization" +status: completed +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/create-local-repository/spec.md" +--- + +# Task 01: Descriptor-Bound Git Initialization + +## Acceptance + +- Linux and macOS initialize the exact verified directory inherited as fd 3; replacing its pathname + cannot redirect Git into the replacement. +- Successful initialization still creates only `.git`, uses unborn branch `main`, and preserves the + service's identity check, exclusive publication, rollback, and error reporting. +- Windows retains its existing pathname-compatible behavior. Other Unix/BSD compatibility builds + remain outside the supported product scope until they provide the same ownership and + exclusive-publication guarantees. + +## Verification + +```bash +make -C apps/backend test +make -C apps/backend lint +make -C apps/backend build + +# Direct Go commands are approved exceptions: the backend Makefile has no target +# for package/test filters, the race detector, or CGO-free Darwin test binaries. +cd apps/backend && go test ./internal/task/gitinit ./internal/task/service \ + ./internal/task/handlers -run 'GitInit|InitializeLocalRepository' -count=1 +cd apps/backend && go test -race ./internal/task/gitinit ./internal/task/service \ + -run 'GitInit|InitializeLocalRepository' -count=1 +cd apps/backend && tmpdir=$(mktemp -d) && \ + GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go test -c \ + -o "$tmpdir/local-repository-service.test" ./internal/task/service && \ + GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build \ + -o "$tmpdir/kandev" ./cmd/kandev +``` + +## Files Likely Touched + +- `apps/backend/internal/task/gitinit/*.go` +- `apps/backend/internal/task/gitinit/*_test.go` +- `apps/backend/internal/task/service/local_repository_initialization.go` +- `apps/backend/internal/task/service/local_repository_initialization_test.go` +- `apps/backend/internal/task/handlers/repository_handlers_test.go` +- `docs/specs/create-local-repository/spec.md` +- `docs/plans/fix-local-repository-darwin-init/plan.md` +- `docs/plans/fix-local-repository-darwin-init/task-01-descriptor-bound-git-init.md` + +## Dependencies + +None. + +## Parallelism + +Sequential. The helper and service changes share one subprocess contract and one regression test. + +## Inputs + +- Spec: exact-directory identity requirement, staging replacement failure mode, and regression + scenario. +- Go package initialization, used only by a marked helper subprocess before application/test entry. +- Existing `ExtraFiles` and identity checks in + `apps/backend/internal/task/service/local_repository_initialization.go`. +- Explicit local repository trust boundary in + `docs/decisions/2026-07-20-explicit-local-repository-trust.md`. + +## Output Contract + +Report the red test evidence, helper boundary, files changed, exact command results, residual cleanup +risk, commit and push receipt, and updated task/plan status. + +## Verification Results + +- RED: `go test ./internal/task/gitinit -run TestCommandContextRejectsMissingGit -count=1 -v` + failed before parent-side Git resolution because command construction incorrectly succeeded. +- RED (CI remediation): the handler integration test timed out because the helper recursively + launched a test binary that had no package-specific helper dispatch; reproduced locally with a + 15-second timeout. +- GREEN: focused helper/service/handler/CLI coverage passed, 35 tests in 4 packages, after moving + the marked subprocess trampoline into the private package. +- Race-focused helper/service/handler coverage passed, 30 tests in 3 packages. +- The CI-shaped full race-and-coverage run completed every actual test package successfully, + including handlers in 12.884 seconds. Its local command exit remained non-zero only because this + environment's Go installation lacks `go tool covdata` for packages with no tests. +- Darwin arm64 cross-compilation passed for service and handler test binaries and `cmd/kandev`. +- `make -C apps/backend test` passed across the full backend. +- `make -C apps/backend build` passed for the unified binary and bundled helper binaries. +- `make -C apps/backend lint` passed with 0 issues after CI remediation. +- Full affected packages passed, 776 tests in 3 packages. +- Race-focused helper and service coverage passed, 22 tests in 2 packages. +- Darwin arm64 cross-compilation passed for the service test binary and `cmd/kandev`. +- `make -C apps/backend lint` passed with 0 issues. + +## Delivery Record + +- **Helper boundary:** Linux/macOS inherit the verified directory as fd 3; a subprocess carrying the + private argument and environment marker calls `fchdir(3)` and execs Git before normal entry. +- **Changed files:** `internal/task/gitinit`, local-repository service/tests, handler integration + coverage, the feature spec, and this plan/task record. +- **Residual cleanup risk:** if another process renames the request-owned inode, its new pathname may + be unknowable; Kandev still leaves the replacement untouched and persists no repository row. +- **Initial delivery:** commit `46258170913b49ad40749ea4538134b6e4c393cf` was pushed to + `nova28:fix/local-repo-init-darwin` with active pre-commit and commit-message hooks and no bypass. +- **Status:** task and plan completed; CI follow-up replaces package-specific dispatch after the + handler integration test exposed recursive test-binary startup. diff --git a/docs/specs/create-local-repository/spec.md b/docs/specs/create-local-repository/spec.md index 2b41bbf67d..3078c666ee 100644 --- a/docs/specs/create-local-repository/spec.md +++ b/docs/specs/create-local-repository/spec.md @@ -26,6 +26,9 @@ repository on the machine running Kandev, select it, and continue composing the 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. +- 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. @@ -121,6 +124,7 @@ to the created canonical target path and the active workspace. | Name is empty, `.`/`..`, or contains a host path separator | Confirmation is disabled client-side and the backend rejects a forged request. | | 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. | | 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. | @@ -168,6 +172,10 @@ deleting the task does not delete the repository or its workspace registration. - **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. +- **GIVEN** the backend has opened its request-owned staging directory, **WHEN** that directory is + renamed and another directory is installed at the staging pathname before Git starts, **THEN** + Git operates only on the originally opened directory, the replacement remains unchanged, and the + request fails because the original identity is no longer publishable at that pathname. - **GIVEN** `/work` exists but `/work/team/projects` does not, **WHEN** the user enters `/work/team/projects` as the parent and creates `alpha`, **THEN** Kandev creates both missing parent directories and initializes `/work/team/projects/alpha`. @@ -198,7 +206,12 @@ deleting the task does not delete the repository or its workspace registration. tools in this iteration. - Automatically deleting a successfully created repository when its originating task is canceled or deleted. +- Adding local-repository initialization support to BSD platforms that do not currently implement + trusted directory ownership checks and exclusive publication. ## Implementation Plan See [Create Local Repository plan](../../plans/create-local-repository/plan.md). + +The descriptor-bound macOS repair is tracked in the +[local repository descriptor initialization plan](../../plans/fix-local-repository-darwin-init/plan.md).