Skip to content

fix(desktop): confirm the repo root when the picked folder isn't one - #6262

Closed
leepokai wants to merge 4 commits into
ParadevAI:mainfrom
leepokai:fix/open-project-confirm-repo-root
Closed

leepokai wants to merge 4 commits into
ParadevAI:mainfrom
leepokai:fix/open-project-confirm-repo-root

Conversation

@leepokai

@leepokai leepokai commented Aug 7, 2026 •

Copy link
Copy Markdown

What & why

getGitRoot resolves a picked folder with git rev-parse --show-toplevel, which walks up the directory tree. If the folder you pick isn't a git repository but any ancestor is, the app silently opens that ancestor instead. NotGitRepoError is the only trigger for the existing "Initialize Git Repository?" dialog, and it's only thrown when no ancestor is a repo, so you're never asked.

The case that hurts is a home directory tracked as a dotfiles repo. Pick ~/projects/new-thing in Add repository → Open project and Superset adds ~ as a project, named after your home directory, without a prompt. Drag-and-drop does the same thing through openFromPath.

This does not change which repository gets opened. A subdirectory still resolves to the repo that contains it, and that stays the default action. The only change is that the app no longer makes the choice silently. plans/20260601-v2-import-git-init-non-git-folder.md:100-102 deferred this case on purpose:

Edge — folder nested inside a parent git repo: git rev-parse --show-toplevel succeeds and resolves to the parent root, so detection reports "already a git repo" and we never offer init — we import the parent root, which is the current behavior. Leave it unchanged.

That's still the behavior. getGitRoot now returns { root, isRoot }, and openNew / openFromPath show the dialog when isRoot is false rather than writing a project row. The dialog gets a third button, Open <root>, which is the primary action and produces exactly the project you get today. Initialize Git was already wired to initGitAndOpen, which inits and upserts the path verbatim, so initializing the folder you actually picked works without a router change — it just wasn't reachable whenever an ancestor repo existed.

The root test is --show-prefix, which is empty exactly when the path is the work tree root. That keeps two cases right that a filesystem or string check would get wrong:

  • Worktree and submodule roots. At a git worktree root .git is a file, not a directory, so statSync(join(p, ".git")).isDirectory() would call it "not a repo" and offer to git init a worktree Superset created itself.
  • Path comparison. Comparing the picked path against --show-toplevel fails on macOS three separate ways: /tmp and /var canonicalize to /private/..., symlinks resolve to their realpath, and a case-insensitive volume returns canonical casing.

Bare repos and .git directories still fail with "this operation must be run in a work tree" rather than "not a git repository", so they keep falling through as a generic error and are never offered a git init. There are tests for both.

Scope is the Electron open-project path only. packages/host-service has the same walk-up in tryRevParseGitRoot / findByPath, but initLocalRepoInPlace documents ancestor adoption as intentional idempotence, so offering "initialize here" there needs a new wire flag through project.create. That's a separate PR.

How I tested it

bun test, bun run lint and bun run typecheck on macOS, bun 1.3.5, git 2.50.1.

Nine new cases in apps/desktop/src/lib/trpc/routers/workspaces/utils/git.test.ts covering repo root, subdirectory, untracked child folder, no ancestor repo, worktree root, worktree subdirectory, symlinked repo root, bare repo and .git directory. getGitRoot had no coverage before this.

$ cd apps/desktop && bun test src/lib/trpc/routers/workspaces/utils/git.test.ts -t getGitRoot
 9 pass
 0 fail
Ran 9 tests across 1 file.

The subdirectory and worktree cases are the regression guards — they assert the walk-up still happens and that a worktree root is still a root.

packages/host-service is untouched, and the two tests that lock the walk-up semantics pass unmodified:

$ cd packages/host-service && bun test src/trpc/router/project/utils/resolve-repo.test.ts
 30 pass
 0 fail

Full desktop suite, bun run lint and bun run typecheck:

$ cd apps/desktop && bun test
 2558 pass
 2 fail
Ran 2560 tests across 267 files.

$ bun run lint          # exit 0
$ bun run typecheck     # Tasks: 37 successful, 37 total

The 2 failures are pre-existing. I confirmed they fail identically on a clean main (a316a1f) with my changes stashed: Shell Environment > getProcessEnvWithShellPath applies shell PATH and preserves string vars and createWorktree hook tolerance > continues when git is killed mid-hook (timeout shape) but worktree is created. Baseline there is 37 pass / 2 fail in that file; with this change it's 46 pass / 2 fail.

src/no-main-process-blocking.test.ts passes with allowedCounts unedited (git.ts stays at 21, projects.ts at 6) — the change reuses the existing git client rather than adding one.

