Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ go test -v -race -count=1 ./internal/git/...

| Package | Responsibility |
|---|---|
| `cmd/projector` | Cobra root + one file per subcommand (`projects.go`, `list.go`, `create.go`, `desc.go`, `open.go`, `path.go`, `addrepo.go`, `archive.go`, `restore.go`, `delete.go`, `version.go`, `config.go`, `config_run.go`, `config_list.go`, `config_get.go`, `config_set.go`, `config_unset.go`). No business logic — delegate to internal packages. |
| `cmd/projector` | Cobra root + one file per subcommand (`projects.go`, `list.go`, `create.go`, `desc.go`, `open.go`, `path.go`, `addrepo.go`, `archive.go`, `repair.go`, `restore.go`, `delete.go`, `version.go`, `config.go`, `config_run.go`, `config_list.go`, `config_get.go`, `config_set.go`, `config_unset.go`). No business logic — delegate to internal packages. |
| `internal/config` | `GlobalConfig` struct, `EditorConfig` struct, `Load`/`Save`/`ResolveBase`/`Validate`. TOML I/O for `~/.config/projector/config.toml` (honors `$XDG_CONFIG_HOME`); `Load` falls back to and copy-migrates the legacy `~/.projector/projector-config.toml`. |
| `internal/project` | `ProjectConfig` struct, `Load`/`Save`/`ListAll`/`FindProjectDir`/`ValidateName`/`DiscoverWorktrees`. TOML I/O for `<projects-dir>/<name>/.projector.toml`. |
| `internal/git` | Thin wrappers around the `git` executable: `RunGit`, `WorktreeAdd`, `WorktreeAddDetached`, `WorktreeRemove`, `WorktreeList`, `WorktreeForBranch`, `StatusPorcelain`, `RefExists`, `BranchExists`, `BranchCheckedOut`, `CurrentBranch`, `AvailableBranchName`, `BranchNameFromRef`, `Remotes`, `DefaultRemote`, `RemoteForRef`, `Fetch`, `FetchRef`, `HasUnpushedCommits`, `HeadSHA`, `MinVersionCheck`. |
| `internal/git` | Thin wrappers around the `git` executable: `RunGit`, `WorktreeAdd`, `WorktreeAddDetached`, `WorktreeRemove`, `WorktreeRepair`, `WorktreeList`, `WorktreeForBranch`, `StatusPorcelain`, `RefExists`, `BranchExists`, `BranchCheckedOut`, `CurrentBranch`, `AvailableBranchName`, `BranchNameFromRef`, `Remotes`, `DefaultRemote`, `RemoteForRef`, `Fetch`, `FetchRef`, `HasUnpushedCommits`, `HeadSHA`, `MinVersionCheck`. |
| `internal/repo` | `Repo` struct, `Discover` (non-recursive scan of search dirs), `ResolveRepos` (name or abs-path lookup). |
| `internal/tui` | `SelectRepos` (huh multi-select), `SelectEditor` (huh single-select, installed editors only), `EditorOption` (with `Terminal` field), `ExpandHome` (tilde expansion). |

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ pj project open my-feature
| `pj project path [project]` | Print the project directory path |
| `pj project add-repo [repos...]` | Add repos to an existing project |
| `pj project archive [project]` | Remove worktrees, keep branches |
| `pj project repair [project]` | Repair worktrees after a project is moved or renamed |
| `pj project restore [project]` | Recreate worktrees from an archived project |
| `pj project delete [project]` | Permanently delete a project |
| `pj config setup` | Interactive configuration wizard |
Expand Down
1 change: 1 addition & 0 deletions cmd/projector/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ func newProjectsCmd() *cobra.Command {
newPathCmd(),
newAddRepoCmd(),
newArchiveCmd(),
newRepairCmd(),
newRestoreCmd(),
newDeleteCmd(),
)
Expand Down
69 changes: 69 additions & 0 deletions cmd/projector/repair.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package main

import (
"fmt"

"github.com/spf13/cobra"

"github.com/kevdoran/projector/internal/git"
"github.com/kevdoran/projector/internal/project"
)

func newRepairCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "repair [project]",
Short: "Repair git worktrees after a project was moved or renamed",
Long: "Repair the git worktrees in a project. Moving or renaming a project " +
"directory leaves each worktree's gitdir/commondir pointers stale; this " +
"runs 'git worktree repair' for every worktree so git can find them again.",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cmd.SilenceUsage = true
cfg, err := loadConfig()
if err != nil {
return err
}

projectDir, p, err := resolveProject(cfg.ProjectsDir, args)
if err != nil {
return err
}

if p.Project.Status != project.StatusActive {
return fmt.Errorf("project %q is not active (status: %s); only active projects have live worktrees to repair", p.Project.Name, p.Project.Status)
}

worktrees, err := project.DiscoverWorktrees(projectDir)
if err != nil {
return fmt.Errorf("discover worktrees: %w", err)
}

if len(worktrees) == 0 {
fmt.Printf("Project %q has no worktrees to repair.\n", p.Project.Name)
return nil
}

var failed []string
for _, wt := range worktrees {
fmt.Printf(" Repairing worktree for %s...\n", wt.RepoName)
// Run repair from the underlying repo, passing the worktree path so
// git can locate it even when its directory was moved.
if err := git.WorktreeRepair(wt.RepoPath, wt.WorktreePath); err != nil {
failed = append(failed, wt.RepoName)
fmt.Printf(" failed: %v\n", err)
continue
}
fmt.Printf(" repaired: %s\n", wt.WorktreePath)
}

if len(failed) > 0 {
return fmt.Errorf("failed to repair %d worktree(s): %v", len(failed), failed)
}

fmt.Printf("Repaired %d worktree(s) in project %q.\n", len(worktrees), p.Project.Name)
return nil
},
}

return cmd
}
21 changes: 21 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,27 @@ pj project archive my-feature

The `.projector.toml` is updated to `status = "archived"` and worktree state is saved for future restore. **Uncommitted changes in any worktree will prevent archiving.**

### `pj project repair [project]`

Repair the git worktrees in an active project after the project directory has been moved or renamed. Moving or renaming a project leaves each worktree's internal `gitdir`/`commondir` pointers stale, so git can no longer resolve them. This runs `git worktree repair` for every worktree to fix those pointers.

```bash
pj project repair # detect from current directory
pj project repair my-feature
```

The command discovers each live worktree and repairs it, printing a per-worktree summary:

```
Repairing worktree for api...
repaired: /Users/alice/projects/my-feature/api
Repairing worktree for frontend...
repaired: /Users/alice/projects/my-feature/frontend
Repaired 2 worktree(s) in project "my-feature".
```

Only **active** projects have live worktrees to repair; archived projects are rejected. A project with no worktrees is reported and left untouched.

### `pj project restore [project]`

Restore an archived project by recreating all its git worktrees.
Expand Down
14 changes: 14 additions & 0 deletions internal/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,20 @@ func WorktreeRemove(repoPath, worktreePath string) error {
return nil
}

// WorktreeRepair re-establishes the administrative links between a repository
// and its worktrees by running `git worktree repair`. This fixes stale gitdir
// and commondir pointers after a repository or worktree has been moved or
// renamed. The optional worktreePaths are passed as arguments so git can repair
// worktrees whose location has changed (git needs the new path to find them).
func WorktreeRepair(repoPath string, worktreePaths ...string) error {
args := append([]string{"worktree", "repair"}, worktreePaths...)
_, err := RunGit(repoPath, args...)
if err != nil {
return fmt.Errorf("worktree repair: %w", err)
}
return nil
}

