fix: preserve repository discovery results and recovery (#3766)
This commit is contained in:
@@ -763,11 +763,38 @@ func normalizeRoots(roots []string) []string {
|
||||
return normalized
|
||||
}
|
||||
|
||||
func scanRootForRepos(ctx context.Context, root string, maxDepth int) ([]LocalRepository, error) {
|
||||
type repositoryDiscoveryScanWarning struct {
|
||||
path string
|
||||
err error
|
||||
}
|
||||
|
||||
type repositoryDiscoveryScanResult struct {
|
||||
repositories []LocalRepository
|
||||
warnings []repositoryDiscoveryScanWarning
|
||||
}
|
||||
|
||||
type repositoryDiscoveryWalkDir func(string, fs.WalkDirFunc) error
|
||||
|
||||
func scanRootForRepos(
|
||||
ctx context.Context,
|
||||
root string,
|
||||
maxDepth int,
|
||||
) (repositoryDiscoveryScanResult, error) {
|
||||
return scanRootForReposWithWalk(ctx, root, maxDepth, filepath.WalkDir)
|
||||
}
|
||||
|
||||
func scanRootForReposWithWalk(
|
||||
ctx context.Context,
|
||||
root string,
|
||||
maxDepth int,
|
||||
walkDir repositoryDiscoveryWalkDir,
|
||||
) (repositoryDiscoveryScanResult, error) {
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
return nil, err
|
||||
return repositoryDiscoveryScanResult{}, err
|
||||
}
|
||||
repos := make([]LocalRepository, 0)
|
||||
warnings := make([]repositoryDiscoveryScanWarning, 0)
|
||||
warnedPaths := make(map[string]struct{})
|
||||
home, _ := os.UserHomeDir()
|
||||
walker := &repoWalker{
|
||||
root: root,
|
||||
@@ -777,8 +804,15 @@ func scanRootForRepos(ctx context.Context, root string, maxDepth int) ([]LocalRe
|
||||
homeRoot: home,
|
||||
goos: runtime.GOOS,
|
||||
ctx: ctx,
|
||||
onAccessDenied: func(path string, err error) {
|
||||
if _, ok := warnedPaths[path]; ok {
|
||||
return
|
||||
}
|
||||
warnedPaths[path] = struct{}{}
|
||||
warnings = append(warnings, repositoryDiscoveryScanWarning{path: path, err: err})
|
||||
},
|
||||
}
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
err := walkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
repo, walkErr := walker.visit(path, d, err)
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
@@ -789,36 +823,43 @@ func scanRootForRepos(ctx context.Context, root string, maxDepth int) ([]LocalRe
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return nil, err
|
||||
return repositoryDiscoveryScanResult{}, err
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return repositoryDiscoveryScanResult{}, err
|
||||
}
|
||||
return repos, nil
|
||||
return repositoryDiscoveryScanResult{repositories: repos, warnings: warnings}, nil
|
||||
}
|
||||
|
||||
// repoWalker holds state for the WalkDir callback used in scanRootForRepos.
|
||||
type repoWalker struct {
|
||||
root string
|
||||
maxDepth int
|
||||
libraryRoot string
|
||||
cacheRoot string
|
||||
homeRoot string
|
||||
goos string
|
||||
ctx context.Context
|
||||
root string
|
||||
maxDepth int
|
||||
libraryRoot string
|
||||
cacheRoot string
|
||||
homeRoot string
|
||||
goos string
|
||||
ctx context.Context
|
||||
onAccessDenied func(path string, err error)
|
||||
}
|
||||
|
||||
// visit is the WalkDir callback. Returns a non-nil *LocalRepository when a git repo is found.
|
||||
func (w *repoWalker) visit(path string, d fs.DirEntry, err error) (*LocalRepository, error) {
|
||||
if contextErr := w.ctx.Err(); contextErr != nil {
|
||||
return nil, contextErr
|
||||
}
|
||||
if err != nil {
|
||||
if path == w.root || fsdiagnostics.IsAccessDenied(err) {
|
||||
if path == w.root {
|
||||
return nil, err
|
||||
}
|
||||
if fsdiagnostics.IsAccessDenied(err) {
|
||||
if w.onAccessDenied != nil {
|
||||
w.onAccessDenied(path, err)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
return nil, nil //nolint:nilerr // skip non-permission entries that cannot be accessed
|
||||
}
|
||||
if w.ctx.Err() != nil {
|
||||
return nil, w.ctx.Err()
|
||||
}
|
||||
if path == w.root {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
)
|
||||
|
||||
func TestDiscoveryRecoveryMixedRootsReplaceOnlySuccessfulRoot(t *testing.T) {
|
||||
rootA := filepath.Join(t.TempDir(), "root-a")
|
||||
rootB := filepath.Join(t.TempDir(), "root-b")
|
||||
svc := newDiscoveryService(t, rootA)
|
||||
svc.discoveryConfig.Roots = []string{rootA, rootB}
|
||||
|
||||
oldA := LocalRepository{Path: filepath.Join(rootA, "old-a"), Name: "old-a"}
|
||||
oldB := LocalRepository{Path: filepath.Join(rootB, "old-b"), Name: "old-b"}
|
||||
newA := LocalRepository{Path: filepath.Join(rootA, "new-a"), Name: "new-a"}
|
||||
newB := LocalRepository{Path: filepath.Join(rootB, "new-b"), Name: "new-b"}
|
||||
scanNumber := 0
|
||||
svc.discoveryScanRoot = func(_ context.Context, root string, _ int) (repositoryDiscoveryScanResult, error) {
|
||||
switch scanNumber {
|
||||
case 0:
|
||||
if root == rootA {
|
||||
return repositoryDiscoveryScanResult{repositories: []LocalRepository{oldA}}, nil
|
||||
}
|
||||
return repositoryDiscoveryScanResult{repositories: []LocalRepository{oldB}}, nil
|
||||
case 1:
|
||||
if root == rootA {
|
||||
return repositoryDiscoveryScanResult{repositories: []LocalRepository{newA}}, nil
|
||||
}
|
||||
return repositoryDiscoveryScanResult{}, errors.Join(
|
||||
os.ErrNotExist,
|
||||
errors.New("clone root is missing"),
|
||||
)
|
||||
default:
|
||||
if root == rootA {
|
||||
return repositoryDiscoveryScanResult{}, nil
|
||||
}
|
||||
return repositoryDiscoveryScanResult{repositories: []LocalRepository{newB}}, nil
|
||||
}
|
||||
}
|
||||
|
||||
first, err := svc.RefreshLocalRepositoryDiscovery(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("initial discovery refresh: %v", err)
|
||||
}
|
||||
assertRepositoryPaths(t, first.Repositories, oldA.Path, oldB.Path)
|
||||
scanTime := first.ScanTime
|
||||
|
||||
scanNumber++
|
||||
second, err := svc.RefreshLocalRepositoryDiscovery(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("partial discovery refresh: %v", err)
|
||||
}
|
||||
assertRepositoryPaths(t, second.Repositories, newA.Path, oldB.Path)
|
||||
if !second.Cached {
|
||||
t.Fatal("partial failure should retain the warm cache marker")
|
||||
}
|
||||
if second.ScanTime == nil || scanTime == nil || !second.ScanTime.Equal(*scanTime) {
|
||||
t.Fatalf("scan time = %v, want previous scan time %v", second.ScanTime, scanTime)
|
||||
}
|
||||
if len(second.FailedRoots) != 1 || second.FailedRoots[0] != rootB {
|
||||
t.Fatalf("partial failed roots = %v, want [%q]", second.FailedRoots, rootB)
|
||||
}
|
||||
|
||||
scanNumber++
|
||||
third, err := svc.RefreshLocalRepositoryDiscovery(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("recovered discovery refresh: %v", err)
|
||||
}
|
||||
assertRepositoryPaths(t, third.Repositories, newB.Path)
|
||||
if len(third.FailedRoots) != 0 {
|
||||
t.Fatalf("recovered failed roots = %v, want none", third.FailedRoots)
|
||||
}
|
||||
}
|
||||
|
||||
// @covers AC-WORKSPACES-LOCAL-REPOSITORIES-003.10, AC-WORKSPACES-LOCAL-REPOSITORIES-003.11
|
||||
func TestDiscoveryRecoveryRootSetChangesRetainUnchangedRootSnapshots(t *testing.T) {
|
||||
t.Run("add root", func(t *testing.T) {
|
||||
rootA := t.TempDir()
|
||||
rootB := t.TempDir()
|
||||
svc := newDiscoveryService(t, rootA)
|
||||
svc.discoveryConfig = RepositoryDiscoveryConfig{DesktopRuntime: true, MaxDepth: 6}
|
||||
oldA := LocalRepository{Path: filepath.Join(rootA, "old-a"), Name: "old-a"}
|
||||
newB := LocalRepository{Path: filepath.Join(rootB, "new-b"), Name: "new-b"}
|
||||
failA := false
|
||||
svc.discoveryScanRoot = func(_ context.Context, root string, _ int) (repositoryDiscoveryScanResult, error) {
|
||||
if root == rootA {
|
||||
if failA {
|
||||
return repositoryDiscoveryScanResult{}, os.ErrPermission
|
||||
}
|
||||
return repositoryDiscoveryScanResult{repositories: []LocalRepository{oldA}}, nil
|
||||
}
|
||||
return repositoryDiscoveryScanResult{repositories: []LocalRepository{newB}}, nil
|
||||
}
|
||||
|
||||
if _, err := svc.AddDesktopDiscoveryRoot(context.Background(), rootA); err != nil {
|
||||
t.Fatalf("add initial root: %v", err)
|
||||
}
|
||||
failA = true
|
||||
if _, err := svc.AddDesktopDiscoveryRoot(context.Background(), rootB); err != nil {
|
||||
t.Fatalf("add second root: %v", err)
|
||||
}
|
||||
|
||||
snapshot, err := svc.GetLocalRepositoryDiscovery(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("get discovery snapshot: %v", err)
|
||||
}
|
||||
assertRepositoryPaths(t, snapshot.Repositories, oldA.Path, newB.Path)
|
||||
if len(snapshot.FailedRoots) != 1 || snapshot.FailedRoots[0] != rootA {
|
||||
t.Fatalf("failed roots = %v, want [%q]", snapshot.FailedRoots, rootA)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reconnect root", func(t *testing.T) {
|
||||
rootA := t.TempDir()
|
||||
rootB := t.TempDir()
|
||||
newA := t.TempDir()
|
||||
svc := newDiscoveryService(t, rootA)
|
||||
svc.discoveryConfig = RepositoryDiscoveryConfig{DesktopRuntime: true, MaxDepth: 6}
|
||||
oldA := LocalRepository{Path: filepath.Join(rootA, "old-a"), Name: "old-a"}
|
||||
oldB := LocalRepository{Path: filepath.Join(rootB, "old-b"), Name: "old-b"}
|
||||
newARepo := LocalRepository{Path: filepath.Join(newA, "new-a"), Name: "new-a"}
|
||||
failB := false
|
||||
svc.discoveryScanRoot = func(_ context.Context, root string, _ int) (repositoryDiscoveryScanResult, error) {
|
||||
if root == rootB {
|
||||
if failB {
|
||||
return repositoryDiscoveryScanResult{}, os.ErrPermission
|
||||
}
|
||||
return repositoryDiscoveryScanResult{repositories: []LocalRepository{oldB}}, nil
|
||||
}
|
||||
if root == newA {
|
||||
return repositoryDiscoveryScanResult{repositories: []LocalRepository{newARepo}}, nil
|
||||
}
|
||||
return repositoryDiscoveryScanResult{repositories: []LocalRepository{oldA}}, nil
|
||||
}
|
||||
|
||||
if _, err := svc.AddDesktopDiscoveryRoot(context.Background(), rootA); err != nil {
|
||||
t.Fatalf("add initial root: %v", err)
|
||||
}
|
||||
if _, err := svc.AddDesktopDiscoveryRoot(context.Background(), rootB); err != nil {
|
||||
t.Fatalf("add second root: %v", err)
|
||||
}
|
||||
failB = true
|
||||
if _, err := svc.ReconnectDesktopDiscoveryRoot(context.Background(), rootA, newA); err != nil {
|
||||
t.Fatalf("reconnect root: %v", err)
|
||||
}
|
||||
|
||||
snapshot, err := svc.GetLocalRepositoryDiscovery(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("get discovery snapshot: %v", err)
|
||||
}
|
||||
assertRepositoryPaths(t, snapshot.Repositories, oldB.Path, newARepo.Path)
|
||||
if len(snapshot.FailedRoots) != 1 || snapshot.FailedRoots[0] != rootB {
|
||||
t.Fatalf("failed roots = %v, want [%q]", snapshot.FailedRoots, rootB)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDiscoveryRecoveryAllFailedRootsRetainWarmSnapshot(t *testing.T) {
|
||||
rootA := filepath.Join(t.TempDir(), "root-a")
|
||||
rootB := filepath.Join(t.TempDir(), "root-b")
|
||||
svc := newDiscoveryService(t, rootA)
|
||||
svc.discoveryConfig.Roots = []string{rootA, rootB}
|
||||
oldA := LocalRepository{Path: filepath.Join(rootA, "old-a"), Name: "old-a"}
|
||||
oldB := LocalRepository{Path: filepath.Join(rootB, "old-b"), Name: "old-b"}
|
||||
fail := false
|
||||
svc.discoveryScanRoot = func(_ context.Context, root string, _ int) (repositoryDiscoveryScanResult, error) {
|
||||
if fail {
|
||||
return repositoryDiscoveryScanResult{}, os.ErrPermission
|
||||
}
|
||||
if root == rootA {
|
||||
return repositoryDiscoveryScanResult{repositories: []LocalRepository{oldA}}, nil
|
||||
}
|
||||
return repositoryDiscoveryScanResult{repositories: []LocalRepository{oldB}}, nil
|
||||
}
|
||||
|
||||
first, err := svc.RefreshLocalRepositoryDiscovery(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("initial discovery refresh: %v", err)
|
||||
}
|
||||
scanTime := first.ScanTime
|
||||
fail = true
|
||||
second, err := svc.RefreshLocalRepositoryDiscovery(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("all-root failure refresh: %v", err)
|
||||
}
|
||||
assertRepositoryPaths(t, second.Repositories, oldA.Path, oldB.Path)
|
||||
if !second.Cached {
|
||||
t.Fatal("all-root failure should retain the warm cache marker")
|
||||
}
|
||||
if len(second.FailedRoots) != 2 || second.FailedRoots[0] != rootA || second.FailedRoots[1] != rootB {
|
||||
t.Fatalf("failed roots = %v, want [%q %q]", second.FailedRoots, rootA, rootB)
|
||||
}
|
||||
if second.ScanTime == nil || scanTime == nil || !second.ScanTime.Equal(*scanTime) {
|
||||
t.Fatalf("scan time = %v, want previous scan time %v", second.ScanTime, scanTime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoveryRecoveryOverlappingRootsDeduplicateByPath(t *testing.T) {
|
||||
rootA := filepath.Join(t.TempDir(), "root-a")
|
||||
rootB := filepath.Join(t.TempDir(), "root-b")
|
||||
svc := newDiscoveryService(t, rootA)
|
||||
svc.discoveryConfig.Roots = []string{rootA, rootB}
|
||||
sharedPath := filepath.Join(t.TempDir(), "shared")
|
||||
svc.discoveryScanRoot = func(_ context.Context, _ string, _ int) (repositoryDiscoveryScanResult, error) {
|
||||
return repositoryDiscoveryScanResult{
|
||||
repositories: []LocalRepository{{Path: sharedPath, Name: "shared"}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
result, err := svc.RefreshLocalRepositoryDiscovery(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("overlapping discovery refresh: %v", err)
|
||||
}
|
||||
assertRepositoryPaths(t, result.Repositories, sharedPath)
|
||||
}
|
||||
|
||||
func TestDiscoveryRecoveryReturnedRepositoriesDoNotMutateCache(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
repositoryPath := filepath.Join(root, "project")
|
||||
svc := newDiscoveryService(t, root)
|
||||
svc.discoveryConfig.Roots = []string{root}
|
||||
svc.discoveryScanRoot = func(_ context.Context, _ string, _ int) (repositoryDiscoveryScanResult, error) {
|
||||
return repositoryDiscoveryScanResult{
|
||||
repositories: []LocalRepository{{Path: repositoryPath, Name: "project"}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
result, err := svc.RefreshLocalRepositoryDiscovery(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("discovery refresh: %v", err)
|
||||
}
|
||||
result.Repositories[0].Name = "mutated"
|
||||
|
||||
snapshot, err := svc.GetLocalRepositoryDiscovery(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("get discovery snapshot: %v", err)
|
||||
}
|
||||
if len(snapshot.Repositories) != 1 || snapshot.Repositories[0].Name != "project" {
|
||||
t.Fatalf("cached repositories = %+v, want original repository", snapshot.Repositories)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanRootForReposRootAccessFailureRemainsFatal(t *testing.T) {
|
||||
missingRoot := filepath.Join(t.TempDir(), "missing")
|
||||
_, err := scanRootForRepos(context.Background(), missingRoot, 6)
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("missing root error = %v, want not-exist", err)
|
||||
}
|
||||
}
|
||||
|
||||
// @covers AC-WORKSPACES-LOCAL-REPOSITORIES-003.9, AC-WORKSPACES-LOCAL-REPOSITORIES-004.2
|
||||
func TestDiscoveryRecoveryDescendantPermissionKeepsSiblingsAndLogsTarget(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
before := filepath.Join(root, "before")
|
||||
private := filepath.Join(root, "Pictures", "Photo Booth Library")
|
||||
after := filepath.Join(root, "after")
|
||||
core, logs := observer.New(zapcore.WarnLevel)
|
||||
observedLogger, err := logger.NewFromZap(zap.New(core))
|
||||
if err != nil {
|
||||
t.Fatalf("create observer logger: %v", err)
|
||||
}
|
||||
|
||||
svc := newDiscoveryService(t, root)
|
||||
svc.logger = observedLogger
|
||||
svc.discoveryScanRoot = func(ctx context.Context, scanRoot string, maxDepth int) (repositoryDiscoveryScanResult, error) {
|
||||
return scanRootForReposWithWalk(
|
||||
ctx,
|
||||
scanRoot,
|
||||
maxDepth,
|
||||
permissionDiscoveryWalk(before, private, after),
|
||||
)
|
||||
}
|
||||
|
||||
result, err := svc.refreshLocalRepositoryDiscovery(
|
||||
context.Background(), "", "", discoveryTriggerStaleRefresh,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("refresh discovery: %v", err)
|
||||
}
|
||||
assertRepositoryPaths(t, result.Repositories, after, before)
|
||||
if len(result.FailedRoots) != 0 {
|
||||
t.Fatalf("failed roots = %v, want none", result.FailedRoots)
|
||||
}
|
||||
|
||||
entries := logs.FilterMessage("filesystem.access_denied").All()
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("access-denied warnings = %d, want 1", len(entries))
|
||||
}
|
||||
fields := entries[0].ContextMap()
|
||||
if fields["target"] != private {
|
||||
t.Fatalf("warning target = %v, want %q", fields["target"], private)
|
||||
}
|
||||
if fields["operation"] != "repository.discovery.scan" {
|
||||
t.Fatalf("warning operation = %v", fields["operation"])
|
||||
}
|
||||
if fields["trigger"] != discoveryTriggerStaleRefresh {
|
||||
t.Fatalf("warning trigger = %v", fields["trigger"])
|
||||
}
|
||||
if fields["runtime"] != "server" {
|
||||
t.Fatalf("warning runtime = %v", fields["runtime"])
|
||||
}
|
||||
}
|
||||
|
||||
// @covers AC-WORKSPACES-LOCAL-REPOSITORIES-003.9
|
||||
func TestDiscoveryRecoveryDescendantPermissionWarnsOncePerPath(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
private := filepath.Join(root, "private")
|
||||
result, err := scanRootForReposWithWalk(
|
||||
context.Background(),
|
||||
root,
|
||||
6,
|
||||
func(root string, visit fs.WalkDirFunc) error {
|
||||
for _, item := range []struct {
|
||||
path string
|
||||
err error
|
||||
}{
|
||||
{path: root},
|
||||
{path: private, err: errors.Join(os.ErrPermission, errors.New("first denial"))},
|
||||
{path: private, err: errors.Join(os.ErrPermission, errors.New("second denial"))},
|
||||
} {
|
||||
if err := visit(item.path, nil, item.err); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("scan root: %v", err)
|
||||
}
|
||||
if len(result.warnings) != 1 || result.warnings[0].path != private {
|
||||
t.Fatalf("scan warnings = %+v, want one warning for %q", result.warnings, private)
|
||||
}
|
||||
}
|
||||
|
||||
// @covers AC-WORKSPACES-LOCAL-REPOSITORIES-003.7
|
||||
func TestDiscoveryRecoveryCancellationDoesNotPublishPartialSnapshot(t *testing.T) {
|
||||
rootA := filepath.Join(t.TempDir(), "root-a")
|
||||
rootB := filepath.Join(t.TempDir(), "root-b")
|
||||
svc := newDiscoveryService(t, rootA)
|
||||
svc.discoveryConfig.Roots = []string{rootA, rootB}
|
||||
partial := LocalRepository{Path: filepath.Join(rootA, "partial"), Name: "partial"}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
svc.discoveryScanRoot = func(ctx context.Context, root string, _ int) (repositoryDiscoveryScanResult, error) {
|
||||
if root == rootA {
|
||||
return repositoryDiscoveryScanResult{repositories: []LocalRepository{partial}}, nil
|
||||
}
|
||||
cancel()
|
||||
return repositoryDiscoveryScanResult{}, ctx.Err()
|
||||
}
|
||||
|
||||
if _, err := svc.RefreshLocalRepositoryDiscovery(ctx, ""); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancelled refresh error = %v, want context canceled", err)
|
||||
}
|
||||
|
||||
snapshot, err := svc.GetLocalRepositoryDiscovery(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("get cancelled snapshot: %v", err)
|
||||
}
|
||||
if snapshot.Cached || len(snapshot.Repositories) != 0 || len(snapshot.FailedRoots) != 0 {
|
||||
t.Fatalf("cancelled snapshot = %+v, want no published data", snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeDiscoveryDirEntry struct {
|
||||
name string
|
||||
dir bool
|
||||
}
|
||||
|
||||
func (entry fakeDiscoveryDirEntry) Name() string { return entry.name }
|
||||
func (entry fakeDiscoveryDirEntry) IsDir() bool { return entry.dir }
|
||||
func (entry fakeDiscoveryDirEntry) Type() fs.FileMode { return modeForDiscoveryDirEntry(entry.dir) }
|
||||
func (entry fakeDiscoveryDirEntry) Info() (fs.FileInfo, error) {
|
||||
return nil, errors.New("not available")
|
||||
}
|
||||
|
||||
func modeForDiscoveryDirEntry(isDir bool) fs.FileMode {
|
||||
if isDir {
|
||||
return fs.ModeDir
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func permissionDiscoveryWalk(before, private, after string) repositoryDiscoveryWalkDir {
|
||||
return func(root string, visit fs.WalkDirFunc) error {
|
||||
items := []struct {
|
||||
path string
|
||||
entry fs.DirEntry
|
||||
err error
|
||||
}{
|
||||
{path: root, entry: fakeDiscoveryDirEntry{name: filepath.Base(root), dir: true}},
|
||||
{path: before, entry: fakeDiscoveryDirEntry{name: filepath.Base(before), dir: true}},
|
||||
{path: filepath.Join(before, ".git"), entry: fakeDiscoveryDirEntry{name: ".git"}},
|
||||
{path: private, err: errors.Join(os.ErrPermission, errors.New("photo library denied"))},
|
||||
{path: after, entry: fakeDiscoveryDirEntry{name: filepath.Base(after), dir: true}},
|
||||
{path: filepath.Join(after, ".git"), entry: fakeDiscoveryDirEntry{name: ".git"}},
|
||||
}
|
||||
for _, item := range items {
|
||||
if err := visit(item.path, item.entry, item.err); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func assertRepositoryPaths(t *testing.T, repositories []LocalRepository, expected ...string) {
|
||||
t.Helper()
|
||||
if len(repositories) != len(expected) {
|
||||
t.Fatalf("repository paths = %#v, want %#v", repositoryPaths(repositories), expected)
|
||||
}
|
||||
for index, path := range expected {
|
||||
if repositories[index].Path != path {
|
||||
t.Fatalf("repository paths = %#v, want %#v", repositoryPaths(repositories), expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func repositoryPaths(repositories []LocalRepository) []string {
|
||||
paths := make([]string, 0, len(repositories))
|
||||
for _, repository := range repositories {
|
||||
paths = append(paths, repository.Path)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
@@ -42,11 +42,18 @@ func NormalizeRepositoryDiscoveryTrigger(trigger string) string {
|
||||
}
|
||||
|
||||
type discoveryCacheEntry struct {
|
||||
roots []string
|
||||
roots []string
|
||||
repositoriesByRoot map[string][]LocalRepository
|
||||
rootStates []models.DesktopDiscoveryRoot
|
||||
scanTime *time.Time
|
||||
failedRoots []string
|
||||
}
|
||||
|
||||
// discoveryRootCacheEntry keeps the last successful result for one exact
|
||||
// root so a root-set change does not discard healthy choices while another
|
||||
// root is being scanned.
|
||||
type discoveryRootCacheEntry struct {
|
||||
repositories []LocalRepository
|
||||
rootStates []models.DesktopDiscoveryRoot
|
||||
scanTime *time.Time
|
||||
failedRoots []string
|
||||
}
|
||||
|
||||
type discoveryFlight struct {
|
||||
@@ -125,7 +132,7 @@ func (s *Service) getLocalRepositoryDiscovery(
|
||||
}
|
||||
return RepositoryDiscoveryResult{
|
||||
Roots: roots,
|
||||
Repositories: cloneRepositories(entry.repositories),
|
||||
Repositories: repositoriesForRoots(roots, entry.repositoriesByRoot),
|
||||
DesktopRuntime: s.discoveryConfig.DesktopRuntime,
|
||||
RootStates: states,
|
||||
ScanTime: cloneTime(entry.scanTime),
|
||||
@@ -216,6 +223,9 @@ func (s *Service) refreshLocalRepositoryDiscovery(
|
||||
}
|
||||
}
|
||||
previous, hasPrevious := s.discoveryCache[key]
|
||||
if !hasPrevious {
|
||||
previous = s.discoveryEntryFromRootCacheLocked(roots)
|
||||
}
|
||||
flight := &discoveryFlight{done: make(chan struct{})}
|
||||
s.discoveryFlights[key] = flight
|
||||
s.discoveryCacheMu.Unlock()
|
||||
@@ -239,14 +249,14 @@ func (s *Service) scanDiscoveryRoots(
|
||||
trigger string,
|
||||
) (RepositoryDiscoveryResult, error) {
|
||||
trigger = NormalizeRepositoryDiscoveryTrigger(trigger)
|
||||
found := make([]LocalRepository, 0)
|
||||
repositoriesByRoot := cloneRepositoriesByRoot(previous.repositoriesByRoot)
|
||||
failed := make([]string, 0)
|
||||
for _, root := range roots {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return RepositoryDiscoveryResult{}, err
|
||||
}
|
||||
s.logFilesystemInfo("repository discovery scan started", "repository.discovery.scan", root, trigger)
|
||||
repositories, err := s.discoveryScanRoot(ctx, root, s.discoveryMaxDepth())
|
||||
scan, err := s.discoveryScanRoot(ctx, root, s.discoveryMaxDepth())
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return RepositoryDiscoveryResult{}, err
|
||||
@@ -254,9 +264,16 @@ func (s *Service) scanDiscoveryRoots(
|
||||
failed = append(failed, root)
|
||||
s.logFilesystemFailure("repository.discovery.scan", root, trigger, err)
|
||||
s.recordDesktopRootFailure(ctx, root, err)
|
||||
if _, ok := repositoriesByRoot[root]; !ok {
|
||||
repositoriesByRoot[root] = nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
found = appendUniqueRepositories(found, repositories)
|
||||
for _, warning := range scan.warnings {
|
||||
s.logFilesystemFailure("repository.discovery.scan", warning.path, trigger, warning.err)
|
||||
}
|
||||
repositories := scan.repositories
|
||||
repositoriesByRoot[root] = cloneRepositories(repositories)
|
||||
s.recordDesktopRootSuccess(ctx, root)
|
||||
}
|
||||
|
||||
@@ -265,19 +282,18 @@ func (s *Service) scanDiscoveryRoots(
|
||||
return RepositoryDiscoveryResult{}, err
|
||||
}
|
||||
if len(failed) > 0 {
|
||||
repositories := found
|
||||
repositories := repositoriesForRoots(roots, repositoriesByRoot)
|
||||
scanTime := (*time.Time)(nil)
|
||||
cached := hasPrevious
|
||||
if hasPrevious {
|
||||
repositories = cloneRepositories(previous.repositories)
|
||||
scanTime = cloneTime(previous.scanTime)
|
||||
}
|
||||
entry := discoveryCacheEntry{
|
||||
roots: append([]string(nil), roots...),
|
||||
repositories: cloneRepositories(repositories),
|
||||
rootStates: cloneRootStates(states),
|
||||
scanTime: cloneTime(scanTime),
|
||||
failedRoots: append([]string(nil), failed...),
|
||||
roots: roots,
|
||||
repositoriesByRoot: repositoriesByRoot,
|
||||
rootStates: states,
|
||||
scanTime: scanTime,
|
||||
failedRoots: failed,
|
||||
}
|
||||
s.storeDiscoveryCache(roots, entry)
|
||||
return RepositoryDiscoveryResult{
|
||||
@@ -294,15 +310,15 @@ func (s *Service) scanDiscoveryRoots(
|
||||
|
||||
now := s.discoveryClockNow()
|
||||
entry := discoveryCacheEntry{
|
||||
roots: append([]string(nil), roots...),
|
||||
repositories: cloneRepositories(found),
|
||||
rootStates: cloneRootStates(states),
|
||||
scanTime: &now,
|
||||
roots: roots,
|
||||
repositoriesByRoot: repositoriesByRoot,
|
||||
rootStates: states,
|
||||
scanTime: &now,
|
||||
}
|
||||
s.storeDiscoveryCache(roots, entry)
|
||||
return RepositoryDiscoveryResult{
|
||||
Roots: roots,
|
||||
Repositories: found,
|
||||
Repositories: repositoriesForRoots(roots, repositoriesByRoot),
|
||||
DesktopRuntime: s.discoveryConfig.DesktopRuntime,
|
||||
RootStates: states,
|
||||
ScanTime: &now,
|
||||
@@ -311,12 +327,56 @@ func (s *Service) scanDiscoveryRoots(
|
||||
}
|
||||
|
||||
func (s *Service) storeDiscoveryCache(roots []string, entry discoveryCacheEntry) {
|
||||
key := discoveryCacheKey(roots, s.discoveryMaxDepth())
|
||||
maxDepth := s.discoveryMaxDepth()
|
||||
key := discoveryCacheKey(roots, maxDepth)
|
||||
entry.roots = append([]string(nil), entry.roots...)
|
||||
entry.repositoriesByRoot = cloneRepositoriesByRoot(entry.repositoriesByRoot)
|
||||
entry.rootStates = cloneRootStates(entry.rootStates)
|
||||
entry.scanTime = cloneTime(entry.scanTime)
|
||||
entry.failedRoots = append([]string(nil), entry.failedRoots...)
|
||||
s.discoveryCacheMu.Lock()
|
||||
if s.discoveryRootCache == nil {
|
||||
s.discoveryRootCache = make(map[string]discoveryRootCacheEntry)
|
||||
}
|
||||
s.discoveryCache[key] = entry
|
||||
failedRoots := make(map[string]struct{}, len(entry.failedRoots))
|
||||
for _, root := range entry.failedRoots {
|
||||
failedRoots[root] = struct{}{}
|
||||
}
|
||||
for _, root := range entry.roots {
|
||||
if _, failed := failedRoots[root]; failed {
|
||||
continue
|
||||
}
|
||||
repositories, ok := entry.repositoriesByRoot[root]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
s.discoveryRootCache[discoveryRootCacheKey(root, maxDepth)] = discoveryRootCacheEntry{
|
||||
repositories: repositories,
|
||||
}
|
||||
}
|
||||
s.discoveryCacheMu.Unlock()
|
||||
}
|
||||
|
||||
// discoveryEntryFromRootCacheLocked reconstructs only per-root repository
|
||||
// snapshots. It intentionally does not provide an aggregate scan time or
|
||||
// warm-cache marker because the current root set has not completed together.
|
||||
func (s *Service) discoveryEntryFromRootCacheLocked(roots []string) discoveryCacheEntry {
|
||||
repositoriesByRoot := make(map[string][]LocalRepository)
|
||||
maxDepth := s.discoveryMaxDepth()
|
||||
for _, root := range roots {
|
||||
cached, ok := s.discoveryRootCache[discoveryRootCacheKey(root, maxDepth)]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
repositoriesByRoot[root] = cloneRepositories(cached.repositories)
|
||||
}
|
||||
return discoveryCacheEntry{
|
||||
roots: append([]string(nil), roots...),
|
||||
repositoriesByRoot: repositoriesByRoot,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) resolveDiscoveryRoots(ctx context.Context, requestedRoot string) ([]string, error) {
|
||||
roots, err := s.effectiveDiscoveryRoots(ctx)
|
||||
if err != nil {
|
||||
@@ -676,10 +736,33 @@ func discoveryCacheKey(roots []string, maxDepth int) string {
|
||||
return fmt.Sprintf("%d:%s", maxDepth, strings.Join(ordered, "\x00"))
|
||||
}
|
||||
|
||||
func discoveryRootCacheKey(root string, maxDepth int) string {
|
||||
return discoveryCacheKey([]string{root}, maxDepth)
|
||||
}
|
||||
|
||||
func cloneRepositories(repositories []LocalRepository) []LocalRepository {
|
||||
return append([]LocalRepository(nil), repositories...)
|
||||
}
|
||||
|
||||
func cloneRepositoriesByRoot(repositoriesByRoot map[string][]LocalRepository) map[string][]LocalRepository {
|
||||
if repositoriesByRoot == nil {
|
||||
return make(map[string][]LocalRepository)
|
||||
}
|
||||
result := make(map[string][]LocalRepository, len(repositoriesByRoot))
|
||||
for root, repositories := range repositoriesByRoot {
|
||||
result[root] = cloneRepositories(repositories)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func repositoriesForRoots(roots []string, repositoriesByRoot map[string][]LocalRepository) []LocalRepository {
|
||||
found := make([]LocalRepository, 0)
|
||||
for _, root := range roots {
|
||||
found = appendUniqueRepositories(found, repositoriesByRoot[root])
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
func cloneRootStates(states []models.DesktopDiscoveryRoot) []models.DesktopDiscoveryRoot {
|
||||
result := make([]models.DesktopDiscoveryRoot, 0, len(states))
|
||||
for i := range states {
|
||||
|
||||
@@ -94,9 +94,9 @@ func TestDesktopDiscoveryWithoutRootDoesNotScanHome(t *testing.T) {
|
||||
svc := newDiscoveryService(t, home)
|
||||
svc.discoveryConfig = RepositoryDiscoveryConfig{DesktopRuntime: true, MaxDepth: 6}
|
||||
var scanCalls int
|
||||
svc.discoveryScanRoot = func(context.Context, string, int) ([]LocalRepository, error) {
|
||||
svc.discoveryScanRoot = func(context.Context, string, int) (repositoryDiscoveryScanResult, error) {
|
||||
scanCalls++
|
||||
return nil, nil
|
||||
return repositoryDiscoveryScanResult{}, nil
|
||||
}
|
||||
|
||||
result, err := svc.GetLocalRepositoryDiscovery(context.Background(), "")
|
||||
@@ -135,8 +135,8 @@ func TestRepositoryDiscoveryScanUsesValidatedTrigger(t *testing.T) {
|
||||
}
|
||||
svc.logger = observedLogger
|
||||
svc.discoveryConfig = RepositoryDiscoveryConfig{Roots: []string{root}, MaxDepth: 6}
|
||||
svc.discoveryScanRoot = func(context.Context, string, int) ([]LocalRepository, error) {
|
||||
return nil, nil
|
||||
svc.discoveryScanRoot = func(context.Context, string, int) (repositoryDiscoveryScanResult, error) {
|
||||
return repositoryDiscoveryScanResult{}, nil
|
||||
}
|
||||
|
||||
if _, err := svc.refreshLocalRepositoryDiscovery(
|
||||
@@ -196,13 +196,15 @@ func TestDesktopDiscoveryRefreshSharesOneScan(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
var callsMu sync.Mutex
|
||||
calls := 0
|
||||
svc.discoveryScanRoot = func(context.Context, string, int) ([]LocalRepository, error) {
|
||||
svc.discoveryScanRoot = func(context.Context, string, int) (repositoryDiscoveryScanResult, error) {
|
||||
callsMu.Lock()
|
||||
calls++
|
||||
callsMu.Unlock()
|
||||
close(started)
|
||||
<-release
|
||||
return []LocalRepository{{Path: filepath.Join(root, "project"), Name: "project"}}, nil
|
||||
return repositoryDiscoveryScanResult{
|
||||
repositories: []LocalRepository{{Path: filepath.Join(root, "project"), Name: "project"}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
results := make(chan RepositoryDiscoveryResult, 2)
|
||||
@@ -250,7 +252,7 @@ func TestDesktopDiscoveryCancelledFlightDoesNotPoisonWaitingCaller(t *testing.T)
|
||||
release := make(chan struct{})
|
||||
var callsMu sync.Mutex
|
||||
calls := 0
|
||||
svc.discoveryScanRoot = func(ctx context.Context, _ string, _ int) ([]LocalRepository, error) {
|
||||
svc.discoveryScanRoot = func(ctx context.Context, _ string, _ int) (repositoryDiscoveryScanResult, error) {
|
||||
callsMu.Lock()
|
||||
calls++
|
||||
callNumber := calls
|
||||
@@ -258,9 +260,11 @@ func TestDesktopDiscoveryCancelledFlightDoesNotPoisonWaitingCaller(t *testing.T)
|
||||
if callNumber == 1 {
|
||||
close(started)
|
||||
<-release
|
||||
return nil, ctx.Err()
|
||||
return repositoryDiscoveryScanResult{}, ctx.Err()
|
||||
}
|
||||
return []LocalRepository{{Path: filepath.Join(root, "project"), Name: "project"}}, nil
|
||||
return repositoryDiscoveryScanResult{
|
||||
repositories: []LocalRepository{{Path: filepath.Join(root, "project"), Name: "project"}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
ownerCtx, cancelOwner := context.WithCancel(context.Background())
|
||||
@@ -304,7 +308,7 @@ func TestDesktopDiscoveryCancelledFlightDoesNotPoisonWaitingCaller(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoWalkerPropagatesAccessDenied(t *testing.T) {
|
||||
func TestRepoWalkerPropagatesRootAccessDeniedButSkipsChild(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
walker := &repoWalker{root: root, ctx: context.Background()}
|
||||
|
||||
@@ -312,8 +316,8 @@ func TestRepoWalkerPropagatesAccessDenied(t *testing.T) {
|
||||
t.Fatalf("root access error = %v, want permission denied", err)
|
||||
}
|
||||
child := filepath.Join(root, "private")
|
||||
if _, err := walker.visit(child, nil, os.ErrPermission); !errors.Is(err, os.ErrPermission) {
|
||||
t.Fatalf("child access error = %v, want permission denied", err)
|
||||
if _, err := walker.visit(child, nil, os.ErrPermission); err != nil {
|
||||
t.Fatalf("child access error = %v, want skipped child", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,11 +360,13 @@ func TestDesktopDiscoveryFailurePreservesCachedRepositories(t *testing.T) {
|
||||
svc := newDiscoveryService(t, root)
|
||||
svc.discoveryConfig = RepositoryDiscoveryConfig{DesktopRuntime: true, MaxDepth: 6}
|
||||
var fail bool
|
||||
svc.discoveryScanRoot = func(context.Context, string, int) ([]LocalRepository, error) {
|
||||
svc.discoveryScanRoot = func(context.Context, string, int) (repositoryDiscoveryScanResult, error) {
|
||||
if fail {
|
||||
return nil, os.ErrPermission
|
||||
return repositoryDiscoveryScanResult{}, os.ErrPermission
|
||||
}
|
||||
return []LocalRepository{{Path: repositoryPath, Name: "project"}}, nil
|
||||
return repositoryDiscoveryScanResult{
|
||||
repositories: []LocalRepository{{Path: repositoryPath, Name: "project"}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
if _, err := svc.AddDesktopDiscoveryRoot(context.Background(), root); err != nil {
|
||||
|
||||
@@ -423,9 +423,10 @@ type Service struct {
|
||||
discoveryConfig RepositoryDiscoveryConfig
|
||||
discoveryCacheMu sync.Mutex
|
||||
discoveryCache map[string]discoveryCacheEntry
|
||||
discoveryRootCache map[string]discoveryRootCacheEntry
|
||||
discoveryFlights map[string]*discoveryFlight
|
||||
discoveryNow func() time.Time
|
||||
discoveryScanRoot func(context.Context, string, int) ([]LocalRepository, error)
|
||||
discoveryScanRoot func(context.Context, string, int) (repositoryDiscoveryScanResult, error)
|
||||
filesystemWarnings *fsdiagnostics.WarningLimiter
|
||||
worktreeCleanup WorktreeCleanup
|
||||
canvasCleanup CanvasCleanup
|
||||
@@ -699,6 +700,7 @@ func NewService(repos Repos, eventBus bus.EventBus, log *logger.Logger, discover
|
||||
logger: log,
|
||||
discoveryConfig: discoveryConfig,
|
||||
discoveryCache: make(map[string]discoveryCacheEntry),
|
||||
discoveryRootCache: make(map[string]discoveryRootCacheEntry),
|
||||
discoveryFlights: make(map[string]*discoveryFlight),
|
||||
discoveryNow: time.Now,
|
||||
discoveryScanRoot: scanRootForRepos,
|
||||
|
||||
@@ -170,8 +170,11 @@ export function DiscoverRepoDialog({
|
||||
variant="outline"
|
||||
className="[@media(pointer:coarse)]:h-11"
|
||||
onClick={onRefreshDiscovery}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t("workspaces:refreshRepositories")}
|
||||
{isLoading
|
||||
? t("workspaces:scanningRepositories")
|
||||
: t("workspaces:refreshRepositories")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ const discovery = vi.hoisted(() => ({
|
||||
{ id: "", path: "/configured", display_path: "/configured", state: "connected" },
|
||||
],
|
||||
homeConfirmationRequired: true,
|
||||
failedRoots: [] as string[],
|
||||
}));
|
||||
const actions = vi.hoisted(() => ({
|
||||
refreshDiscovery: vi.fn(),
|
||||
@@ -49,6 +50,8 @@ import { RepositoryDiscoveryControls } from "./repository-discovery-controls";
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
discovery.desktopRuntime = true;
|
||||
discovery.failedRoots = [];
|
||||
});
|
||||
|
||||
describe("RepositoryDiscoveryControls", () => {
|
||||
@@ -66,4 +69,13 @@ describe("RepositoryDiscoveryControls", () => {
|
||||
|
||||
expect(screen.queryByTestId("root-controls")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render failed-root diagnostics for a non-desktop picker", () => {
|
||||
discovery.desktopRuntime = false;
|
||||
discovery.failedRoots = ["/missing-repositories"];
|
||||
|
||||
render(<RepositoryDiscoveryControls workspaceId="workspace-1" presentation="picker" />);
|
||||
|
||||
expect(screen.queryByTestId("root-controls")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,10 +15,9 @@ type RepositoryDiscoveryControlsProps = {
|
||||
};
|
||||
|
||||
/**
|
||||
* The shared consent and recovery surface for every repository selector.
|
||||
* Keeping the discovery lease and root actions together prevents a picker
|
||||
* from showing an empty result without also offering the action that can
|
||||
* establish or repair its filesystem access.
|
||||
* Renders desktop discovery-root consent and recovery controls. Browser and
|
||||
* phone selectors keep their existing repository-list actions while discovery
|
||||
* diagnostics remain outside the selector UI.
|
||||
*/
|
||||
export function RepositoryDiscoveryControls({
|
||||
workspaceId,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock("@/components/folder-picker", () => ({
|
||||
FolderPicker: () => <button type="button">Folder picker</button>,
|
||||
}));
|
||||
|
||||
import { RepositoryDiscoveryRootControls } from "./repository-discovery-root-controls";
|
||||
|
||||
const baseProps = {
|
||||
isLoading: false,
|
||||
discoveryRoots: [],
|
||||
homeConfirmationRequired: false,
|
||||
onChooseDiscoveryRoot: vi.fn(),
|
||||
onRefreshDiscovery: vi.fn(),
|
||||
onReconnectDiscoveryRoot: vi.fn(),
|
||||
onRemoveDiscoveryRoot: vi.fn(),
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("RepositoryDiscoveryRootControls", () => {
|
||||
it("keeps the folder and refresh actions available", () => {
|
||||
render(<RepositoryDiscoveryRootControls {...baseProps} presentation="picker" />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Folder picker" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "workspaces:refreshRepositories" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("leaves saved root recovery controls in place", () => {
|
||||
render(
|
||||
<RepositoryDiscoveryRootControls
|
||||
{...baseProps}
|
||||
discoveryRoots={[
|
||||
{
|
||||
id: "root-1",
|
||||
path: "/Users/example/Library/Photo Booth Library",
|
||||
display_path: "~/Library/Photo Booth Library",
|
||||
state: "reconnect_required",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "workspaces:refreshRepositories" })).toBeTruthy();
|
||||
expect(screen.getByText("workspaces:removeDiscoveryRoot")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("disables refresh while discovery is refreshing", () => {
|
||||
render(<RepositoryDiscoveryRootControls {...baseProps} isLoading />);
|
||||
|
||||
expect(
|
||||
(screen.getByRole("button", { name: "workspaces:refreshRepositories" }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,47 @@ export type RepositoryDiscoveryRootControlsProps = {
|
||||
onRemoveDiscoveryRoot: (path: string) => void;
|
||||
};
|
||||
|
||||
function SavedDiscoveryRootList({
|
||||
discoveryRoots,
|
||||
onReconnectDiscoveryRoot,
|
||||
onRemoveDiscoveryRoot,
|
||||
}: {
|
||||
discoveryRoots: DesktopDiscoveryRoot[];
|
||||
onReconnectDiscoveryRoot: (oldPath: string, newPath: string) => void;
|
||||
onRemoveDiscoveryRoot: (path: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
{discoveryRoots.map((root) => (
|
||||
<div
|
||||
key={root.id || root.path}
|
||||
className="flex min-w-0 flex-wrap items-center gap-2 rounded border border-border/50 p-2 text-xs"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-mono" title={root.path}>
|
||||
{root.display_path || root.path}
|
||||
</span>
|
||||
{root.state === "reconnect_required" && (
|
||||
<FolderPicker
|
||||
value=""
|
||||
placeholder={t("workspaces:reconnectDiscoveryRoot")}
|
||||
onChange={(newPath) => onReconnectDiscoveryRoot(root.path, newPath)}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="[@media(pointer:coarse)]:h-11"
|
||||
onClick={() => onRemoveDiscoveryRoot(root.path)}
|
||||
>
|
||||
{t("workspaces:removeDiscoveryRoot")}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function RepositoryDiscoveryRootControls({
|
||||
className,
|
||||
presentation = "card",
|
||||
@@ -79,31 +120,11 @@ export function RepositoryDiscoveryRootControls({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{discoveryRoots.map((root) => (
|
||||
<div
|
||||
key={root.id || root.path}
|
||||
className="flex min-w-0 flex-wrap items-center gap-2 rounded border border-border/50 p-2 text-xs"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-mono" title={root.path}>
|
||||
{root.display_path || root.path}
|
||||
</span>
|
||||
{root.state === "reconnect_required" && (
|
||||
<FolderPicker
|
||||
value=""
|
||||
placeholder={t("workspaces:reconnectDiscoveryRoot")}
|
||||
onChange={(newPath) => onReconnectDiscoveryRoot(root.path, newPath)}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="[@media(pointer:coarse)]:h-11"
|
||||
onClick={() => onRemoveDiscoveryRoot(root.path)}
|
||||
>
|
||||
{t("workspaces:removeDiscoveryRoot")}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<SavedDiscoveryRootList
|
||||
discoveryRoots={discoveryRoots}
|
||||
onReconnectDiscoveryRoot={onReconnectDiscoveryRoot}
|
||||
onRemoveDiscoveryRoot={onRemoveDiscoveryRoot}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
import type { PillOption } from "./task-create-dialog-pill";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { prioritizeSelectedOption, selectorOptionClassName } from "@/lib/utils/selector-options";
|
||||
import { CommandEmpty, CommandGroup, CommandItem, CommandList } from "@kandev/ui/command";
|
||||
|
||||
export function PillCommandList({
|
||||
options,
|
||||
value,
|
||||
onSelect,
|
||||
onPointerSelect,
|
||||
setOpen,
|
||||
emptyMessage,
|
||||
}: {
|
||||
options: PillOption[];
|
||||
value: string;
|
||||
onSelect: (value: string) => void;
|
||||
onPointerSelect: (pointerType: string) => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
emptyMessage: string;
|
||||
}) {
|
||||
const groups = new Map<string, { label?: string; options: PillOption[] }>();
|
||||
for (const option of options) {
|
||||
const key = option.group ?? "";
|
||||
const group = groups.get(key) ?? { label: option.groupLabel, options: [] };
|
||||
group.options.push(option);
|
||||
groups.set(key, group);
|
||||
}
|
||||
const groupOrder = new Map([
|
||||
["policies", 0],
|
||||
["branches", 1],
|
||||
]);
|
||||
const orderedGroups = Array.from(groups.entries()).sort(
|
||||
([firstKey], [secondKey]) =>
|
||||
(groupOrder.get(firstKey) ?? Number.MAX_SAFE_INTEGER) -
|
||||
(groupOrder.get(secondKey) ?? Number.MAX_SAFE_INTEGER),
|
||||
);
|
||||
|
||||
return (
|
||||
<CommandList>
|
||||
<CommandEmpty>{emptyMessage}</CommandEmpty>
|
||||
{orderedGroups.map(([key, group]) => (
|
||||
<CommandGroup key={key || "ungrouped"} heading={group.label}>
|
||||
{prioritizeSelectedOption(group.options, value, (option) => option.value).map(
|
||||
(option) => {
|
||||
const selected = option.value === value;
|
||||
const item = (
|
||||
<CommandItem
|
||||
key={option.renderAccessory ? undefined : option.value}
|
||||
value={option.value}
|
||||
keywords={[option.label, ...(option.keywords ?? [])]}
|
||||
disabled={option.disabled}
|
||||
onPointerDown={(event) => onPointerSelect(event.pointerType)}
|
||||
onSelect={() => {
|
||||
onSelect(option.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
selectorOptionClassName(selected),
|
||||
option.renderAccessory && "pr-14",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
{option.renderLabel ? option.renderLabel() : option.label}
|
||||
</div>
|
||||
<IconCheck
|
||||
className={cn(
|
||||
"absolute right-2 h-4 w-4",
|
||||
selected ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
);
|
||||
if (!option.renderAccessory) return item;
|
||||
return (
|
||||
<div key={option.value} className="relative">
|
||||
{item}
|
||||
<div className="absolute inset-y-0 right-7 z-10 flex items-center">
|
||||
{option.renderAccessory()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { prioritizeSelectedOption, selectorOptionClassName } from "@/lib/utils/selector-options";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@kandev/ui/command";
|
||||
import { Command, CommandInput } from "@kandev/ui/command";
|
||||
import { BranchRefreshButton } from "@/components/branch-refresh-button";
|
||||
import { PillCommandList } from "@/components/task-create-dialog-pill-command-list";
|
||||
import { controlSizingClassName } from "@kandev/ui/control-sizing";
|
||||
import { useTaskCreateDialogPopoverContainer } from "@/hooks/use-task-create-dialog-popover-container";
|
||||
import { usePillTooltipSuppression } from "@/hooks/use-pill-tooltip-suppression";
|
||||
@@ -93,90 +85,6 @@ function pillActiveClass(flat: boolean): string {
|
||||
return "hover:bg-muted hover:border-border cursor-pointer";
|
||||
}
|
||||
|
||||
function PillCommandList({
|
||||
options,
|
||||
value,
|
||||
onSelect,
|
||||
onPointerSelect,
|
||||
setOpen,
|
||||
emptyMessage,
|
||||
}: {
|
||||
options: PillOption[];
|
||||
value: string;
|
||||
onSelect: (value: string) => void;
|
||||
onPointerSelect: (pointerType: string) => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
emptyMessage: string;
|
||||
}) {
|
||||
const groups = new Map<string, { label?: string; options: PillOption[] }>();
|
||||
for (const option of options) {
|
||||
const key = option.group ?? "";
|
||||
const group = groups.get(key) ?? { label: option.groupLabel, options: [] };
|
||||
group.options.push(option);
|
||||
groups.set(key, group);
|
||||
}
|
||||
const groupOrder = new Map([
|
||||
["policies", 0],
|
||||
["branches", 1],
|
||||
]);
|
||||
const orderedGroups = Array.from(groups.entries()).sort(
|
||||
([firstKey], [secondKey]) =>
|
||||
(groupOrder.get(firstKey) ?? Number.MAX_SAFE_INTEGER) -
|
||||
(groupOrder.get(secondKey) ?? Number.MAX_SAFE_INTEGER),
|
||||
);
|
||||
|
||||
return (
|
||||
<CommandList>
|
||||
<CommandEmpty>{emptyMessage}</CommandEmpty>
|
||||
{orderedGroups.map(([key, group]) => (
|
||||
<CommandGroup key={key || "ungrouped"} heading={group.label}>
|
||||
{prioritizeSelectedOption(group.options, value, (option) => option.value).map(
|
||||
(option) => {
|
||||
const selected = option.value === value;
|
||||
const item = (
|
||||
<CommandItem
|
||||
key={option.renderAccessory ? undefined : option.value}
|
||||
value={option.value}
|
||||
keywords={[option.label, ...(option.keywords ?? [])]}
|
||||
disabled={option.disabled}
|
||||
onPointerDown={(event) => onPointerSelect(event.pointerType)}
|
||||
onSelect={() => {
|
||||
onSelect(option.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
selectorOptionClassName(selected),
|
||||
option.renderAccessory && "pr-14",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
{option.renderLabel ? option.renderLabel() : option.label}
|
||||
</div>
|
||||
<IconCheck
|
||||
className={cn(
|
||||
"absolute right-2 h-4 w-4",
|
||||
selected ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
);
|
||||
if (!option.renderAccessory) return item;
|
||||
return (
|
||||
<div key={option.value} className="relative">
|
||||
{item}
|
||||
<div className="absolute inset-y-0 right-7 z-10 flex items-center">
|
||||
{option.renderAccessory()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the className for the pill trigger button. Extracted so the inline
|
||||
* trigger JSX stays compact (the Pill function is right at the complexity cap).
|
||||
|
||||
@@ -2,6 +2,10 @@ import { expect, test } from "../../fixtures/test-base";
|
||||
import { useRegularMode } from "../../helpers/regular-mode";
|
||||
import { waitForFiniteAnimations } from "../../helpers/animations";
|
||||
import { MobileKanbanPage } from "../../pages/mobile-kanban-page";
|
||||
import {
|
||||
DISCOVERY_FAILURE_ROOT,
|
||||
installRepositoryDiscoveryFailureRoute,
|
||||
} from "./repository-discovery-failure-helpers";
|
||||
import fs from "node:fs";
|
||||
|
||||
useRegularMode();
|
||||
@@ -47,6 +51,57 @@ test.describe("Mobile repository discovery consent", () => {
|
||||
expect(refreshCssHeight).toBe(44);
|
||||
});
|
||||
|
||||
test("keeps failed-root diagnostics out of the phone selector", async ({ testPage, backend }) => {
|
||||
test.setTimeout(120_000);
|
||||
await installRepositoryDiscoveryFailureRoute(testPage);
|
||||
await backend.restart({ KANDEV_DESKTOP_RUNTIME: "false" });
|
||||
|
||||
const mobile = new MobileKanbanPage(testPage);
|
||||
await mobile.goto();
|
||||
await mobile.mobileFab.tap();
|
||||
|
||||
const dialog = testPage.getByTestId("create-task-dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByTestId("repo-chip-trigger").first().tap();
|
||||
|
||||
await expect(testPage.getByTestId("discovery-root-controls")).toHaveCount(0);
|
||||
await expect(testPage.getByTestId("discovery-failure")).toHaveCount(0);
|
||||
await expect(testPage.getByText(DISCOVERY_FAILURE_ROOT, { exact: true })).toHaveCount(0);
|
||||
const viewport = testPage.viewportSize();
|
||||
expect(viewport).not.toBeNull();
|
||||
const refresh = testPage.getByTestId("repo-refresh-button");
|
||||
const refreshBox = await refresh.boundingBox();
|
||||
expect(refreshBox).not.toBeNull();
|
||||
expect(refreshBox!.y).toBeGreaterThanOrEqual(0);
|
||||
expect(refreshBox!.y + refreshBox!.height).toBeLessThanOrEqual(viewport!.height);
|
||||
const availableRepository = testPage.getByRole("option", { name: /healthy-project/ });
|
||||
await expect(availableRepository).toBeEnabled();
|
||||
const availableRepositoryBox = await availableRepository.boundingBox();
|
||||
expect(availableRepositoryBox).not.toBeNull();
|
||||
expect(availableRepositoryBox!.y).toBeGreaterThanOrEqual(0);
|
||||
expect(availableRepositoryBox!.y + availableRepositoryBox!.height).toBeLessThanOrEqual(
|
||||
viewport!.height,
|
||||
);
|
||||
await availableRepository.tap();
|
||||
await expect(dialog.getByTestId("repo-chip-trigger").first()).toContainText("healthy-project");
|
||||
|
||||
await dialog.getByTestId("repo-chip-trigger").first().tap();
|
||||
const refreshResponse = testPage.waitForResponse(
|
||||
(response) =>
|
||||
/\/api\/v1\/workspaces\/[^/]+\/repositories$/.test(new URL(response.url()).pathname) &&
|
||||
response.request().method() === "GET" &&
|
||||
response.ok(),
|
||||
);
|
||||
expect(refreshBox!.height).toBeGreaterThanOrEqual(44);
|
||||
await refresh.tap();
|
||||
await refreshResponse;
|
||||
await expect(testPage.getByTestId("discovery-failure")).toHaveCount(0);
|
||||
await expect(testPage.getByRole("option", { name: /healthy-project/ })).toBeVisible();
|
||||
expect(
|
||||
await testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("uses the HTTP picker on a mobile browser connected to a desktop backend", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
|
||||
@@ -2,6 +2,10 @@ import { expect, test } from "../../fixtures/test-base";
|
||||
import { useRegularMode } from "../../helpers/regular-mode";
|
||||
import { waitForFiniteAnimations } from "../../helpers/animations";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import {
|
||||
DISCOVERY_FAILURE_ROOT,
|
||||
installRepositoryDiscoveryFailureRoute,
|
||||
} from "./repository-discovery-failure-helpers";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -41,6 +45,43 @@ test.describe("Desktop repository discovery consent", () => {
|
||||
expect(refreshBox!.height).toBeLessThanOrEqual(32);
|
||||
});
|
||||
|
||||
test("keeps failed-root diagnostics out of the server selector", async ({
|
||||
testPage,
|
||||
backend,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await installRepositoryDiscoveryFailureRoute(testPage);
|
||||
await backend.restart({ KANDEV_DESKTOP_RUNTIME: "false" });
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await kanban.createTaskButton.first().click();
|
||||
|
||||
const dialog = testPage.getByTestId("create-task-dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByTestId("repo-chip-trigger").first().click();
|
||||
|
||||
await expect(testPage.getByTestId("discovery-root-controls")).toHaveCount(0);
|
||||
await expect(testPage.getByTestId("discovery-failure")).toHaveCount(0);
|
||||
await expect(testPage.getByText(DISCOVERY_FAILURE_ROOT, { exact: true })).toHaveCount(0);
|
||||
const availableRepository = testPage.getByRole("option", { name: /healthy-project/ });
|
||||
await expect(availableRepository).toBeEnabled();
|
||||
await availableRepository.click();
|
||||
await expect(dialog.getByTestId("repo-chip-trigger").first()).toContainText("healthy-project");
|
||||
|
||||
await dialog.getByTestId("repo-chip-trigger").first().click();
|
||||
const refreshResponse = testPage.waitForResponse(
|
||||
(response) =>
|
||||
/\/api\/v1\/workspaces\/[^/]+\/repositories$/.test(new URL(response.url()).pathname) &&
|
||||
response.request().method() === "GET" &&
|
||||
response.ok(),
|
||||
);
|
||||
await testPage.getByTestId("repo-refresh-button").click();
|
||||
await refreshResponse;
|
||||
await expect(testPage.getByTestId("discovery-failure")).toHaveCount(0);
|
||||
await expect(testPage.getByRole("option", { name: /healthy-project/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test("uses the native picker and keeps the discovery root recoverable", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
export const DISCOVERY_FAILURE_ROOT = "/srv/repositories/inaccessible-root";
|
||||
export const DISCOVERED_REPOSITORY_PATH = "/srv/repositories/healthy-project";
|
||||
|
||||
type DiscoveryResponse = {
|
||||
roots: string[];
|
||||
repositories: Array<{ path: string; name: string; default_branch: string }>;
|
||||
total: number;
|
||||
desktop_runtime: boolean;
|
||||
root_states: [];
|
||||
scan_time: string;
|
||||
refreshing: boolean;
|
||||
cached: boolean;
|
||||
home_confirmation_required: boolean;
|
||||
failed_roots: string[];
|
||||
};
|
||||
|
||||
function discoveryResponse(): DiscoveryResponse {
|
||||
const repositories = [
|
||||
{
|
||||
path: DISCOVERED_REPOSITORY_PATH,
|
||||
name: "healthy-project",
|
||||
default_branch: "main",
|
||||
},
|
||||
];
|
||||
return {
|
||||
roots: ["/srv/repositories", DISCOVERY_FAILURE_ROOT],
|
||||
repositories,
|
||||
total: repositories.length,
|
||||
desktop_runtime: false,
|
||||
root_states: [],
|
||||
scan_time: "2026-09-17T10:00:00.000Z",
|
||||
refreshing: false,
|
||||
cached: true,
|
||||
home_confirmation_required: false,
|
||||
failed_roots: [DISCOVERY_FAILURE_ROOT],
|
||||
};
|
||||
}
|
||||
|
||||
export async function installRepositoryDiscoveryFailureRoute(page: Page): Promise<void> {
|
||||
await page.route("**/api/v1/workspaces/*/repositories/discovery**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(discoveryResponse()),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -41,6 +41,15 @@ function response(scanTime = oldScan): RepositoryDiscoveryResponse {
|
||||
};
|
||||
}
|
||||
|
||||
function failedResponse(
|
||||
scanTime = new Date(currentTime).toISOString(),
|
||||
): RepositoryDiscoveryResponse {
|
||||
return {
|
||||
...response(scanTime),
|
||||
failed_roots: ["/missing"],
|
||||
};
|
||||
}
|
||||
|
||||
function client(overrides: Partial<RepositoryDiscoveryClient> = {}): RepositoryDiscoveryClient {
|
||||
return {
|
||||
getSnapshot: vi.fn(async () => response()),
|
||||
@@ -139,6 +148,41 @@ describe("RepositoryDiscoveryCoordinator", () => {
|
||||
coordinator.dispose();
|
||||
});
|
||||
|
||||
it("does not auto-refresh a snapshot with failed roots", async () => {
|
||||
const api = client({
|
||||
getSnapshot: vi.fn(async () => failedResponse(oldScan)),
|
||||
});
|
||||
const coordinator = new RepositoryDiscoveryCoordinator(api, {
|
||||
now: () => currentTime,
|
||||
});
|
||||
|
||||
const release = coordinator.acquire(workspaceId);
|
||||
await vi.waitFor(() => expect(api.getSnapshot).toHaveBeenCalledTimes(1));
|
||||
expect(api.refresh).not.toHaveBeenCalled();
|
||||
expect(coordinator.getSnapshot(workspaceId).response?.failed_roots).toEqual(["/missing"]);
|
||||
release();
|
||||
coordinator.dispose();
|
||||
});
|
||||
|
||||
it("allows an explicit refresh to recover a failed snapshot", async () => {
|
||||
const api = client({
|
||||
getSnapshot: vi.fn(async () => failedResponse()),
|
||||
refresh: vi.fn(async () => response(new Date(currentTime).toISOString())),
|
||||
});
|
||||
const coordinator = new RepositoryDiscoveryCoordinator(api, {
|
||||
now: () => currentTime,
|
||||
});
|
||||
|
||||
const release = coordinator.acquire(workspaceId);
|
||||
await vi.waitFor(() => expect(api.getSnapshot).toHaveBeenCalledTimes(1));
|
||||
await coordinator.refresh(workspaceId);
|
||||
|
||||
expect(api.refresh).toHaveBeenCalledWith(workspaceId, "manual_refresh");
|
||||
expect(coordinator.getSnapshot(workspaceId).response?.failed_roots).toEqual([]);
|
||||
release();
|
||||
coordinator.dispose();
|
||||
});
|
||||
|
||||
it("joins a refresh reported by a fresh cached snapshot", async () => {
|
||||
const api = client({
|
||||
getSnapshot: vi.fn(async () => ({
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
---
|
||||
created: 2026-09-17
|
||||
status: done
|
||||
requirements:
|
||||
- REQ-WORKSPACES-LOCAL-REPOSITORIES-003
|
||||
- REQ-WORKSPACES-LOCAL-REPOSITORIES-004
|
||||
system_design:
|
||||
- ../../specs/workspaces/system-design/local-repositories.md
|
||||
legacy_specs: []
|
||||
---
|
||||
|
||||
# Implementation Plan: Repository Discovery Failure Recovery
|
||||
|
||||
## Overview
|
||||
|
||||
Keep accessible repositories visible when filesystem discovery encounters denied
|
||||
descendants or failed roots. First correct traversal and cache recovery. Then
|
||||
keep failed-root diagnostics in structured logs while preserving existing
|
||||
repository choices and Refresh actions.
|
||||
|
||||
## Evidence and assumption check
|
||||
|
||||
The v0.94.0 report shows an EPERM below Home at `Pictures/Photo Booth Library`.
|
||||
The clone directory is also absent. `repoWalker.visit` propagates descendant
|
||||
permission errors, and `scanRootForRepos` discards accumulated repositories.
|
||||
`scanDiscoveryRoots` then replaces fresh successes with the entire previous
|
||||
snapshot whenever any root fails. Server pickers hide `failed_roots`.
|
||||
|
||||
A temporary test established a readable repository, injected EPERM through the
|
||||
real walker callback, and observed an empty service result without an API error.
|
||||
The reproduction and two existing discovery tests passed. The temporary test
|
||||
was removed. This Linux run did not reproduce native macOS privacy enforcement.
|
||||
Commit `d796cf131d` introduced the fatal descendant-error branch.
|
||||
|
||||
The user requested a fix after this diagnosis. Workspace discovery owns the
|
||||
contract. Existing AC-003.7 requires preservation and recovery.
|
||||
New AC-003.9 through 003.12 clarify descendant failures and mixed root outcomes.
|
||||
No unresolved product choice blocks this package.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Preserve accessible siblings after descendant EACCES or EPERM.
|
||||
- Retain root failures, cancellation, and structured diagnostics.
|
||||
- Merge fresh and cached results by exact scan root.
|
||||
- Keep available choices and the normal Refresh action in server and phone
|
||||
repository selectors. Failed-root diagnostics are logs-only.
|
||||
- Preserve saved desktop root recovery controls.
|
||||
- Update public recovery guidance during implementation.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Broaden Home exclusions, filesystem permissions, or repository grants.
|
||||
- Create absent clone directories or suppress their failure reports.
|
||||
- Change public API fields, root persistence, or background refresh policy.
|
||||
- Change native folder selection, provider discovery, or workspace polling.
|
||||
|
||||
## Technical approach
|
||||
|
||||
`repository_discovery.go` skips inaccessible descendants while retaining root
|
||||
errors. The scan forwards trigger and runtime context to existing diagnostics.
|
||||
`repository_discovery_state.go` retains internal result slices by exact root
|
||||
and keeps independent per-root snapshots across aggregate cache invalidation.
|
||||
It deduplicates only the final response. Successful empty scans clear stale
|
||||
entries. Cancellation leaves the prior snapshot unchanged.
|
||||
|
||||
`RepositoryDiscoveryControls` keeps the existing desktop root-management
|
||||
surface. Server and phone selectors do not render `failedRoots`; they keep
|
||||
available choices and their normal Refresh action. The backend retains failed
|
||||
root details in structured logs and the coordinator retains its retry policy.
|
||||
|
||||
## ASCII UI preview
|
||||
|
||||
UI-01: Create Task repository selector during a failed scan.
|
||||
Failed-root diagnostics remain in backend logs. The selector keeps its normal
|
||||
choices and Refresh action without adding a warning or path list.
|
||||
|
||||
```text
|
||||
Desktop picker Phone picker (same surface)
|
||||
+-------------------------------+ +-------------------------------+
|
||||
| Search repositories [Refresh] | | Search repositories [Refresh] |
|
||||
| project-a | | project-a |
|
||||
| project-b | | project-b |
|
||||
+-------------------------------+ +-------------------------------+
|
||||
```
|
||||
|
||||
UI-01 structure is required, but copy and spacing are illustrative. Existing
|
||||
picker search, selection, dismissal, focus return, and Refresh behavior remain
|
||||
intact. No failed-root warning or path list is rendered. Failed-root details
|
||||
remain available in structured backend logs. The existing action is disabled
|
||||
during refresh and shows progress where that surface already supports it.
|
||||
|
||||
The phone entry point remains the repository selector. Reuse its current
|
||||
surface, with one scroll owner and existing safe-area clearance. The Add
|
||||
Workspace Sources drawer is the inline-error exemplar. No new overlay is needed.
|
||||
Use 28-pixel desktop controls and at least 44-pixel touch targets. AC-003.11
|
||||
maps to desktop and mobile discovery E2E tests.
|
||||
|
||||
## Tests
|
||||
|
||||
All AC references below use the prefix `AC-WORKSPACES-LOCAL-REPOSITORIES`.
|
||||
|
||||
| Criteria | Evidence |
|
||||
| --- | --- |
|
||||
| 003.9, 004.2 | New `repository_discovery_recovery_test.go`: descendant EACCES/EPERM, readable siblings before and after, root denial, and exact structured diagnostic target |
|
||||
| 003.7, 003.10, 003.12 | Same file: cold cache, warm cache, missing clone root across two refreshes, successful empty root, overlapping roots, all roots fail, recovery |
|
||||
| 003.6 | Existing discovery concurrency tests plus race-enabled recovery tests |
|
||||
| 003.11, 003.8 | `repository-discovery-controls.test.tsx` and discovery E2E: failed-root paths are absent, available choices remain selectable, normal Refresh remains usable, and desktop root controls remain available |
|
||||
| 003.4, 003.5 | `use-repository-discovery.test.ts`: failed snapshots do not auto-retry, explicit refresh still runs |
|
||||
|
||||
## E2E tests
|
||||
|
||||
- Extend `tests/task/repository-discovery-consent.spec.ts` in `chromium` with
|
||||
server partial failure, hidden failed-root diagnostics, usable choices, and
|
||||
normal Refresh.
|
||||
- Extend `tests/task/mobile-repository-discovery.spec.ts` in `mobile-chrome`
|
||||
with the same outcome and touch-target coverage for the existing Refresh
|
||||
action.
|
||||
- Use controlled discovery responses for deterministic UI failures. Backend
|
||||
tests prove filesystem behavior without relying on root-bypassed chmod.
|
||||
|
||||
## Work orders
|
||||
|
||||
- [x] [Task 01: Preserve repositories across scan failures](task-01-scan-recovery.md) (done)
|
||||
- [x] [Task 02: Preserve selector recovery without visible failure paths](task-02-picker-recovery.md) (done)
|
||||
|
||||
Task 02 followed Task 01. Work was completed sequentially in the primary session.
|
||||
The [original consent package](../desktop-repository-discovery-consent/plan.md)
|
||||
retains its historical results. This package owns the additional regressions.
|
||||
|
||||
## Verification results
|
||||
|
||||
Implementation completed on 2026-09-17. Backend recovery, selector behavior,
|
||||
E2E coverage, and public recovery guidance are in place. Failed-root paths
|
||||
remain logs-only; no warning translations or selector path list are shipped.
|
||||
|
||||
Passed checks:
|
||||
|
||||
- `go test -tags fts5 ./internal/task/service -count=1`
|
||||
- `go test -tags fts5 -race ./internal/task/service -run 'Discovery|DiscoverLocal|RepoWalker|ScanRoot|MacOSHome' -count=1`
|
||||
- `go test -tags fts5 ./internal/common/fsdiagnostics -count=1`
|
||||
- Focused frontend Vitest run: 82 tests passed across 6 files
|
||||
- `pnpm run typecheck`, `pnpm run i18n:check`, and targeted ESLint
|
||||
- Chromium and mobile-chrome discovery E2E suites: 3 tests passed in each
|
||||
- `make build-web` and `make build-backend`
|
||||
- Public-doc tests and validator, specification validation and lint, and
|
||||
`git diff --check`
|
||||
|
||||
Native macOS privacy enforcement was not available on this Linux host. The
|
||||
regression is covered through deterministic walk-callback injection and the
|
||||
browser flows use controlled discovery responses.
|
||||
|
||||
## Risks
|
||||
|
||||
- Overlapping roots require exact scan provenance to avoid stale duplicates.
|
||||
- A successful root with a denied descendant cannot prove that missing cached
|
||||
repositories were deleted. Its fresh accessible results replace its cache.
|
||||
- A missing clone directory remains a structured root failure until it exists.
|
||||
- Native macOS privacy dialogs require a Mac for final platform confirmation.
|
||||
- Backend log access must be treated as sensitive because it can contain local
|
||||
paths.
|
||||
@@ -0,0 +1,116 @@
|
||||
---
|
||||
id: "01-scan-recovery"
|
||||
title: "Preserve repositories across scan failures"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-WORKSPACES-LOCAL-REPOSITORIES-003
|
||||
- REQ-WORKSPACES-LOCAL-REPOSITORIES-004
|
||||
acceptance_criteria:
|
||||
- AC-WORKSPACES-LOCAL-REPOSITORIES-003.6
|
||||
- AC-WORKSPACES-LOCAL-REPOSITORIES-003.7
|
||||
- AC-WORKSPACES-LOCAL-REPOSITORIES-003.9
|
||||
- AC-WORKSPACES-LOCAL-REPOSITORIES-003.10
|
||||
- AC-WORKSPACES-LOCAL-REPOSITORIES-003.12
|
||||
- AC-WORKSPACES-LOCAL-REPOSITORIES-004.2
|
||||
system_design:
|
||||
- ../../specs/workspaces/system-design/local-repositories.md
|
||||
---
|
||||
|
||||
# Task 01: Preserve repositories across scan failures
|
||||
|
||||
## Summary
|
||||
|
||||
Continue discovery after denied descendants and preserve usable results from
|
||||
successful roots. Retain root failure diagnostics and explicit recovery policy.
|
||||
|
||||
## In scope
|
||||
|
||||
- Write `TestDiscoveryRecoveryDescendantPermission` before the correction.
|
||||
Assert readable siblings before and after EACCES and EPERM remain visible.
|
||||
- Keep root stat/read failures fatal and context cancellation distinct.
|
||||
- Forward scan trigger and runtime into warnings for exact denied descendants.
|
||||
- Store copied result slices per normalized root inside the existing cache.
|
||||
- Write `TestDiscoveryRecoveryMixedRoots`, including two refreshes while the
|
||||
clone root remains absent. Assert fresh successful results replace old ones.
|
||||
- Cover all-root failure, recovery, empty successes, overlap, and cancellation.
|
||||
- Update `TestRepoWalkerPropagatesAccessDenied` to distinguish root and child.
|
||||
|
||||
## Out of scope
|
||||
|
||||
UI, API schema changes, root persistence changes, directory creation, and new
|
||||
Home exclusions are outside this work order.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Descendant errors preserve accessible results and diagnostic context. Root
|
||||
errors remain reported. Cancellation does not publish an incomplete snapshot.
|
||||
- Every successful root supplies fresh results, even when another root fails.
|
||||
Only failed roots retain prior results. Overlap produces no duplicate paths.
|
||||
- Existing single-flight, consent, cache-copy, and root-recovery tests pass.
|
||||
|
||||
## Verification
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
(cd apps/backend && go test -tags fts5 ./internal/task/service -run 'TestDiscoveryRecovery' -count=1 -v)
|
||||
(cd apps/backend && go test -tags fts5 -race ./internal/task/service -run 'Discovery|DiscoverLocal|RepoWalker|ScanRoot|MacOSHome' -count=1)
|
||||
(cd apps/backend && go test -tags fts5 ./internal/common/fsdiagnostics -count=1)
|
||||
git diff --check
|
||||
```
|
||||
|
||||
First record expected failures from the new regression tests. Use deterministic
|
||||
walk-callback error injection when filesystem permissions cannot be enforced.
|
||||
Drive the real scan and service path, not a duplicate traversal implementation.
|
||||
If a real chmod fixture is used, probe enforcement and skip only that fixture
|
||||
when the executor bypasses permissions. Deterministic coverage must still run.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/task/service/repository_discovery.go`
|
||||
- `apps/backend/internal/task/service/repository_discovery_state.go`
|
||||
- `apps/backend/internal/task/service/repository_discovery_test.go`
|
||||
- New `apps/backend/internal/task/service/repository_discovery_recovery_test.go`
|
||||
- `apps/backend/internal/task/service/service.go` if the scan function type changes
|
||||
- `apps/backend/internal/task/service/filesystem_diagnostics.go`
|
||||
- `apps/backend/internal/common/fsdiagnostics/context.go` as the existing diagnostic contract
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Risks
|
||||
|
||||
Do not reconstruct provenance from repository path containment. Do not mutate
|
||||
shared cached maps or slices during an in-flight scan. Keep aggregate scan time
|
||||
unchanged until all roots succeed.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- [Requirements](../../specs/workspaces/requirements/local-repositories.md), REQ-003 and REQ-004.
|
||||
- [Design](../../specs/workspaces/system-design/local-repositories.md), Partial scan recovery and Diagnostics.
|
||||
- Existing `TestDesktopDiscoveryFailurePreservesCachedRepositories` and discovery concurrency tests.
|
||||
|
||||
## Results
|
||||
|
||||
Implemented descendant-permission recovery and per-root cache replacement.
|
||||
Inaccessible descendants now produce bounded structured warnings while root
|
||||
failures remain visible. Successful roots replace their own cached results,
|
||||
failed roots retain prior results, and independent exact-root snapshots survive
|
||||
aggregate invalidation during Add and Reconnect. Successful empty scans clear
|
||||
stale entries, and cancellation does not publish a partial snapshot.
|
||||
|
||||
Verification passed:
|
||||
|
||||
- `go test -tags fts5 ./internal/task/service -run 'TestDiscoveryRecovery|TestScanRootForReposRootAccessFailureRemainsFatal|TestRepoWalkerPropagatesRootAccessDeniedButSkipsChild' -count=1 -v`
|
||||
- `go test -tags fts5 ./internal/task/service -run 'TestDiscoveryRecoveryRootSetChangesRetainUnchangedRootSnapshots' -count=1`
|
||||
- `go test -tags fts5 -race ./internal/task/service -run 'Discovery|DiscoverLocal|RepoWalker|ScanRoot|MacOSHome' -count=1`
|
||||
- `go test -tags fts5 ./internal/common/fsdiagnostics -count=1`
|
||||
- `git diff --check`
|
||||
@@ -0,0 +1,159 @@
|
||||
---
|
||||
id: "02-picker-recovery"
|
||||
title: "Preserve selector recovery without visible failure paths"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on: ["01-scan-recovery"]
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-WORKSPACES-LOCAL-REPOSITORIES-003
|
||||
acceptance_criteria:
|
||||
- AC-WORKSPACES-LOCAL-REPOSITORIES-003.4
|
||||
- AC-WORKSPACES-LOCAL-REPOSITORIES-003.5
|
||||
- AC-WORKSPACES-LOCAL-REPOSITORIES-003.7
|
||||
- AC-WORKSPACES-LOCAL-REPOSITORIES-003.8
|
||||
- AC-WORKSPACES-LOCAL-REPOSITORIES-003.11
|
||||
system_design:
|
||||
- ../../specs/workspaces/system-design/local-repositories.md
|
||||
---
|
||||
|
||||
# Task 02: Preserve selector recovery without visible failure paths
|
||||
|
||||
## Summary
|
||||
|
||||
Keep failed-root diagnostics in structured backend logs while preserving
|
||||
available choices and the normal Refresh action in existing selectors.
|
||||
|
||||
## In scope
|
||||
|
||||
- Add component and E2E coverage for a server response with repositories and
|
||||
failed roots, asserting that failed paths are not rendered.
|
||||
- Keep existing normal Refresh behavior available during partial failure and
|
||||
refresh the repository list without a background retry.
|
||||
- Keep saved desktop Reconnect/Remove controls unchanged.
|
||||
- Retain `failedRoots` for coordinator retry suppression and structured
|
||||
diagnostics; preserve the existing repository-list Refresh in selectors.
|
||||
- Check all existing consumers: task creation, workspace sources, repository
|
||||
settings, automations, and Office project setup.
|
||||
- Remove warning-only locale keys that no longer have a selector consumer.
|
||||
- Extend desktop and mobile discovery E2E with partial-failure and selection
|
||||
flows.
|
||||
- Update recovery guidance in configuration and usage documentation.
|
||||
|
||||
## Out of scope
|
||||
|
||||
New picker layouts, native bridge changes, new discovery endpoints, and
|
||||
automatic retry changes are outside this work order.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Server and phone selectors do not show failed-root warnings or paths when a
|
||||
discovery response contains `failed_roots`.
|
||||
- Available choices remain selectable during failure, and the existing normal
|
||||
Refresh action remains reachable and refreshes the repository list without a
|
||||
background retry.
|
||||
- Desktop E2E keeps explicit saved-root Reconnect/Remove management. Phone E2E
|
||||
keeps the existing Refresh action at a 44-pixel touch target.
|
||||
|
||||
## ASCII UI preview
|
||||
|
||||
UI-01: Failed scan, excerpt from [the plan](plan.md#ascii-ui-preview).
|
||||
|
||||
```text
|
||||
Desktop selector Phone selector
|
||||
Search + Refresh Search + Refresh
|
||||
Available repository choices Available repository choices
|
||||
```
|
||||
|
||||
Keep the existing selector surface and scroll owner. Do not add a failure
|
||||
warning, path list, or selector-specific containment for failed roots. During
|
||||
refresh, preserve the existing action state and available choices. Failed-root
|
||||
details remain in structured backend logs. These structural requirements
|
||||
implement AC-003.11.
|
||||
|
||||
## Verification
|
||||
|
||||
From the repository root, install workspace dependencies if this worktree has none.
|
||||
|
||||
```bash
|
||||
(cd apps && pnpm install --frozen-lockfile)
|
||||
(cd apps/web && pnpm exec vitest run components/repository-discovery-controls.test.tsx components/repository-discovery-root-controls.test.tsx hooks/domains/workspace/use-repository-discovery.test.ts)
|
||||
(cd apps/web && pnpm run typecheck)
|
||||
(cd apps/web && pnpm run i18n:check)
|
||||
(cd apps/web && pnpm exec eslint components/repository-discovery-controls.tsx)
|
||||
make build-web
|
||||
make build-backend
|
||||
(cd apps/web && pnpm e2e:run --project chromium tests/task/repository-discovery-consent.spec.ts)
|
||||
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/task/mobile-repository-discovery.spec.ts)
|
||||
node --test scripts/validate-public-docs.test.mjs
|
||||
node scripts/validate-public-docs.mjs
|
||||
python3 scripts/list-docs.py validate
|
||||
python3 scripts/lint-spec-files.py --all
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Run added component/helper tests explicitly if implementation creates separate files.
|
||||
Inspect the rendered phone selector and compare it with UI-01. Keep E2E runs
|
||||
sequential under the existing worker limits. Use controlled API responses for
|
||||
partial discovery results. Task 01 supplies deterministic filesystem
|
||||
regression coverage.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/components/repository-discovery-controls.tsx` and its test
|
||||
- `apps/web/hooks/domains/workspace/use-repository-discovery.test.ts`
|
||||
- `apps/web/components/repository-discovery-root-controls.test.tsx`
|
||||
- `apps/web/components/task-create-dialog-pill.tsx`
|
||||
- `apps/web/e2e/tests/task/repository-discovery-consent.spec.ts`
|
||||
- `apps/web/e2e/tests/task/mobile-repository-discovery.spec.ts`
|
||||
- `apps/web/src/locales/` required catalogs
|
||||
- `docs/public/configuration.md` (reference)
|
||||
- `docs/public/use-kandev.md` (how-to)
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 01 provides the corrected result semantics. Public docs describe the final
|
||||
implementation, not draft behavior.
|
||||
|
||||
## Risks
|
||||
|
||||
Some selectors already provide Refresh. Preserve the existing action instead
|
||||
of adding a second recovery control. Do not offer root mutations for
|
||||
operator-configured paths. Preserve accessibility and existing desktop controls.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- [Requirements](../../specs/workspaces/requirements/local-repositories.md), REQ-003.
|
||||
- [Design](../../specs/workspaces/system-design/local-repositories.md), User interface.
|
||||
- Existing discovery-controls tests and desktop/mobile consent E2E fixtures.
|
||||
- Mobile-parity, E2E, TDD, and docs-maintainer skills.
|
||||
|
||||
## Results
|
||||
|
||||
Kept failed-root details in structured backend logs and removed their selector
|
||||
warning/path list. Browser and phone selectors retain available repositories
|
||||
and the normal Refresh action during partial failure. Saved desktop roots retain
|
||||
Reconnect and Remove controls.
|
||||
|
||||
Updated component, hook, and Chromium/mobile-chrome E2E coverage. Removed the
|
||||
unused warning locale keys and updated public configuration, desktop, and usage
|
||||
guidance.
|
||||
|
||||
Verification passed:
|
||||
|
||||
- Correction-focused Vitest run: 29 tests passed across 4 files
|
||||
- `pnpm run typecheck`
|
||||
- `pnpm run i18n:check`
|
||||
- Targeted ESLint for discovery components and workspace repository settings
|
||||
- `pnpm e2e:run --project chromium tests/task/repository-discovery-consent.spec.ts` (3 passed)
|
||||
- `pnpm e2e:run --project mobile-chrome tests/task/mobile-repository-discovery.spec.ts` (3 passed)
|
||||
- Mobile E2E covers failed-root diagnostics staying out of the selector,
|
||||
reachable Refresh, and healthy repository selection.
|
||||
- `make build-web`
|
||||
- `node --test scripts/validate-public-docs.test.mjs`
|
||||
- `node scripts/validate-public-docs.mjs`
|
||||
- `git diff --check`
|
||||
@@ -334,6 +334,15 @@ details. A browser can connect to a desktop backend and use the HTTP folder
|
||||
picker; the browser's picker capability does not change the backend's
|
||||
discovery policy.
|
||||
|
||||
If discovery cannot read a root, repository selectors keep repositories from
|
||||
successful roots available and retain their normal **Refresh repositories**
|
||||
action. Failed-root paths stay in structured backend logs and are not shown in
|
||||
selectors. A denied descendant does not require root reconnection. If a saved
|
||||
Desktop root itself fails, use **Reconnect** or **Remove**. Kandev does not
|
||||
create a missing clone directory during recovery.
|
||||
You can also enter an absolute repository path in **Add Local Repository** and
|
||||
select **Validate**; this explicit check is independent of discovery roots.
|
||||
|
||||
### Debug configuration
|
||||
|
||||
| YAML key | Environment variable | Default | Current behavior |
|
||||
|
||||
@@ -144,6 +144,14 @@ shows **Reconnect** and **Remove** actions. Reconnect selects the folder again
|
||||
and starts a fresh scan. Removal forgets that desktop discovery folder; it does
|
||||
not remove repositories or files.
|
||||
|
||||
If one root fails while another succeeds, repository selectors keep the
|
||||
successful results and their normal **Refresh repositories** action. Failed-root
|
||||
paths stay in structured backend logs and are not shown in selectors. An
|
||||
inaccessible descendant does not make its accessible root require
|
||||
reconnection. You can use **Add Local Repository** to validate an absolute path
|
||||
when automatic discovery cannot reach its root. Kandev does not create missing
|
||||
clone directories during recovery.
|
||||
|
||||
macOS privacy access belongs to the application identity. A replacement that
|
||||
is unsigned, re-signed, or otherwise has a different identity can require
|
||||
access again. Kandev cannot promise that consent survives every unsigned
|
||||
|
||||
@@ -127,6 +127,14 @@ workspace there from the menu sheet instead.
|
||||
4. Select **Use Repository**. This opens an unsaved repository card.
|
||||
5. Review the repository name, worktree branch template, pull behavior, setup/cleanup/dev scripts, copied files, and custom commands. Then select **Save changes**.
|
||||
|
||||
If discovery cannot scan a root, the picker keeps repositories from successful
|
||||
roots available and retains its normal **Refresh repositories** action. Failed
|
||||
root paths stay in structured backend logs and are not shown in selectors. A
|
||||
missing or inaccessible root does not prevent you from validating an absolute
|
||||
repository path. On Desktop, a saved root that fails also offers **Reconnect**
|
||||
and **Remove**. Kandev does not retry failed roots in the background or create
|
||||
missing clone directories.
|
||||
|
||||
New local repository records default to:
|
||||
|
||||
- source type `local`;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
status: active
|
||||
system: workspaces
|
||||
created: 2026-07-20
|
||||
updated: 2026-08-30
|
||||
updated: 2026-09-17
|
||||
owners:
|
||||
- kandev
|
||||
---
|
||||
@@ -108,9 +108,28 @@ can display a permission dialog after the user leaves the application.
|
||||
- **AC-WORKSPACES-LOCAL-REPOSITORIES-003.6:** Concurrent requests for the same
|
||||
roots shall share one scan.
|
||||
- **AC-WORKSPACES-LOCAL-REPOSITORIES-003.7:** A failed refresh shall preserve the
|
||||
last successful result and shall expose the failed root and recovery action.
|
||||
last successful result. Failed-root details shall remain in structured backend
|
||||
diagnostics. Repository selectors shall retain their existing manual Refresh
|
||||
action, and saved desktop roots shall retain their explicit recovery actions.
|
||||
- **AC-WORKSPACES-LOCAL-REPOSITORIES-003.8:** An empty or filtered discovery
|
||||
result shall show a visible manual Refresh action.
|
||||
- **AC-WORKSPACES-LOCAL-REPOSITORIES-003.9:** When a descendant is inaccessible,
|
||||
discovery shall continue through accessible siblings and retain repositories
|
||||
already found. An accessible root shall not require reconnection solely
|
||||
because a descendant is inaccessible.
|
||||
- **AC-WORKSPACES-LOCAL-REPOSITORIES-003.10:** When at least one root succeeds
|
||||
and another fails, discovery shall return fresh results from every successful
|
||||
root. It shall retain previous results only for failed roots. Successful
|
||||
empty scans shall remove obsolete results from those roots.
|
||||
- **AC-WORKSPACES-LOCAL-REPOSITORIES-003.11:** When roots fail, browser and phone
|
||||
repository selectors shall keep available repositories selectable and retain
|
||||
their normal manual Refresh action. They shall not render failed-root paths or
|
||||
a failed-root warning. Failed-root details shall remain in structured backend
|
||||
diagnostics. Saved desktop roots shall retain their Reconnect and Remove
|
||||
actions.
|
||||
- **AC-WORKSPACES-LOCAL-REPOSITORIES-003.12:** A missing clone directory shall
|
||||
not prevent results from other roots from appearing on initial or later scans.
|
||||
Discovery shall not create the directory to recover from this condition.
|
||||
|
||||
### REQ-WORKSPACES-LOCAL-REPOSITORIES-004: Filesystem access diagnostics
|
||||
|
||||
@@ -281,6 +300,8 @@ the repository record removes that exact durable grant from the workspace.
|
||||
|
||||
## Implementation Plans
|
||||
|
||||
- [Repository Discovery Failure Recovery](../../../plans/repository-discovery-failure-recovery/plan.md)
|
||||
|
||||
- [Explicit Local Repository Trust](../../../plans/explicit-local-repository-trust/plan.md)
|
||||
- [Local-only Merge and Rebase](../../../plans/local-only-merge-rebase/plan.md)
|
||||
- [Desktop Repository Discovery Consent](../../../plans/desktop-repository-discovery-consent/plan.md)
|
||||
|
||||
@@ -133,8 +133,11 @@ does not copy these roots into SQLite.
|
||||
The workspace-scoped discovery endpoint returns repositories for one workspace.
|
||||
It does not give workspace scope to the desktop root records.
|
||||
|
||||
The repository-discovery cache is keyed by the normalized root set and maximum
|
||||
depth. It stores the last successful repositories, scan time, and root state.
|
||||
The aggregate repository-discovery cache is keyed by the normalized root set
|
||||
and maximum depth. It stores the last successful repositories, scan time, and
|
||||
root state. A secondary snapshot cache is keyed by each exact normalized root
|
||||
and maximum depth. It survives aggregate invalidation so an unchanged root can
|
||||
retain its last successful repositories while a changed root set is scanned.
|
||||
One single-flight scan serves concurrent workspace requests for the same key.
|
||||
|
||||
## Upgrade behavior
|
||||
@@ -160,8 +163,46 @@ migration.
|
||||
3. The frontend renders saved and cached repositories immediately.
|
||||
4. If the surface is active and the snapshot is 30 minutes old, it requests a refresh.
|
||||
5. The backend shares an existing scan or starts one scan for the root set.
|
||||
6. Success replaces the cache and broadcasts the new snapshot.
|
||||
7. Failure preserves the cache and marks only the failed root for recovery.
|
||||
6. Each successful root replaces its cached results, including an empty result.
|
||||
7. Each failed root retains only its previous results and reports recovery state.
|
||||
|
||||
### Partial scan recovery
|
||||
|
||||
This section defines AC-WORKSPACES-LOCAL-REPOSITORIES-003.9 through 003.12.
|
||||
`repoWalker.visit` distinguishes root errors from descendant errors.
|
||||
An inaccessible root fails its scan. An inaccessible descendant produces a
|
||||
structured warning and does not terminate traversal of accessible siblings.
|
||||
Cancellation and deadline expiry abort the operation without a cache write.
|
||||
The existing Home exclusions and explicit-path validation remain unchanged.
|
||||
|
||||
`scanRootForRepos` retains repositories found before a descendant error.
|
||||
The scan carries the existing runtime and trigger context into descendant
|
||||
diagnostics. Warnings identify the denied descendant, not just its root.
|
||||
The walker emits at most one warning per denied path during one scan.
|
||||
It does not retry denied paths within that scan.
|
||||
|
||||
The aggregate `discoveryCacheEntry` retains results by exact normalized scan
|
||||
root internally. A secondary per-root snapshot retains each root's last
|
||||
successful result independently of the aggregate root-set key. This preserves
|
||||
unchanged roots across Add or Reconnect invalidation without borrowing results
|
||||
from a different normalized path or maximum depth. `scanDiscoveryRoots`
|
||||
replaces successful root entries and retains failed root entries. A root
|
||||
without previous results contributes an empty list on failure.
|
||||
The response deduplicates the union by repository path. Root membership comes
|
||||
from scan provenance, not a path-prefix guess, because effective roots can overlap.
|
||||
Cache snapshots and responses copy their slices to prevent concurrent mutation.
|
||||
|
||||
The public response keeps its existing fields. `failed_roots` lists current
|
||||
root failures. Descendant denials do not mark accessible roots as failed.
|
||||
An absent clone root remains a reported root failure, but never replaces fresh
|
||||
results from another root. Discovery does not create directories. Failed-root
|
||||
paths are diagnostic data and are not rendered by repository selectors.
|
||||
|
||||
The aggregate `scan_time` advances only after all roots succeed. During partial
|
||||
failure, it retains the previous complete-scan time, or remains absent.
|
||||
The coordinator retains automatic-retry suppression for failed snapshots.
|
||||
Manual Refresh retries the effective roots through the existing single-flight
|
||||
operation. This repair does not introduce background retries.
|
||||
|
||||
The shared discovery coordinator owns one activation count in each browser tab.
|
||||
An open consumer acquires one activation lease. It releases the lease when the
|
||||
@@ -202,6 +243,19 @@ browser on a desktop backend uses desktop policy and the HTTP folder browser.
|
||||
Temporary repository choices use the current phone-native picker or drawer
|
||||
composition. No native Tauri control appears in a browser or mobile viewport.
|
||||
|
||||
`RepositoryDiscoveryControls` remains the desktop root-management surface. It
|
||||
does not render failed-root warnings or paths for server, browser, or phone
|
||||
selectors. Those selectors keep their available repository choices and their
|
||||
normal manual Refresh action. The backend and coordinator retain failed-root
|
||||
data for structured diagnostics and automatic-retry suppression, but that data
|
||||
is logs-only from the selector's perspective.
|
||||
|
||||
Saved desktop roots retain their existing Reconnect and Remove actions. Those
|
||||
controls manage explicit saved roots and do not extend to operator-configured
|
||||
roots. Phone presentation reuses each existing selector and its scroll owner;
|
||||
no warning details or extra scroll owner is added. Desktop actions remain 28
|
||||
pixels high. Phone and coarse-pointer actions have at least 44-pixel hit targets.
|
||||
|
||||
## Workspace polling
|
||||
|
||||
Repository discovery and workspace monitoring remain separate functions. Normal
|
||||
@@ -268,7 +322,8 @@ Suggested event names are:
|
||||
|
||||
- Picker cancellation changes no grant and starts no scan.
|
||||
- An invalid selected path is rejected without persistence.
|
||||
- A partial scan returns the last successful cache and identifies failed roots.
|
||||
- A partial scan combines fresh successful roots with cached failed roots.
|
||||
- An inaccessible descendant does not discard accessible sibling results.
|
||||
- A denied root moves to `reconnect_required` and receives no automatic retry.
|
||||
- An unsigned update can change macOS code identity. The UI offers Reconnect and
|
||||
does not claim that one consent will survive every update.
|
||||
@@ -333,6 +388,10 @@ repository trust](../../../decisions/2026-08-28-explicit-submodule-repository-tr
|
||||
- Lifecycle tests cover final refresh, paused delivery, startup fallback, focus,
|
||||
and new operation activity.
|
||||
|
||||
## Implementation plans
|
||||
|
||||
- [Repository Discovery Failure Recovery](../../../plans/repository-discovery-failure-recovery/plan.md)
|
||||
|
||||
## Decisions
|
||||
|
||||
- [Use Explicit Roots for Desktop Repository Discovery](../../../decisions/2026-08-27-explicit-desktop-repository-discovery-roots.md)
|
||||
|
||||
Reference in New Issue
Block a user