I haven't captured screenshots. I don't have a working desktop app to drive over CDP, and I'd rather not put a mocked-up image in a PR description. Happy to add them if you'd like, or the dialog copy is:

  • Title: Open the enclosing repository?
  • Body: new-thing isn't a git repository. It's inside dotfiles (/Users/me).
  • Buttons: Cancel · Initialize Git · Open dotfiles

When there's no enclosing repo the dialog is unchanged, both copy and buttons.

Checklist

  • PR title follows conventional commits (type(scope): subject)
  • bun run lint and bun run typecheck pass (CI fails on lint warnings too)
  • "Allow edits from maintainers" is checked on fork PRs

Summary by cubic

Ask the user to confirm when they pick a folder inside a Git repo. The dialog lets them open the enclosing repo or initialize Git in the selected folder; default stays “open enclosing,” and only shows when all selected folders are nested.

  • Bug Fixes
    • Desktop: detect non-root selections and show a three-button dialog (Cancel · Initialize Git · Open ). Multi-select supported; “Open enclosing” appears only when every folder has an enclosing repo. Track which action is running so each button shows accurate progress; dedupe projects when a repo root and its subfolder are both selected; surface typed error results from openFromPath.
    • getGitRoot now returns { root, isRoot }; reads --show-toplevel and --show-prefix in two calls to handle names with newlines; uses raw to preserve trailing whitespace and strips only the trailing LF (preserves a trailing CR). Keeps worktrees, submodules, symlinks, and macOS path canonicalization correct.
    • Tests: add cases for roots ending in trailing whitespace and trailing CR, and retain coverage for repo root, subdirectory, untracked child, no ancestor, worktree root/subdir, symlinked root, bare repo, and .git directory.

Written for commit ff12eba. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Improved project opening for folders nested inside existing Git repositories.
    • Added an option to open the enclosing repository directly.
    • Git initialization prompts now distinguish standalone folders from folders within repositories.
    • Preserved selected folder paths for confirmation and initialization.
  • Bug Fixes

    • Improved repository-root detection for nested, linked, symlinked, and bare repositories.
    • Improved handling of repository paths containing special characters.

`git rev-parse --show-toplevel` walks up the directory tree, so opening a
folder that isn't a git repository silently opened the nearest ancestor
that is one. Someone whose home directory is a dotfiles repo could pick
`~/projects/thing` and get their whole home directory added as a project,
with no prompt: the existing "Initialize Git Repository?" dialog only
fires when no ancestor is a repo at all.

Resolution is unchanged — a subdirectory still resolves to the repo that
contains it. getGitRoot now also reports whether the requested path is
itself the work tree root, and openNew/openFromPath surface the dialog
instead of deciding silently. Opening the enclosing repo stays the
primary action and produces the same project as before; initializing the
folder the user actually picked is now reachable as an alternative.

`--show-prefix` is empty exactly at a work tree root, which keeps
worktree roots (where `.git` is a file) and submodule roots correct and
avoids comparing paths against git output, which would break on macOS
where /tmp canonicalizes to /private/tmp, symlinks resolve to realpaths
and a case-insensitive volume returns canonical casing.
@coderabbitai

coderabbitai Bot commented Aug 7, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Project opening detects when a selected directory is nested inside a Git repository. The flow preserves the selected path and enclosing repository path. The Git initialization dialog can initialize the selected directory or open the enclosing repository.

Changes

Nested repository opening

Layer / File(s) Summary
Git-root detection contract and coverage
apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts, apps/desktop/src/lib/trpc/routers/workspaces/utils/git.test.ts
getGitRoot returns the repository root and an isRoot flag. Tests cover nested paths, worktrees, symlinks, bare repositories, .git directories, and error classification.
Nested project-opening results
apps/desktop/src/lib/trpc/routers/projects/projects.ts
openNew and openFromPath return the selected path and optional enclosing repository path when the selection is nested.
Git initialization confirmation flow
apps/desktop/src/renderer/stores/git-init-dialog.ts, apps/desktop/src/renderer/react-query/projects/useOpenProject.tsx, apps/desktop/src/renderer/react-query/projects/InitGitDialog.tsx
The dialog stores folder metadata, initializes selected folders, and opens unique enclosing repositories. The UI displays enclosing repository information and conditional actions.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant InitGitDialog
  participant useOpenProject
  participant openFromPath
  User->>InitGitDialog: Select an action for a nested folder
  InitGitDialog->>useOpenProject: Confirm initialization or open enclosing repository
  useOpenProject->>openFromPath: Open each unique enclosing repository
  openFromPath-->>useOpenProject: Return opened projects
  useOpenProject-->>InitGitDialog: Close dialog and resolve projects
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title uses conventional commit format and clearly describes confirming the enclosing repository for a non-root folder selection.
Description check ✅ Passed The description includes complete What & why, testing details, and checklist sections with relevant implementation and validation information.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (1)
apps/desktop/src/renderer/react-query/projects/InitGitDialog.tsx (1)

13-118: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use the required component directory layout.

Move this component to InitGitDialog/InitGitDialog.tsx. Add an InitGitDialog/index.ts barrel export. The direct InitGitDialog.tsx layout does not meet the component organization rule.

As per coding guidelines, “Use one component per file and organize components as ComponentName/ComponentName.tsx with an index.ts barrel export.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/renderer/react-query/projects/InitGitDialog.tsx` around
lines 13 - 118, Reorganize the InitGitDialog component into an InitGitDialog
directory by moving the existing InitGitDialog function implementation into
InitGitDialog/InitGitDialog.tsx, then add InitGitDialog/index.ts as the barrel
export. Update any imports or references to use the barrel export while
preserving the component’s behavior.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/desktop/src/lib/trpc/routers/workspaces/utils/git.test.ts`:
- Around line 1202-1205: Replace the interpolated shell commands in the worktree
setup with execFileSync("git", [...]) calls for both worktree operations near
the existing execSync calls, passing repository arguments and worktreePath as
separate arguments while preserving the current cwd and stdio options.
- Around line 1189-1193: Await the promise returned by the getGitRoot rejection
assertion in the test “throws NotGitRepoError when no ancestor is a repo.” Apply
the same await to the two later expect(...).rejects assertions in the related
tests so each rejection assertion completes before the test exits.

In `@apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts`:
- Around line 893-896: Update the Git output parsing near the root-path return
to preserve the exact root value from split("\n"); remove the root.trim()
transformation while retaining prefix.trim() for the emptiness check. Ensure
openNew and openFromPath receive root paths with trailing whitespace unchanged.

---

Nitpick comments:
In `@apps/desktop/src/renderer/react-query/projects/InitGitDialog.tsx`:
- Around line 13-118: Reorganize the InitGitDialog component into an
InitGitDialog directory by moving the existing InitGitDialog function
implementation into InitGitDialog/InitGitDialog.tsx, then add
InitGitDialog/index.ts as the barrel export. Update any imports or references to
use the barrel export while preserving the component’s behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b93bbecc-0dd4-4799-bb11-2ad0514a95b6

📥 Commits

Reviewing files that changed from the base of the PR and between a316a1f and a37ffc5.

📒 Files selected for processing (6)
  • apps/desktop/src/lib/trpc/routers/projects/projects.ts
  • apps/desktop/src/lib/trpc/routers/workspaces/utils/git.test.ts
  • apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts
  • apps/desktop/src/renderer/react-query/projects/InitGitDialog.tsx
  • apps/desktop/src/renderer/react-query/projects/useOpenProject.tsx
  • apps/desktop/src/renderer/stores/git-init-dialog.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

1 issue found across 6 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/desktop/src/lib/trpc/routers/workspaces/utils/git.test.ts">

<violation number="1" location="apps/desktop/src/lib/trpc/routers/workspaces/utils/git.test.ts:1158">
P3: The new getGitRoot tests compare the git-reported root against the locally joined `repoPath` with `toBe`. On Windows, `git rev-parse --show-toplevel` returns forward-slash paths while `join` produces backslashes, so these assertions would fail (and `symlinkSync` on a directory needs elevated privileges). Consider normalizing both sides through `realpathSync` or comparing with a platform-independent helper so the suite stays portable.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic


const result = await getGitRoot(repoPath);

expect(result.root).toBe(repoPath);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: The new getGitRoot tests compare the git-reported root against the locally joined repoPath with toBe. On Windows, git rev-parse --show-toplevel returns forward-slash paths while join produces backslashes, so these assertions would fail (and symlinkSync on a directory needs elevated privileges). Consider normalizing both sides through realpathSync or comparing with a platform-independent helper so the suite stays portable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/lib/trpc/routers/workspaces/utils/git.test.ts, line 1158:

<comment>The new getGitRoot tests compare the git-reported root against the locally joined `repoPath` with `toBe`. On Windows, `git rev-parse --show-toplevel` returns forward-slash paths while `join` produces backslashes, so these assertions would fail (and `symlinkSync` on a directory needs elevated privileges). Consider normalizing both sides through `realpathSync` or comparing with a platform-independent helper so the suite stays portable.</comment>

