diff --git a/apps/backend/internal/agent/runtime/lifecycle/env_preparer_local.go b/apps/backend/internal/agent/runtime/lifecycle/env_preparer_local.go index 67048d9866..8cc1e8ad12 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/env_preparer_local.go +++ b/apps/backend/internal/agent/runtime/lifecycle/env_preparer_local.go @@ -218,7 +218,16 @@ func validateLocalRepositoryWorkspace(ctx context.Context, workspacePath, reposi if err != nil { return err } - if workspaceCommonDir != repositoryCommonDir { + workspaceCommonInfo, err := os.Stat(workspaceCommonDir) + if err != nil { + return worktree.ErrReuseWorktreeUnavailable + } + repositoryCommonInfo, err := os.Stat(repositoryCommonDir) + if err != nil { + return worktree.ErrReuseWorktreeUnavailable + } + if !workspaceCommonInfo.IsDir() || !repositoryCommonInfo.IsDir() || + !os.SameFile(workspaceCommonInfo, repositoryCommonInfo) { return worktree.ErrReuseWorktreeUnavailable } return nil @@ -243,9 +252,9 @@ func localGitTopLevel(ctx context.Context, path string) (string, error) { } // localGitCommonDir returns the canonical Git directory shared by a checkout -// and all of its linked worktrees. Comparing this value, instead of the -// worktree top-level paths, proves that a linked worktree belongs to the -// selected repository while still rejecting an unrelated Git checkout. +// and all of its linked worktrees. Its filesystem identity, rather than its +// path spelling, proves that a linked worktree belongs to the selected +// repository while still rejecting an unrelated Git checkout. func localGitCommonDir(ctx context.Context, path string) (string, error) { info, err := os.Stat(path) if err != nil || !info.IsDir() { diff --git a/apps/backend/internal/agent/runtime/lifecycle/env_preparer_local_test.go b/apps/backend/internal/agent/runtime/lifecycle/env_preparer_local_test.go index 888780ba7a..dc82798013 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/env_preparer_local_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/env_preparer_local_test.go @@ -78,6 +78,27 @@ func TestLocalPreparer_ReuseRequiredValidatesRepositoryIdentity(t *testing.T) { } } +// @covers AC-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-002.1 +func TestValidateLocalRepositoryWorkspaceAcceptsCaseAliasedCommonDirectory(t *testing.T) { + parent := t.TempDir() + repositoryPath := filepath.Join(parent, "CaseIdentity") + if err := os.Mkdir(repositoryPath, 0o755); err != nil { + t.Fatalf("create mixed-case repository directory: %v", err) + } + initGitRepoAt(t, repositoryPath) + caseAlias := filepath.Join(parent, "caseidentity") + if _, err := os.Stat(caseAlias); err != nil { + if errors.Is(err, os.ErrNotExist) { + t.Skip("filesystem is case-sensitive") + } + t.Fatalf("inspect case-aliased repository path: %v", err) + } + + if err := validateLocalRepositoryWorkspace(context.Background(), repositoryPath, caseAlias); err != nil { + t.Fatalf("validateLocalRepositoryWorkspace() rejected the same repository through a case alias: %v", err) + } +} + func TestLocalPreparer_RejectsRepoBackedNonGitWorkspace(t *testing.T) { workspacePath := t.TempDir() preparer := NewLocalPreparer(newTestLocalLogger()) @@ -116,6 +137,11 @@ func TestLocalPreparer_AcceptsMatchingRepoBackedGitWorkspace(t *testing.T) { func initGitRepo(t *testing.T) string { t.Helper() dir := t.TempDir() + return initGitRepoAt(t, dir) +} + +func initGitRepoAt(t *testing.T, dir string) string { + t.Helper() // Start from a clean env: filter all GIT_* vars that may leak from parent // processes (e.g. pre-commit hooks set GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILE). gitEnv := filterLocalTestGitEnv(os.Environ()) diff --git a/docs/plans/case-insensitive-worktree-recovery/plan.md b/docs/plans/case-insensitive-worktree-recovery/plan.md new file mode 100644 index 0000000000..34a11e8fcf --- /dev/null +++ b/docs/plans/case-insensitive-worktree-recovery/plan.md @@ -0,0 +1,91 @@ +--- +created: 2026-09-21 +status: implemented +requirements: + - REQ-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-002 + - REQ-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-003 +system_design: + - ../../specs/tasks/system-design/additional-session-workspace-reuse.md +legacy_specs: [] +--- + +# Implementation Plan: Case-Insensitive Worktree Recovery + +## Overview + +Restore session recovery for valid worktrees whose persisted repository path +uses different letter casing from the path Git reports on a case-insensitive +filesystem. Add the failing regression first, replace lexical Git-common-dir +comparison with filesystem identity comparison, and retain fail-closed behavior +for missing or unrelated repositories. + +## Confirmed root cause + +`validateLocalRepositoryWorkspace` resolves the Git common directory for the +workspace and source repository, then compares the resulting path strings. +On case-insensitive macOS volumes, Git and persisted configuration can spell the +same directory with different casing. `filepath.EvalSymlinks` preserves that +spelling, so the valid worktree is rejected even though `os.SameFile` confirms +that both paths identify the same directory. + +## Scope + +### In scope + +- Compare existing Git common directories by filesystem identity. +- Fail closed when either common directory cannot be inspected. +- Preserve rejection of unrelated repositories. +- Add a case-alias regression that fails before the correction on a + case-insensitive filesystem. + +### Out of scope + +- Rewriting persisted repository paths or database rows. +- Recreating, moving, cleaning, or otherwise changing worktrees. +- Changing repository discovery or path-casing policy. +- UI or API contract changes. + +## Technical approach + +Update `validateLocalRepositoryWorkspace` in +`apps/backend/internal/agent/runtime/lifecycle/env_preparer_local.go` to inspect +both resolved Git common directories with `os.Stat` and compare their identities +with `os.SameFile`. A stat error or identity mismatch returns the existing +`worktree.ErrReuseWorktreeUnavailable` sentinel. + +Add the regression beside the existing identity tests in +`apps/backend/internal/agent/runtime/lifecycle/env_preparer_local_test.go`. +Create one repository under a mixed-case directory, access it through a +case-aliased path, skip only when the test filesystem is case-sensitive, and +prove the validator accepts the alias. Existing tests continue to prove an +unrelated repository is rejected. + +## Tests + +- `AC-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-002.1`: a valid retained + worktree remains recoverable when path spelling differs only by case. +- `AC-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-003.2`: repository admission + uses filesystem identity for an existing expected checkout. +- `AC-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-003.3`: missing and unrelated + common directories remain rejected. +- `AC-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-003.4`: validation remains + read-only and does not alter either checkout. + +## Work orders + +- [x] [Task 01: Compare Git directories by filesystem identity](task-01-compare-git-directory-identity.md) + +## Verification results + +- RED: the case-alias regression reproduced + `worktree.ErrReuseWorktreeUnavailable` for one physical repository. +- GREEN: the focused lifecycle identity and admission tests passed after + filesystem-identity comparison replaced lexical comparison. +- Plan/specification validation and `git diff --check` passed. + +## Risks + +- Converting a stat failure into equality would weaken fail-closed admission; + the implementation must return the existing typed error instead. +- The case-alias regression is meaningful only on case-insensitive filesystems, + so unrelated-repository coverage must remain the cross-platform safety gate. diff --git a/docs/plans/case-insensitive-worktree-recovery/task-01-compare-git-directory-identity.md b/docs/plans/case-insensitive-worktree-recovery/task-01-compare-git-directory-identity.md new file mode 100644 index 0000000000..bddbe37efc --- /dev/null +++ b/docs/plans/case-insensitive-worktree-recovery/task-01-compare-git-directory-identity.md @@ -0,0 +1,90 @@ +--- +id: "01-compare-git-directory-identity" +title: "Compare Git directories by filesystem identity" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-002 + - REQ-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-003 +acceptance_criteria: + - AC-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-002.1 + - AC-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-003.2 + - AC-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-003.3 + - AC-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-003.4 +system_design: + - ../../specs/tasks/system-design/additional-session-workspace-reuse.md +--- + +# Task 01: Compare Git Directories by Filesystem Identity + +## Summary + +Make local and worktree recovery recognize two differently cased paths as the +same repository when the filesystem reports the same underlying Git common +directory. Preserve the existing typed rejection for missing or unrelated +directories. + +## In scope + +- Add a failing case-alias regression for repository identity validation. +- Compare resolved Git common directories with `os.Stat` and `os.SameFile`. +- Keep missing-path and unrelated-repository behavior fail-closed. + +## Out of scope + +- Persisted path normalization or migration. +- Workspace recreation or cleanup. +- Frontend or API changes. + +## Acceptance + +- A valid repository accessed through case-aliased paths passes admission on a + case-insensitive filesystem. +- Missing common directories and different repositories return + `worktree.ErrReuseWorktreeUnavailable`. +- Validation performs no Git or filesystem mutation. + +## Verification + +```bash +(cd apps/backend && go test ./internal/agent/runtime/lifecycle -run 'Test(LocalPreparer_ReuseRequiredValidatesRepositoryIdentity|ValidateLaunchWorkspaceAdmission.*Repository|ValidateLocalRepositoryWorkspace.*)' -count=1) +git diff --check -- apps/backend/internal/agent/runtime/lifecycle docs/plans/case-insensitive-worktree-recovery +``` + +## Files likely touched + +- `apps/backend/internal/agent/runtime/lifecycle/env_preparer_local.go` +- `apps/backend/internal/agent/runtime/lifecycle/env_preparer_local_test.go` +- `docs/plans/case-insensitive-worktree-recovery/plan.md` +- `docs/plans/case-insensitive-worktree-recovery/task-01-compare-git-directory-identity.md` + +## Dependencies + +None. + +## Risks + +- Test filesystems that are case-sensitive must skip the case-alias scenario + without skipping the unrelated-repository safety coverage. + +## Parallelism + +`sequential` + +## Inputs + +- `docs/specs/tasks/requirements/additional-session-workspace-reuse.md` +- `docs/specs/tasks/system-design/additional-session-workspace-reuse.md` +- Existing repository identity validation and tests. + +## Results + +- RED: `TestValidateLocalRepositoryWorkspaceAcceptsCaseAliasedCommonDirectory` + failed because the valid case-aliased path returned + `worktree.ErrReuseWorktreeUnavailable`. +- GREEN: the same regression passed after Git common directories were compared + with `os.Stat` and `os.SameFile`. +- The targeted identity and admission test command passed. +- `git diff --check` passed for the implementation and plan package.