// WorktreeList returns the list of worktrees for the repository in porcelain format lines.
func WorktreeList(repoPath string) (string, error) {
out, err := RunGit(repoPath, "worktree", "list", "--porcelain")
Expand Down
189 changes: 186 additions & 3 deletions internal/git/git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,23 @@ import (
"github.com/kevdoran/projector/internal/git"
)

// createTestRepo initialises a git repo with an initial commit and returns its path.
// createTestRepo initialises a git repo with an initial commit in a fresh temp
// directory and returns its path.
func createTestRepo(t *testing.T) string {
t.Helper()
dir := t.TempDir()
makeRepoAt(t, dir)
return dir
}

// makeRepoAt initialises a git repo with an initial commit at the given path,
// creating the directory if needed. Useful when the repo must live at a
// specific location (e.g. under a parent directory that will be moved).
func makeRepoAt(t *testing.T, dir string) {
t.Helper()
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("mkdir repo: %v", err)
}

run := func(args ...string) {
t.Helper()
Expand All @@ -35,8 +48,6 @@ func createTestRepo(t *testing.T) string {
}
run("add", "README.md")
run("commit", "-m", "initial commit")

return dir
}

func TestRunGit_Success(t *testing.T) {
Expand Down Expand Up @@ -109,6 +120,178 @@ func TestWorktreeAdd_ExistingBranch(t *testing.T) {
}
}

func TestWorktreeRepair(t *testing.T) {
// This exercises the git wrapper directly against a "repo and worktree moved
// together" scenario: a single parent directory holds BOTH the repo and the
// worktree, and renaming the parent breaks both pointers. The test then passes
// the post-move repo path straight to WorktreeRepair.
//
// Note this is NOT the path the `pj project repair` command takes — the command
// discovers the repo from the worktree's still-valid .git file and never sees a
// repo that has itself moved. For a faithful reproduction of pj's common layout
// (external repo, only the project dir renamed) see
// TestWorktreeRepair_RenamedProjectDir below.
//
// Simulate a project that is moved/renamed: a parent directory containing both
// the repo and a worktree. Renaming the parent leaves both git pointers stale
// (the worktree's .git file points at the old repo path, and the repo's
// administrative gitdir points at the old worktree path).
root := t.TempDir()
oldDir := filepath.Join(root, "project-old")
if err := os.Mkdir(oldDir, 0755); err != nil {
t.Fatalf("mkdir project dir: %v", err)
}

repo := filepath.Join(oldDir, "repo")
makeRepoAt(t, repo)

wt := filepath.Join(oldDir, "wt")
if err := git.WorktreeAdd(repo, wt, "HEAD", "repair-branch", true); err != nil {
t.Fatalf("WorktreeAdd: %v", err)
}

// Sanity check: worktree is healthy before the move.
if _, err := git.RunGit(wt, "status"); err != nil {
t.Fatalf("worktree should be healthy before move: %v", err)
}

// Rename the whole project directory.
newDir := filepath.Join(root, "project-new")
if err := os.Rename(oldDir, newDir); err != nil {
t.Fatalf("rename project dir: %v", err)
}
newRepo := filepath.Join(newDir, "repo")
newWt := filepath.Join(newDir, "wt")

// The worktree is now broken: its .git file still points at the old repo path.
if _, err := git.RunGit(newWt, "status"); err == nil {
t.Fatal("expected git status to fail in moved worktree before repair")
}

// Repair from the (moved) repo, passing the new worktree path so git can find it.
if err := git.WorktreeRepair(newRepo, newWt); err != nil {
t.Fatalf("WorktreeRepair: %v", err)
}

// After repair the worktree resolves again.
if _, err := git.RunGit(newWt, "status"); err != nil {
t.Fatalf("worktree should be healthy after repair: %v", err)
}

// git worktree list should reference the new path, not the old one.
out, err := git.WorktreeList(newRepo)
if err != nil {
t.Fatalf("WorktreeList: %v", err)
}
if !strings.Contains(out, newWt) {
t.Fatalf("worktree list does not reference new path %q:\n%s", newWt, out)
}
if strings.Contains(out, wt) {
t.Fatalf("worktree list still references old path %q:\n%s", wt, out)
}
}

// TestWorktreeRepair_RenamedProjectDir reproduces pj's common layout and the
// exact breakage the `pj project repair` command fixes:
//
// - The git repo lives EXTERNALLY (in a repo-search-dir), outside the project dir.
// - A worktree is created under a separate "project dir".
// - Only the PROJECT DIR is renamed; the external repo stays put.
//
// Because the external repo did not move, the worktree's own .git file (an
// absolute path to the external repo) stays valid, so `git -C <worktree> status`
// keeps working. But the repo's administrative pointer
// (.git/worktrees/<id>/gitdir) goes stale, so `git worktree list` from the repo
// marks the worktree as `prunable` and still points at the OLD path.
//
// `git worktree repair <newWorktreePath>` run from the external repo — exactly
// what the command does (anchored at wt.RepoPath with the new worktree path as an
// arg) — fixes the stale admin pointer.
func TestWorktreeRepair_RenamedProjectDir(t *testing.T) {
// External repo, outside the project dir.
repo := createTestRepo(t)

// Project dir holding the worktree, in a separate location.
projectDir := filepath.Join(t.TempDir(), "project")
if err := os.MkdirAll(projectDir, 0755); err != nil {
t.Fatalf("mkdir project dir: %v", err)
}
wt := filepath.Join(projectDir, "myrepo")
if err := git.WorktreeAdd(repo, wt, "HEAD", "feature-branch", true); err != nil {
t.Fatalf("WorktreeAdd: %v", err)
}

// Sanity: worktree healthy and listed at its current path before the move.
if _, err := git.RunGit(wt, "status"); err != nil {
t.Fatalf("worktree should be healthy before move: %v", err)
}

// Rename ONLY the project dir; the external repo stays put.
projectDirRenamed := filepath.Join(filepath.Dir(projectDir), "project-renamed")
if err := os.Rename(projectDir, projectDirRenamed); err != nil {
t.Fatalf("rename project dir: %v", err)
}
newWt := filepath.Join(projectDirRenamed, "myrepo")

// The worktree's own .git file still points at the (unmoved) external repo, so
// status from the new path keeps working — this is what masks the problem from
// the worktree's perspective.
if _, err := git.RunGit(newWt, "status"); err != nil {
t.Fatalf("worktree status from new path should still work (external repo did not move): %v", err)
}

// Assert the breakage: the repo's admin pointer is stale, so worktree list
// marks the worktree prunable and still references the OLD path.
before, err := git.RunGit(repo, "worktree", "list", "--porcelain")
if err != nil {
t.Fatalf("worktree list (before repair): %v", err)
}
if !strings.Contains(before, "prunable") {
t.Fatalf("expected stale worktree to be marked prunable before repair:\n%s", before)
}
if !strings.Contains(before, wt) {
t.Fatalf("expected worktree list to still reference old path %q before repair:\n%s", wt, before)
}
if strings.Contains(before, newWt) {
t.Fatalf("did not expect worktree list to reference new path %q before repair:\n%s", newWt, before)
}

// Repair from the external repo, passing the new worktree path — exactly what
// the command does.
if err := git.WorktreeRepair(repo, newWt); err != nil {
t.Fatalf("WorktreeRepair: %v", err)
}

// Assert the fix: the admin pointer now references the new path and is no
// longer prunable.
after, err := git.RunGit(repo, "worktree", "list", "--porcelain")
if err != nil {
t.Fatalf("worktree list (after repair): %v", err)
}
if strings.Contains(after, "prunable") {
t.Fatalf("worktree should not be prunable after repair:\n%s", after)
}
if !strings.Contains(after, newWt) {
t.Fatalf("worktree list should reference new path %q after repair:\n%s", newWt, after)
}
if strings.Contains(after, wt) {
t.Fatalf("worktree list should not reference old path %q after repair:\n%s", wt, after)
}

// And the worktree itself is still healthy.
if _, err := git.RunGit(newWt, "status"); err != nil {
t.Fatalf("worktree should be healthy after repair: %v", err)
}
}

func TestWorktreeRepair_NoWorktrees(t *testing.T) {
repo := createTestRepo(t)
// Repairing a repo with no extra worktrees should be a no-op, not an error.
if err := git.WorktreeRepair(repo); err != nil {
t.Fatalf("WorktreeRepair on repo with no worktrees: %v", err)
}
}

func TestStatusPorcelain_Clean(t *testing.T) {
repo := createTestRepo(t)
clean, lines, err := git.StatusPorcelain(repo)
Expand Down
7 changes: 7 additions & 0 deletions pj-skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,13 @@ Fails if any worktree has uncommitted changes. Saves worktree state for restore.
pj project restore my-feature
```

### Repair worktrees after moving or renaming a project
If a project directory is moved or renamed, its worktrees' internal git pointers go stale and git operations inside them fail. Repair fixes them with `git worktree repair`.
```bash
pj project repair my-feature
```
Only works on active projects. Safe to run anytime; a no-op when nothing needs fixing.

### Delete a project (irreversible — only when explicitly requested)
**Do not run `pj project delete` unless the user explicitly asks to delete the project.** Prefer `pj project archive` for all routine cleanup — it is reversible and the safe default.

Expand Down
Loading