Skip to content

fix: reject an existing bare repository as an init destination - #29

Merged
ashproto merged 5 commits into
nextfrom
fix/detect-bare-repo-destination
Aug 9, 2026
Merged

fix: reject an existing bare repository as an init destination#29
ashproto merged 5 commits into
nextfrom
fix/detect-bare-repo-destination

Conversation

@ashproto

@ashproto ashproto commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Reported by Codex review on #26 as P2. The code is not part of that PR — crates/git-core/src/git_ops.rs is untouched by #26, and the guard came from 15e51c3 (2026-07-17) — so it is fixed here on its own branch, same as #27.

The gap

initialize_repository guards against an existing repository two ways, and a bare one slips between them:

guard bare repo
destination.join(".git").exists() false — a bare repo has no .git child
is_inside_worktree(destination) falserev-parse --is-inside-work-tree says false inside a bare repo

The destination is non-empty, so the user gets the "folder is not empty" prompt rather than "already a Git repository". On confirming, git init runs with the bare repository as its working directory.

What actually happens

Verified against real git rather than assumed — this is the exact command git_ops.rs issues, cwd set to a bare repo holding a real branch:

$ (cd bare.git && git init --initial-branch=newbranch)
Initialized empty Git repository in …/bare.git/.git/

nested .git created?  YES
bare HEAD after:      refs/heads/main     (unchanged)
refs still present:   refs/heads/trunk    (unchanged)
core.bare after:      true                (unchanged)

$ diff -rq bare-backup.git bare.git
Only in bare.git: .git

So it is a wrong state, not data loss — the bare repo survives intact and the only change is the nested .git. Worth stating plainly since "mutates a directory that should have been rejected" could read as destructive. It is still exactly what the sibling guard exists to prevent, and the app then reports success and opens the nested repo.

The fix

Probe the destination itself with rev-parse --resolve-git-dir:

if destination.join(".git").exists() || is_git_dir(&destination) {
    return Err("That folder is already a Git repository. Open it instead.".to_string());
}

I characterised the probe against every shape before picking it, because the risk is over-reach — refusing a legitimate destination:

path --resolve-git-dir
bare repo dir OK ← the case being fixed
normal repo .git OK
normal repo root fail
plain empty dir fail
subdir inside a normal repo fail ← does not walk up
nonexistent path fail

That last row is the important one: --resolve-git-dir answers for the path given, so a plain folder that merely sits inside a repository is still a valid destination. Nesting remains is_inside_worktree's job, and a test now pins that boundary.

The .git check is kept alongside rather than replaced, so nothing refused before is accepted now.

The operand is always absolute (parent is fs::canonicalized before the join), so it cannot be read as a flag — noted in the doc comment since the project's shell-out rule would otherwise want a --, which --resolve-git-dir cannot take because it consumes the next argument.

Tests (TDD)

  • initialize_repository_rejects_an_existing_bare_repository — watched fail first, returning Ok(initialized: true, existing_entries: 7). Asserts the error and that no nested .git was created.
  • initialize_repository_still_accepts_a_plain_empty_folder — the over-reach guard. Passed before the change and after.