<file context>
@@ -1134,3 +1137,129 @@ describe("parsePrUrl", () => {
+
+		const result = await getGitRoot(repoPath);
+
+		expect(result.root).toBe(repoPath);
+		expect(result.isRoot).toBe(true);
+	});
</file context>

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Leaving this as is for now. CI is ubuntu-latest across every job in ci.yml, and the rest of this file already compares join()-built paths against git output the same way (the getWorktreeCreatedAt and createWorktree blocks above), so normalizing only the new tests would make them inconsistent with their neighbours. If the suite ever runs on Windows it needs a pass over the whole file rather than these nine cases.

Worth noting the production code is separator-agnostic — the root check is --show-prefix being empty, so nothing compares a joined path to git output at runtime. This is purely a test-portability point.

Trimming the toplevel line corrupts a path whose final directory name
ends in whitespace, handing openNew/openFromPath a path that doesn't
exist. Splitting on newline already separates the two values, so only a
trailing CR needs stripping.

Also await the rejects assertions in the new getGitRoot tests.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 2 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Read the toplevel and the prefix in two rev-parse calls instead of
splitting one newline-delimited result: a directory name may contain a
newline, which would truncate the root and open the wrong repository.
Reading the toplevel with `raw` keeps stdout untrimmed, so a root whose
final directory name ends in whitespace survives, and there is now a test
covering that.

The enclosing-repo action only appears when every selected folder has an
enclosing repo. In a mixed selection it would otherwise open the nested
folders and silently drop the plain ones, which are neither opened nor
initialized.

Also dedupe the resolved projects, since selecting a repo root together
with one of its subfolders resolves to the same project twice; surface
typed error results from openFromPath instead of dropping them; and track
which action is running so the buttons no longer both read
"Initializing...".
@leepokai

leepokai commented Aug 7, 2026

Copy link
Copy Markdown
Author

Pushed cd218a9 addressing the review. Summary of what changed:

  • Newline in a path truncating the root. Split the single rev-parse --show-toplevel --show-prefix into two calls, since a directory name containing a newline makes the combined output impossible to split back apart. The toplevel is now read with raw so stdout stays untrimmed, which also keeps the trailing-whitespace case working.
  • No coverage for the whitespace behaviour. Added a test that creates a repo whose directory name ends in a space and asserts root matches the on-disk path exactly (10 pass in the getGitRoot block now).
  • Mixed selections silently dropping folders. The enclosing-repo action now only appears when every selected folder has an enclosing repo. Previously a selection mixing nested folders with plain non-repo folders would open the nested ones and drop the rest with no feedback.
  • Duplicate projects. Selecting a repo root together with one of its own subfolders resolved to the same project twice; the resolved list is deduped by id.
  • Swallowed errors. Typed error results from openFromPath are logged instead of dropped.
  • Wrong pending label. The dialog tracks which action is running, so choosing "Open " no longer makes the Initialize button read "Initializing...".
  • Duplicated settle logic. Both actions now share one settle routine.

Two I left alone, with reasoning in the threads: the execSync interpolation in tests (matches 22 existing call sites including the identical git worktree add line in two neighbouring files) and the Windows path-separator point (CI is ubuntu-only and the rest of the file compares paths the same way; the production code is separator-agnostic).

Re-verified after the changes: bun run lint exit 0, bun run typecheck 37/37, desktop suite 2559 pass / 2 fail, and packages/host-service resolve-repo.test.ts still 30/30 unmodified. The 2 failures are the pre-existing ones I noted in the description.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 5 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

A directory name can end in a carriage return, and git round-trips that
as `<name>\r\n`, so consuming the CR as part of the terminator returned a
path that doesn't exist. Verified against git 2.50.1: `rev-parse
--show-toplevel` ends the line with a bare LF, and a repo directory whose
name ends in CR reports `.../cr\r\n`.

Covered by a test alongside the trailing-whitespace one.
@github-actions

Copy link
Copy Markdown
Contributor

This PR has had no activity for 53 days and will close in 7 days unless there is new activity. If this contribution is still important or relevant, please leave a comment to keep it open. If it closes, you are welcome to reopen it or ask a maintainer to reopen it. Thank you for contributing!

@github-actions github-actions Bot added the stale label Sep 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

This PR was automatically closed due to inactivity to help keep the review queue manageable. This does not mean the contribution was rejected. If this contribution is still important or relevant, please reopen it or leave a comment asking a maintainer to reopen it. Thank you for contributing!

@github-actions github-actions Bot closed this Oct 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant