fix(backend): preserve local repository creation on macOS (#2052)

This commit is contained in:
nova28
2026-07-30 23:23:39 +08:00
committed by GitHub
parent 21742aa3ef
commit 9b6d976f3b
11 changed files with 518 additions and 22 deletions
@@ -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).