fix(backend): preserve local repository creation on macOS (#2052)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 `<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.
|
||||
- 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).
|
||||
|
||||
Reference in New Issue
Block a user