fix(init): back-fill missing hop.json on idempotent re-init#27
Conversation
When git hop init was run in a bare-worktree-shaped repo that already
had the structure but was missing hop.json (legacy bare repos cloned
outside git hop, or repos whose hop.json was lost), the command
printed "Repository already initialized" and exited without writing
hop.json. Downstream commands (status, list, add) then refused to
treat the directory as a registered hub.
handleAlreadyInitializedWithFlags now back-fills a missing hop.json
from runtime git state before printing the idempotent summary:
- origin URL via 'git remote get-url origin' (best-effort; falls
back to <parent>/<dir> naming, matching registerAsIs);
- default branch via 'git symbolic-ref HEAD' (falls back to "main");
- branches by parsing 'git worktree list --porcelain' — the bare
entry and detached-HEAD worktrees are skipped; the short branch
name (refs/heads/ stripped) is used as the map key.
When invoked from a worktree child (structure WorktreeChild), the hub
root is derived from <cwd>/.git's gitdir pointer, not hop.FindProjectRoot,
because the latter aborts when its DetectRepoStructure walk hits an
intermediate hops/ directory that returns NotGit.
envPatterns defaults to ["dev","staging","qa"] so a backfilled hub
behaves identically to one created by CreateHub. Write failures emit
a "warning:" line to stderr but do not abort init — hooks installation
and the "Repository already initialized" summary still run.
Existing TestIdempotentRun_* tests passed g=nil because the function
didn't touch git state; they now pass a mocks.NewMockGit() with the
symbolic-ref response wired up.
There was a problem hiding this comment.
Pull request overview
This PR makes git hop init a recovery path for legacy/partially-initialized bare-worktree repos by back-filling a missing hop.json from runtime git state before printing the usual idempotent “already initialized” summary.
Changes:
- Add backfill logic to reconstruct and write
hop.jsonusinggit remote get-url origin,git symbolic-ref HEAD, andgit worktree list --porcelain. - Invoke the backfill step from
handleAlreadyInitializedWithFlags(warning-only on failure). - Add unit tests for the porcelain parser, backfill behavior, and hub-root resolution; update existing init flag tests to use a non-nil mock git interface.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| cmd/init.go | Calls the new backfill step during idempotent init runs to heal missing hop.json. |
| cmd/init_flags_test.go | Updates idempotent-init tests to pass a mock git interface (now required). |
| cmd/init_backfill.go | New helper implementation for parsing worktree porcelain, deriving hub root, and writing hop.json. |
| cmd/init_backfill_test.go | New tests covering parser behavior, backfill behavior, and hub-root resolution. |
Comments suppressed due to low confidence (2)
cmd/init_backfill_test.go:247
- The comment above TestResolveBackfillRoot says the WorktreeChild case "walks up via FindProjectRoot", but resolveBackfillRoot no longer calls FindProjectRoot—it parses the worktree's .git gitdir pointer via hubFromWorktreeChild. Please update this comment to reflect the current behavior to avoid misleading future changes.
// resolveBackfillRoot picks the path to back-fill given the cwd and the
// repo structure detected there. BareWorktreeRoot → cwd; WorktreeChild
// → walk up via FindProjectRoot. Anything else → ("", false).
func TestResolveBackfillRoot(t *testing.T) {
cmd/init_backfill_test.go:283
- The WorktreeChild test data uses a gitdir pointer of "/worktrees/", but elsewhere in the repo the worktree-child shape is typically "/.git/worktrees/" (e.g. cmd/init_idempotent_test.go). Add coverage for the ".git/worktrees" form (and ensure resolveBackfillRoot returns the hub root, not the .git directory) so the backfill logic matches real repos.
// A worktree child under <tmp>/repo/hops/main.
hopsMain := filepath.Join(repo, "hops", "main")
if err := fs.MkdirAll(hopsMain, 0755); err != nil {
t.Fatal(err)
}
if err := afero.WriteFile(fs, filepath.Join(hopsMain, ".git"), []byte("gitdir: "+filepath.Join(repo, "worktrees", "main")+"\n"), 0644); err != nil {
t.Fatal(err)
}
got, ok := resolveBackfillRoot(fs, hopsMain, config.WorktreeChild)
if !ok || got != repo {
t.Errorf("got (%q, %v), want (%q, true)", got, ok, repo)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // gitdir shape: <hub>/worktrees/<name>. Strip the last two segments. | ||
| dir := filepath.Dir(gitdir) // <hub>/worktrees | ||
| if filepath.Base(dir) != "worktrees" { | ||
| return "", false | ||
| } | ||
| return filepath.Dir(dir), true |
There was a problem hiding this comment.
Fixed in bab9f56. hubFromWorktreeChild now strips an optional trailing ".git" segment after locating the "worktrees" parent, so both bare-hub ("/worktrees/") and regular-hub ("/.git/worktrees/") shapes resolve to the hub root. Added a test for the regular-hub shape plus a malformed-gitdir negative case.
Skipped the relative-gitdir-path suggestion: git emits absolute paths by default, and the codebase has no precedent for handling --relative here. Happy to add it if you can point to a repro.
| }) | ||
| } | ||
|
|
||
| var _ = hop.ParseRepoFromURL |
There was a problem hiding this comment.
Fixed in bab9f56. Removed the dead var _ = hop.ParseRepoFromURL line and the now-unused hop import.
The "also appears at line 244, 272" claim looks hallucinated — there was only the one occurrence in this file.
The initial impl assumed the worktree's gitdir pointer always had the
shape "<hub>/worktrees/<name>", which holds for bare hubs (git hop's
own layout) but not for non-bare hubs. `git worktree add` from a
regular repo writes "<hub>/.git/worktrees/<name>" — feeding that
through filepath.Dir twice returned "<hub>/.git", and the back-fill
would have tried to write hop.json into the .git directory instead
of the hub root.
Strip an optional trailing ".git" segment after locating the
"worktrees" parent so both shapes resolve to the actual hub.
Also drop the dead `var _ = hop.ParseRepoFromURL` (leftover from an
earlier draft that was going to assert on it) and the now-unused hop
import. Update the TestResolveBackfillRoot docstring which still
referenced the old FindProjectRoot-based implementation.
New test cases:
- WorktreeChild with regular-hub shape "<hub>/.git/worktrees/<n>"
(regression coverage for the bug above).
- WorktreeChild with a gitdir whose parent isn't "worktrees" — must
return ok=false rather than guessing a hub from arbitrary paths.
Summary
git hop initwas a no-op when a bare-worktree-shaped repo had the structure but was missinghop.json(legacy bare repos cloned outsidegit hop, or repos whosehop.jsonwas lost). The command printed "Repository already initialized" and exited, leaving the directory un-registered as a hub.handleAlreadyInitializedWithFlagsnow back-fills a missinghop.jsonfrom runtime git state before printing the idempotent summary, so re-runninggit hop initbecomes a true recovery path.git remote get-url origin(best-effort, with<parent>/<dir>fallback matchingregisterAsIs); default branch viagit symbolic-ref HEAD(falls back tomain); branches by parsinggit worktree list --porcelain(bare/detached entries skipped; short branch name used as the map key).<cwd>/.git'sgitdir:pointer rather thanhop.FindProjectRoot, because the latter aborts when itsDetectRepoStructurewalk hits an intermediatehops/directory that returnsNotGit.Companion PR
fix(status): diagnose bare-worktree repo missing hop.json) — pairs with this change: status now points users atgit hop init, which then heals the repo.Test plan
go build ./...clean.go test ./internal/... ./cmd/...— 865 pass, 0 fail (8 new test cases acrosscmd/init_backfill_test.go, all written test-first:parseWorktreeListPorcelainparser shape,backfillHubConfigIfMissinghappy-path / no-op / no-remote fallback / no-default-branch fallback / error propagation, andresolveBackfillRootforBareWorktreeRoot/WorktreeRoot/WorktreeChild/ declined-on-StandardRepo).go vet ./cmd/clean.refactor/kit-12fcc-baseline-T-0119-20whose branch name has no suffix): removedhop.json, rangit hop initfrom the bare-repo root → printsCreated missing hop.json at <path>.and writes a valid config; subsequentgit hop statusshows the full hub table. Repeated from insidehops/main/(worktree-child) → also resolves the correct hub root via the gitdir pointer and writeshop.jsonthere.Notes
["dev","staging","qa"]so the back-filled hub matchesCreateHub's defaults.warning:line to stderr but do not abort init — hooks installation and the "Repository already initialized" summary still run.TestIdempotentRun_*tests previously passedg=nil; updated to passmocks.NewMockGit()since the function now touches git state on first invocation.