Skip to content

fix: Existing Folder workflow reuses local repo instead of bare clone#527

Open
PureWeen wants to merge 10 commits intomainfrom
fix/existing-folder-reuse-local-repo
Open

fix: Existing Folder workflow reuses local repo instead of bare clone#527
PureWeen wants to merge 10 commits intomainfrom
fix/existing-folder-reuse-local-repo

Conversation

@PureWeen
Copy link
Copy Markdown
Owner

@PureWeen PureWeen commented Apr 6, 2026

Problem

When a user adds an existing repo via Existing Folder (e.g. their dotnet/maui clone), PolyPilot:

  1. Created a bare clone of the entire repo at ~/.polypilot/repos/ (redundant - user already has it)
  2. Created nested worktree checkouts inside the user's repo at .polypilot/worktrees/{branch}/
  3. The user's existing checkout was never used for sessions

For huge repos like MAUI (~5GB), this wasted significant disk space and time.

Additionally, when both a URL-based repo and a local folder pointed to the same remote, both incorrectly used the same BareClonePath, causing the local folder to be ignored.

Changes

Skip bare clone for Existing Folder repos

  • AddRepositoryFromLocalAsync now points BareClonePath directly at the user's existing repo instead of creating a redundant bare clone
  • EnsureRepoCloneInCurrentRootAsync detects non-bare repos (.git dir/file) and skips clone management

Separate repos for URL vs local folder

  • When a URL-based repo already exists, adding the same repo from a local folder creates a separate RepositoryInfo with a distinct ID ({baseId}-local-{pathHash})
  • The original URL-based repo's BareClonePath is never overwritten
  • Idempotent: adding the same local folder twice returns the same repo

Validate bare clone before reuse

  • Corrupt or partial bare clone directories are detected and removed before re-cloning

Reuse existing checkout for same-branch sessions

  • CreateWorktreeAsync checks for an existing registered worktree on the requested branch before creating a new one
  • Only matches centralized worktrees (under ~/.polypilot/worktrees/), never external user checkouts

Remove nested worktree strategy

  • All worktrees now go to the centralized ~/.polypilot/worktrees/ directory
  • Removed localPath parameter from CreateWorktreeAsync, CreateSessionWithWorktreeAsync, and all UI callers

Bug fixes