Both run with cwd inside a repository (cargo's working dir), so the "already inside a repo" case is exercised naturally.

Gates: cargo test full workspace green (git-core 185) · npm run check 524 files / 0 errors · npm test 330 passed.

🤖 Generated with Claude Code

`initialize_repository` guarded against an existing repository two ways, and a
bare one slipped between them: it has no `.git` child, and `rev-parse
--is-inside-work-tree` answers `false` inside it. The destination is non-empty,
so the user got the "folder is not empty" prompt rather than "already a Git
repository" — and on confirming, `git init` ran with the bare repo as its
working directory.

Verified against real git: it prints "Initialized empty Git repository in
.../bare.git/.git/" and creates a nested repository inside the bare one. The
bare repo's own HEAD, refs and core.bare survive untouched — `diff -rq` against
a backup shows only the added `.git` — so this is a wrong state rather than
data loss, but it is exactly what the sibling guard exists to prevent.

Probe the destination itself with `rev-parse --resolve-git-dir`. It answers for
the path GIVEN and does not walk up to a parent, so a plain folder that merely
sits inside a repository is still a valid destination — that case belongs to
is_inside_worktree, and a test now pins it. The `.git` check is kept alongside
rather than replaced, so nothing that was refused before is accepted now.

Reported by Codex review on #26; the code is not part of that PR — it came from
15e51c3 (2026-07-17) and git_ops.rs is untouched there — so it is fixed here.

cargo test full workspace green (git-core 185); npm run check 524 files
0 errors; npm test 330 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

💡 Codex Review

&parent
};
if is_inside_worktree(nesting_probe) {

P2 Badge Reject Git directories used as repository parents

When the selected parent is itself a bare repository or a normal repository's .git directory and the requested child does not exist, nesting_probe becomes parent, but --is-inside-work-tree returns false in Git directories. The flow consequently creates the new repository inside the existing repository's metadata directory; check is_git_dir(&parent) as well so these parent selections are rejected before filesystem mutation.


"vite": "^8.1.4",

P2 Badge Declare the Node runtime required by Vite 8

The upgrade to Vite 8 raises the runtime requirement to Node ^20.19.0 || >=22.12.0 (recorded in package-lock.json), while package.json has no engines field and the build-from-source instructions specify no Node version. Contributors following those instructions with Node 18 or early Node 20 can install with only engine warnings and then fail when running the documented build commands; declare the requirement or retain a compatible toolchain.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Two findings from Codex review on #29.

**Git directory as parent.** The same blind spot this PR fixed for the
destination, one level up. When the destination does not exist yet the nesting
probe falls back to the PARENT, and `--is-inside-work-tree` answers false
inside a Git directory just as it does inside a bare repo. Verified: selecting
`some-repo/.git` produced `some-repo/.git/proj`, a whole repository inside
another repository's metadata, and returned initialized: true. A bare repo as
parent did the same. `is_git_dir(&parent)` now rejects both before any
filesystem mutation, with a message that names the actual problem.

**Node floor.** Vite 8 requires `^20.19.0 || >=22.12.0` (recorded in
package-lock.json) but package.json declared no `engines` and the
build-from-source instructions named no version, so `npm install` on Node 18 or
early 20 warns and then fails later as a confusing build error. Declared in
`engines` and stated in the README next to the commands it governs. CI already
builds on 24, so this documents the existing requirement rather than changing
it. Unrelated to the rest of this PR, but fixing it here keeps the review on
one branch.

cargo test 186 + 7 passed; clippy clean; npm run check 524 files 0 errors;
npm test 330 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ashproto

ashproto commented Aug 9, 2026

Copy link
Copy Markdown
Owner Author

Both addressed in 4ddc2af.

Git directory as parent — confirmed, and it is the same blind spot this PR fixes, one level up. I guarded the destination; the parent has the identical hole. Probed both candidates:

normal/.git    --is-inside-work-tree=false   is_git_dir=true
bare.git       --is-inside-work-tree=false   is_git_dir=true

With the destination not yet existing the nesting probe falls back to the parent, which answers false, so the flow proceeded. Reproduced end-to-end through initialize_repository: selecting host/.git as the parent produced

InitializeRepositoryResult { path: ".../host/.git/proj", initialized: true, existing_entries: 0 }

— a whole repository inside another repository's metadata. is_git_dir(&parent) now rejects it before any filesystem mutation, with a message naming the real problem rather than reusing the nesting one. initialize_repository_rejects_a_git_directory_as_parent covers both shapes and asserts nothing was created; it was watched failing first.

Node floor — confirmed. node_modules/vite 8.1.5 in the lockfile declares {"node": "^20.19.0 || >=22.12.0"}, package.json had no engines, and the build-from-source section named no version. Declared in engines and stated in the README beside the commands it governs. CI already runs Node 24, so this documents the existing requirement rather than changing it.

That second one is unrelated to bare-repo detection; fixed here anyway so the review converges on one branch rather than spawning another PR.

cargo test 186 + 7 passed · clippy clean · npm run check 524 files / 0 errors · npm test 330 passed.

@chatgpt-codex-connector

Copy link
Copy Markdown

💡 Codex Review

window.addEventListener("contextmenu", suppressNativeContextMenu);

P2 Badge Preserve native menus outside custom context-menu targets

Because this window-level listener unconditionally calls preventDefault(), right-clicking editable controls such as commit-message fields, repository descriptions, search boxes, and settings inputs no longer exposes the native Cut/Copy/Paste menu. Only suppress the event when a custom app context menu is actually being opened, or exempt input, textarea, and contenteditable targets.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@ashproto

ashproto commented Aug 9, 2026

Copy link
Copy Markdown
Owner Author

Correct about the behaviour, but it is not this PR — and it is already fixed.

src/routes/+page.svelte is untouched by #29 (which changes git_ops.rs, package.json and README.md). The unconditional preventDefault() you flagged came from 15e51c3 (2026-07-17) and was the entire subject of #27, which took the same approach you suggest — exempting targets where text is selectable — and earned a 👍 before merging as 6856964.

You saw the old code because this branch was cut from next before #27 landed. I have merged next in (50d15f8), so the fix is now present here at +page.svelte:305:

const style = el && getComputedStyle(el);
if (style && (style.webkitUserSelect || style.userSelect) !== "none") return;
event.preventDefault();

It reads the computed user-select rather than listing element types, so it covers input/textarea/contenteditable and the app's wider allowlist (.diff-cell, .md, .body-msg, .sha, .selectable and their descendants), while the diff gutter keeps suppressing its own menu. Verified against the live stylesheet before merging.

Gate after the merge: cargo test 232 + 7 passed · clippy clean · npm run check 530 files / 0 errors · npm test 386 passed.

@chatgpt-codex-connector

Copy link
Copy Markdown

💡 Codex Review

git-it/src/lib/store.svelte.ts

Lines 1684 to 1688 in 50d15f8

// Switching to ANOTHER repo: deliberately KEEP the previous repo's graph,
// refs, status and working changes on screen until reloadGraph() swaps in
// the new repo's data. Clearing them here flashed the header branch chip,
// sidebar and status bar empty mid-switch (the reported flicker). A
// stale-load guard in reloadGraph() prevents cross-repo contamination.

P1 Badge Block ref actions while the new repository loads

On an A → B switch, repo becomes B while this deliberately retains A's graph and refs. The main graph is replaced with a skeleton, but Sidebar continues rendering the retained refs and its checkout, delete, and fast-forward handlers execute against the current appState.repo. During a slow reload, clicking an old row can therefore operate on a same-named branch in B, including deleting it; clear the repo-affiliated state or disable all sidebar ref actions while repoLoading is true.


if (!available) return;
if (manual) {
if (pendingAutomaticUpdate?.version === available.version) pendingAutomaticUpdate = null;

P2 Badge Clear queued automatic updates before every manual check

If an automatic beta update is queued while Settings is open, the user can switch to stable and run a manual check. When that check returns no update—or a different version—the backend pending-update slot is cleared or replaced, but this UI queue is retained because it is only cleared for the same version. Closing Settings then presents the stale beta prompt, whose Download action either fails with no pending update or downloads a version different from the one displayed; invalidate the queued automatic update whenever a manual check starts or completes.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

…rseded update

Two findings from Codex review on #29. Both are pre-existing on `next` and in
files this PR does not otherwise touch; fixed here to keep the review on one
branch.

**P1 — sidebar acting on the wrong repository.** A switch deliberately keeps the
previous repo's refs on screen until the reload lands, because clearing them
flashed the sidebar empty mid-switch. But `appState.repo` already points at the
new repo, so a command fired from a stale row runs against it. With a name both
repos have — `main`, `develop` — "Delete branch" deletes the wrong repo's
branch, and there is no reflog prompt in front of that.

Clearing the state would reintroduce the flicker the retention exists to
prevent, so the rows stay and the actions go inert: checkout, delete (also the
Linked Worktrees panel, which routes through the same handler), and the ref
context menu all refuse while `repoLoading`, which `reloadGraph` clears in a
`finally` guarded against a superseding switch. The context menu bails before it
is built, so its items are unreachable rather than merely guarded. Selection and
scrolling stay live — they read nothing and write nothing.

Also gated the detached-HEAD menu, which the report did not mention: "Create
branch here" would use the OLD repo's HEAD sha, and two clones of one project
share commit ids, so that can quietly succeed in the wrong repository instead of
erroring on an unknown sha.

**P2 — superseded automatic update still prompting.** A manual check replaces the
backend's pending-update slot, but the UI queue was only cleared on an exact
version match. A manual check finding nothing, or a different version, left the
old one queued; closing the overlay then presented a version the backend no
longer had — Download failed with "no pending update", or fetched something
other than what the dialog named. The queue is now dropped when a manual check
starts. Two regression tests, both watched failing first.

npm run check 530 files 0 errors; npm test 388 passed; cargo test 232 + 7;
clippy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ashproto

ashproto commented Aug 9, 2026

Copy link
Copy Markdown
Owner Author

Both fixed in 87a0114. Neither is in this PR's diff (still git_ops.rs, package.json, README.md) — they are pre-existing on next — but fixing them here keeps the review on one branch.

P1 — sidebar acting on the wrong repository ✅

Confirmed structurally: set repo assigns the new path while deliberately retaining the previous repo's refsDetailed/worktrees, and Sidebar has no repoLoading gate, so its handlers run against appState.repo. With a name both repos have — main, develop — Delete Branch hits the wrong repo, and nothing prompts a second time for that.

Of your two suggestions I took the second. Clearing the repo-affiliated state would reintroduce the exact flicker the retention exists to prevent (documented at the retention site: clearing "flashed the header branch chip, sidebar and status bar empty mid-switch"). So the rows stay and the actions go inert while repoLoading — which reloadGraph clears in a finally, guarded so a superseding switch does not clear it early.

Covered: checkout, delete (including the Linked Worktrees panel, which routes through the same confirmDeleteBranch), and the ref context menu — which bails before the menu is built, so Checkout/Fast-forward/Delete are unreachable rather than merely guarded. Selection and scrolling stay live; they read nothing and write nothing.

One you did not mention, same root cause: the detached-HEAD menu. "Create branch here" would use the old repo's HEAD sha — and two clones of one project share commit ids, so that can quietly succeed in the wrong repository rather than erroring on an unknown sha. Gated too.

P2 — superseded automatic update ✅

Confirmed, and both holes reproduce. Regression tests written first and watched fail:

× drops a queued automatic update when a manual check finds nothing
    expected "confirm" not to be called, but was called 1 time
× drops a queued automatic update when a manual check finds a different version
    expected "confirm" to be called once, but got 2 times

The queue is now dropped when a manual check starts, which covers both the nothing-found and different-version cases the exact-version match missed. The retry timer needs no cancelling — it re-reads the variable and stops on its own. The old same-version clear is removed as dead.

npm run check 530 files / 0 errors · npm test 388 passed · cargo test 232 + 7 · clippy clean.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 87a0114bb2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/git-core/src/git_ops.rs Outdated
…r itself

`is_git_dir` resolves an exact path, so the parent guard saw `repo/.git` and a
bare repo but walked straight past `repo/.git/hooks` and `bare.git/objects` —
which a folder picker reaches just as easily. `--is-inside-work-tree` is false
down there too, so nothing else caught it either, and the flow created a
repository inside another repository's object store. Reproduced: it returned
initialized: true with a path of `host/.git/hooks/proj`.

`--is-inside-git-dir` is documented as true anywhere below the repository
directory, which is the question a candidate parent actually has to answer:

  path                 resolve-git-dir   is-inside-git-dir
  normal (worktree)    false             false
  normal/.git          true              true
  normal/.git/hooks    false             true
  bare.git             true              true
  bare.git/objects     false             true
  plain (no repo)      false             error

The working-tree row is why this stays a separate probe from the nesting one: a
normal working directory must remain a valid parent. The error row is handled
the way `is_inside_worktree` already does — a non-zero exit counts as false.

`is_git_dir` still guards the DESTINATION, where the exact-path question is the
right one. The parent test now covers both tops and both descendants.

cargo test 232 + 7; clippy clean; npm run check 530 files 0 errors;
npm test 388 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ashproto
ashproto merged commit 90f6614 into next Aug 9, 2026
2 checks passed
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