Orchestrator image dispatch fix (cherry-picked from PR #590)

  • Orchestrator messages now always route through the dispatch pipeline even with image attachments
  • Previously, image attachments caused the dispatch pipeline to be bypassed entirely

Testing

All 3433 tests pass (12 pre-existing ExternalSessionScannerTests failures due to missing node).

Test coverage includes:

  • 11 AddExistingRepoTests (local folder behavior, URL vs local separation, idempotency, validation errors, ID format, reconciliation)
  • 43 RepoManagerTests (URL/ID parsing, clone management, worktree reuse, git exclude entries)
  • 34 WorktreeStrategyTests (strategy behaviors, worktree creation)

Copy link
Copy Markdown
Owner Author

@PureWeen PureWeen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #527 Code Review

Focus: regressions, bugs, data loss, race conditions. No style comments.


🔴 Critical — RemoveRepositoryAsync can delete the user's real project

File: RepoManager.cs (the deleteFromDisk path in RemoveRepositoryAsync)

if (deleteFromDisk && Directory.Exists(repo.BareClonePath))
    try { Directory.Delete(repo.BareClonePath, recursive: true); } catch { }

When a repo was added via "Existing Folder", BareClonePath now points to the user's actual working directory (e.g. ~/projects/my-app). This will recursively delete the user's entire project. The guard in RemoveWorktreeAsync that checks against managed paths does NOT protect this separate deletion site.

Fix: Before deleting, check whether BareClonePath is under the managed ReposDir:

bool isManagedBareClone = repo.BareClonePath.StartsWith(ReposDir, StringComparison.OrdinalIgnoreCase);
if (deleteFromDisk && isManagedBareClone && Directory.Exists(repo.BareClonePath))
    try { Directory.Delete(repo.BareClonePath, recursive: true); } catch { }

🔴 High — Existing bare clone silently orphaned when same repo added via folder

File: RepoManager.cs, AddRepositoryFromLocalAsync

existing.BareClonePath = localPath;  // overwrites e.g. ~/.polypilot/repos/owner-repo.git

If the user previously added the same repo via URL (creating a managed bare clone), then adds it again via "Existing Folder", the bare clone at ~/.polypilot/repos/ is orphaned and never cleaned up. Worse, all existing worktrees created from that bare clone become disconnected — their .git files still reference the old bare clone's worktrees/ directory, but future git fetch, git worktree list, and git branch -D calls now run against the local path instead.

Fix: When existing.BareClonePath is non-null and points to a managed path (under ReposDir), either keep using it or explicitly clean up the old bare clone before overwriting.


🟠 High — BackfillWorktreeClonePaths propagates non-bare path to pre-existing worktrees

File: RepoManager.cs, called from AddRepositoryFromLocalAsync after setting BareClonePath = localPath

BackfillWorktreeClonePaths overwrites BareClonePath on every worktree that has a blank value. Worktrees created before this re-add get their BareClonePath silently redirected to the user's non-bare repo. When RemoveWorktreeAsync subsequently runs git worktree remove using this path, it operates on the user's local repo — potentially affecting worktrees the user manages outside of PolyPilot.


🟠 High — git worktree add on non-bare repo fails when branch is already checked out

File: RepoManager.cs, CreateWorktreeAsync

git worktree add errors with fatal: '<branch>' is already checked out at '<path>' if the user's repo has the same branch checked out. This is most likely to happen for the default branch (main). The worktree reuse code mitigates this for the registered local path, but any new branch creation that matches the parent's current branch will fail.


🟡 Medium — testhost in production FindActiveLockPid risks false positives

File: ExternalSessionScanner.cs

testhost is the .NET test runner. If a user runs dotnet test and PID recycling causes a stale lock file to contain the testhost PID, FindActiveLockPid returns a false positive — claiming a session is active when it's not, blocking session cleanup.

This is a test-only concern that shouldn't be in production code. Fix: Make the process name filter injectable, then add testhost only in the test. Or simply assert matchesFilter || myName.Contains("testhost") in the test without touching production code.


🟡 Medium — Worktree reuse returns stale/corrupted worktrees without validation

File: RepoManager.cs, CreateWorktreeAsync (new reuse check)

&& Directory.Exists(w.Path)

Directory.Exists returns true even if the worktree is corrupted (missing/broken .git file), locked, or has uncommitted changes from a previous session that will contaminate the new session's workspace.

Fix: Run git -C <path> rev-parse --git-dir before reusing, and optionally warn if the working tree is dirty.


🟡 Medium — GetDefaultBranch returns wrong branch for non-bare repos not on default branch

File: RepoManager.cs, GetDefaultBranch

git symbolic-ref HEAD on a non-bare repo returns the currently checked-out branch, not the repo's canonical default. If the user's repo is on feature/my-thing, the new worktree branches from feature/my-thing instead of main.

Fix: Use git rev-parse --abbrev-ref origin/HEAD to get the canonical default branch.


🔵 Low — RunGhAsync GIT_DIR guard stale after non-bare BareClonePath

File: RepoManager.cs, RunGhAsync

The guard if (workDir.EndsWith(".git")) setting GIT_DIR was written assuming BareClonePath always ends in .git. It's now a plain directory path. gh still discovers the remote, so it's not broken today — but the assumption is now wrong and the comment is misleading for future callers.


Summary

Severity Issue
🔴 Critical RemoveRepositoryAsync recursively deletes user's real project on deleteFromDisk
🔴 High Existing bare clone silently orphaned when same repo re-added via folder
🟠 High BackfillWorktreeClonePaths corrupts clone paths of pre-existing worktrees
🟠 High git worktree add fails when non-bare repo has same branch checked out
🟡 Medium testhost in production process filter — false positive risk
🟡 Medium Worktree reuse skips corruption/dirty-state validation
🟡 Medium GetDefaultBranch returns wrong branch for non-default-branch checkouts
🔵 Low RunGhAsync GIT_DIR guard stale assumption

The Critical issue is a data-loss bug that should block merge. The three High issues reflect structural concerns with the "BareClonePath → non-bare repo" approach that need explicit guards.

@PureWeen
Copy link
Copy Markdown
Owner Author

PureWeen commented Apr 6, 2026

🔍 Multi-Model Code Review — PR #527 (v4)

Reviewed by: 3 independent reviewers with adversarial consensus
Commits reviewed: 3 (16a8048, d40e4f6, 9e21b09)
CI Status: ⚠️ No checks configured for this branch


New Findings

🔴 CRITICAL: Missing EnsureLoaded() in AddRepositoryFromLocalAsync (3/3)

File: PolyPilot/Services/RepoManager.cs:550-639
Flagged by: All 3 reviewers (1 initial + 2 adversarial confirmation)

AddRepositoryFromLocalAsync accesses _state.Repositories (line 595) without calling EnsureLoaded() first. Every other public method that reads or mutates _state (18+ call sites) calls EnsureLoaded() as its first action. The old implementation safely delegated to AddRepositoryAsync which does call EnsureLoaded().

Data-loss scenario: If AddRepositoryFromLocalAsync is called before any other method triggers Load():

  1. _state is the default empty new RepositoryState()
  2. The method creates a new repo in the empty list (line 621)
  3. Save() at line 624 passes the empty-state guard (guard only skips when state is empty AND not loaded, but Repositories.Count == 1 at this point)
  4. repos.json is overwritten with only 1 repo — all previously registered repositories are silently destroyed

Fix: Add EnsureLoaded(); immediately before the lock (_stateLock) block at line 593.


🔴 CRITICAL: Orphaned bare clone deletion breaks active worktrees (2/3) — 4th consecutive round

File: PolyPilot/Services/RepoManager.cs:607-631
Flagged by: 2/3 reviewers independently

When a user adds a local folder for a repo already registered via URL, AddRepositoryFromLocalAsync:

  1. Overwrites existing.BareClonePath = localPath (line 607)
  2. Calls BackfillWorktreeClonePaths (line 608) — but this only updates worktrees with null/empty BareClonePath
  3. Deletes the old managed bare clone (lines 628-631)

Worktrees created by CreateWorktreeAsync always have BareClonePath set (line 787), so they are silently skipped by BackfillWorktreeClonePaths. After deletion:

  • wt.BareClonePath in state still points at the deleted directory
  • Each worktree's .git file contains gitdir: <deleted-bare-clone>/worktrees/<id> — all git operations fail immediately

Scenario: User adds https://github.com/org/repo via URL → creates sessions (worktrees) → later adds same repo via "Existing Folder" → all existing worktrees are permanently broken.

Suggested fix (safest): When the existing repo already has a managed bare clone AND active worktrees exist, preserve the existing repo as-is — don't overwrite BareClonePath, don't delete. Only register the local folder as an external worktree. This eliminates the entire class of orphan bugs.


Previous Findings Status

# Finding v1 v2 v3 v4
1 RemoveRepositoryAsync data-loss guard 🔴 ✅ FIXED
2 Worktree reuse scoping 🔴 ✅ FIXED
3 Race in concurrent CreateWorktreeAsync ⚠️ ⚠️ PARTIALLY FIXED
4 Worktree health check ⚠️ ⚠️ PARTIALLY FIXED
5 Orphaned bare clone deletion 🔴 🔴 🔴 🔴 STILL PRESENT
6 Safety tests structural-only 🟡 🟡 🟡 STILL PRESENT
7 XML doc misattached 🟢 ✅ FIXED (overload removed)
8 Missing EnsureLoaded() 🔴 NEW

Test Coverage Assessment

  • AddRepositoryFromLocal_PointsBareClonePathAtLocalRepo — core behavioral test
  • AddRepositoryFromLocal_NoBareCloneCreatedInReposDir — source-parsing test
  • ❌ No behavioral test for orphan scenario (URL-based repo + worktrees + local folder add)
  • AddRepositoryFromLocal_DoesNotOverwriteExistingUrlBasedRepo mentioned in earlier review but never added
  • 🟡 Safety tests (delete guard, worktree reuse) are structural/source-parsing only

Recommendation

⚠️ Request changes — 2 critical issues must be fixed:

  1. Add EnsureLoaded(); before the lock block in AddRepositoryFromLocalAsync (1-line fix)
  2. Preserve existing URL-based repos — when AddRepositoryFromLocalAsync finds an existing repo with a managed bare clone, don't overwrite BareClonePath or delete the bare clone. Only register the local folder as an external worktree. Add a behavioral test for this scenario.

Items 3, 4, and 6 are lower-severity and acceptable as follow-up work.

@PureWeen PureWeen force-pushed the fix/existing-folder-reuse-local-repo branch from a231f7a to 9e21b09 Compare April 7, 2026 20:53
@PureWeen PureWeen force-pushed the fix/existing-folder-reuse-local-repo branch from 864d89e to 83ea56e Compare April 15, 2026 19:23
PureWeen and others added 10 commits April 15, 2026 16:30
- AddRepositoryFromLocalAsync now points BareClonePath at the user's
  existing repo instead of creating a redundant bare clone
- EnsureRepoCloneInCurrentRootAsync skips clone management when
  BareClonePath points at a non-bare repo (.git dir/file exists)
- CreateWorktreeAsync reuses an existing registered worktree when the
  requested branch matches (avoids duplicating huge repos like MAUI)
- Removed nested worktree strategy — all worktrees now go to the
  centralized ~/.polypilot/worktrees/ directory
- Removed localPath parameter from CreateWorktreeAsync,
  CreateSessionWithWorktreeAsync, and all UI callers
- Fixed Path.Combine producing backslashes for Unix-style path in
  BuildContinuationTranscript
- Fixed FindActiveLockPid process name filter to include testhost

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Guard RemoveRepositoryAsync to only delete BareClonePath under managed
  ReposDir, preventing recursive deletion of user's real project
- Restrict worktree reuse to centralized WorktreesDir only — external
  user checkouts are never returned to avoid multi-session conflicts
- Clean up orphaned managed bare clone when same repo is re-added via
  Existing Folder (prevents disk waste)
- Fix GetDefaultBranch to prefer origin/HEAD over symbolic-ref HEAD so
  non-bare repos don't branch from the wrong base
- Revert testhost from production FindActiveLockPid (test-only concern)
- Update RunGhAsync comment to reflect non-bare repo support
- Add regression tests for delete guard and worktree reuse scoping

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Replace AddRepositoryFromLocal_ClonesLocallyAndSetsRemoteUrl with
  AddRepositoryFromLocal_PointsBareClonePathAtLocalRepo (verifies
  BareClonePath points at user's local repo, no bare clone created)
- Replace localCloneSource reflection test with source-code assertion
  that AddRepositoryFromLocalAsync never calls AddRepositoryAsync
- Remove unused localCloneSource overload from AddRepositoryAsync
- Add GetRepoRoot/ExtractMethodBody test helpers

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ixes #570)

The repo picker was using id.Split('-').Last() to derive display names,
which made 'vscode-maui' and 'maui' both show as 'maui'. Added
RepoNameFromUrl() that extracts the actual repo name (last path segment)
from the git URL, preserving hyphens in repo names.

Fixed all 3 places where RepositoryInfo.Name is set:
- AddRepositoryAsync (remote clone)
- AddRepositoryFromLocalAsync (existing folder)
- Load() healing path (bare clone recovery)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
#570)

Existing repos persisted with the old id.Split('-').Last() naming showed
ambiguous names in the sidebar (e.g., both 'dotnet/maui' and
'nicknisi/vscode-maui' displayed as 'maui').

Changes:
- Add EnsureLoaded() call to AddRepositoryFromLocalAsync (was missing)
- Add name migration on Load(): re-derives repo names from URLs
- Add group name migration in ReconcileOrganization: updates sidebar
  group names to match corrected repo names
- Add test: Load_MigratesOldStyleRepoNames verifies the migration

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
When a repo was first added via 'Add from URL' (creating a managed bare
clone) and then the same repo was added via 'Existing Folder',
AddRepositoryFromLocalAsync was overwriting the existing repo's
BareClonePath to point at the local folder and deleting the managed bare
clone. This made the URL-based repo disappear from the sidebar.

Fix: when an existing repo with the same remote URL is found, keep it
as-is and only register the local folder as an external worktree. The
UI caller (AddLocalFolderAsync) separately creates a 📁 local folder
group, so both entries coexist in the sidebar.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…tories

When a bare clone directory exists on disk but is not a valid git
repository (e.g., leftover from a failed/interrupted clone), the
fetch would fail with 'not a git repository'. Now both
AddRepositoryAsync and EnsureRepoCloneInCurrentRootAsync validate
the directory with IsGitRepositoryAsync before reusing it, and
delete corrupt directories to allow a fresh clone.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
When a repo was already added via 'Add from URL' (managed bare clone at
~/.polypilot/repos/), adding the same repo from a local folder now
creates a SEPARATE RepositoryInfo with a distinct ID (e.g.,
'dotnet-maui-local-{hash}') and BareClonePath pointing at the user's
local checkout. This ensures both repos coexist with independent paths.

Previously the code reused the existing repo, causing the local folder
to share the managed bare clone path — the local folder was never used.

- Added path-hash suffix for local repos to avoid ID collisions
- Idempotent: re-adding the same local folder returns the same repo
- URL-based repo and its bare clone are never touched
- Updated tests: CreatesSeparateRepo + IdempotentForSameLocalFolder

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Previously, messages with image attachments sent to orchestrator sessions
bypassed SendToMultiAgentGroupAsync entirely, going directly through
SendPromptAsync. The orchestrator would produce @worker blocks in its
response, but nobody parsed them — workers were never dispatched.

Changes:
- Dashboard.razor: Always route orchestrator messages through the dispatch
  pipeline. Images are stripped (pipeline doesn't support forwarding them)
  with a user-visible warning. This ensures @worker blocks are always
  parsed and workers are dispatched.
- Dashboard.razor: Dispatch errors now logged via LogDispatchError (persisted
  to event-diagnostics.log) instead of Console.WriteLine (invisible).
- CopilotService.Organization.cs: Added LogDispatchError method for
  persisting dispatch failures to diagnostics log.
- CopilotService.Organization.cs: ResumeOrchestrationIfPendingAsync now
  resets stale ReflectionState (IsActive=false, CompletedAt set) after
  completing resume synthesis. Without this, StartGroupReflection returns
  early on the next user prompt because the old IsActive=true persists
  across app restarts.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…git detection

- AddRepositoryFromLocal_LocalRepoId_HasExpectedFormat: verifies the
  '{baseId}-local-{hexhash}' ID pattern when URL repo already exists
- EnsureRepoClone_SkipsCloneForNonBareRepo_WithGitDirectory: structural
  guard that .git dir AND .git file are checked
- AddRepositoryFromLocal_ValidationErrors_ThrowDescriptiveExceptions:
  verifies descriptive errors for missing folder, non-git, and no origin

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@PureWeen PureWeen force-pushed the fix/existing-folder-reuse-local-repo branch from 5f3912f to a633ef3 Compare April 15, 2026 21:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant