Keep worktrees git could not read, never delete them
Any git error on a directory under the worktree root was classified orphan, and orphan deletes the directory outright, so a transient failure reading the source root became data loss on a plain `agentws prune --yes`. - prove a backing repo gone by stat before calling a directory an orphan - classify an unexplained git failure as keep, naming the error - refuse to remove a worktree whose git state is unknown, even with --include-keep - spell out that --include-keep discards uncommitted and in-progress work
This commit is contained in:
+69
-6
@@ -231,8 +231,11 @@ func newListCmd() *cobra.Command {
|
||||
}
|
||||
for _, w := range managed {
|
||||
branch := w.branch
|
||||
if w.orphan {
|
||||
switch {
|
||||
case w.orphan:
|
||||
branch = "(orphan)"
|
||||
case w.inspectErr != nil:
|
||||
branch = "(unreadable)"
|
||||
}
|
||||
_, _ = fmt.Fprintf(out, "%s\t%s\t%s\n", w.repo, branch, w.path)
|
||||
}
|
||||
@@ -258,15 +261,20 @@ type managedWt struct {
|
||||
// missing is a registration whose working tree is gone: nothing to inspect,
|
||||
// nothing to lose.
|
||||
missing bool
|
||||
// orphan is a directory under the worktree root whose backing repo no longer
|
||||
// resolves, so no git state can be read from it at all.
|
||||
// orphan is a directory under the worktree root whose backing git dir is
|
||||
// proven gone, so no git state can be read from it ever again.
|
||||
orphan bool
|
||||
// inspectErr is set when git refused to answer for a checkout and the reason
|
||||
// was not a proven-absent backing repo. The state is unknown, never removable.
|
||||
inspectErr error
|
||||
}
|
||||
|
||||
// managedWorktrees scans the worktree root and resolves each entry's repo and
|
||||
// branch from git so branch names are accurate (not the sanitized dir name).
|
||||
// Directories whose backing repo no longer resolves are returned as orphans
|
||||
// rather than dropped, so callers can see (and clean up) the leftovers.
|
||||
// Directories whose backing repo is proven gone are returned as orphans rather
|
||||
// than dropped, so callers can see (and clean up) the leftovers; a directory git
|
||||
// merely failed to answer for is returned with its error instead, because an
|
||||
// unread state must never be mistaken for a dead one.
|
||||
func managedWorktrees() ([]managedWt, error) {
|
||||
wr, err := worktreeRoot()
|
||||
if err != nil {
|
||||
@@ -291,7 +299,15 @@ func managedWorktrees() ([]managedWt, error) {
|
||||
branch, branchErr := agent.GitCurrentBranch(path)
|
||||
srcDir, srcErr := agent.SourceRepoDir(path)
|
||||
if branchErr != nil || srcErr != nil {
|
||||
out = append(out, managedWt{repo: repoFromDirName(e.Name()), path: path, managed: true, orphan: true})
|
||||
entry := managedWt{repo: repoFromDirName(e.Name()), path: path, managed: true}
|
||||
if gone, err := backingRepoGone(path); err == nil && gone {
|
||||
entry.orphan = true
|
||||
} else if branchErr != nil {
|
||||
entry.inspectErr = branchErr
|
||||
} else {
|
||||
entry.inspectErr = srcErr
|
||||
}
|
||||
out = append(out, entry)
|
||||
continue
|
||||
}
|
||||
out = append(out, managedWt{
|
||||
@@ -306,6 +322,49 @@ func managedWorktrees() ([]managedWt, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// backingRepoGone proves, by stat alone, that a worktree directory's backing
|
||||
// repo no longer exists: its .git file names a git dir that is absent, and the
|
||||
// repo's shared .git the git dir lived in is absent too. Only that pair licenses
|
||||
// deleting the directory. Every other outcome — an unreadable .git file, a git
|
||||
// dir still on disk, a stat that failed for any reason other than "not there",
|
||||
// or a mere lost registration in a repo that is still present — reports false,
|
||||
// so a transient or unexplained failure can never be read as "safe to delete".
|
||||
func backingRepoGone(path string) (bool, error) {
|
||||
dot := filepath.Join(path, ".git")
|
||||
info, err := os.Lstat(dot)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return false, nil // a standalone checkout, not a linked worktree
|
||||
}
|
||||
data, err := os.ReadFile(dot)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
rest, ok := strings.CutPrefix(strings.TrimSpace(string(data)), "gitdir:")
|
||||
if !ok {
|
||||
return false, fmt.Errorf("%s: not a worktree gitdir pointer", dot)
|
||||
}
|
||||
gitDir := strings.TrimSpace(rest)
|
||||
if gitDir == "" {
|
||||
return false, fmt.Errorf("%s: empty gitdir", dot)
|
||||
}
|
||||
if !filepath.IsAbs(gitDir) {
|
||||
gitDir = filepath.Join(path, gitDir)
|
||||
}
|
||||
// The git dir is "<repo>/.git/worktrees/<name>"; both it and the shared .git
|
||||
// it sits in must be absent before the repo counts as gone.
|
||||
for _, dir := range []string{gitDir, filepath.Dir(filepath.Dir(gitDir))} {
|
||||
if _, err := os.Stat(dir); err == nil {
|
||||
return false, nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// repoFromDirName recovers the repo name from the "<repo>__<branch>" layout used
|
||||
// under the worktree root, for entries git can no longer answer for.
|
||||
func repoFromDirName(name string) string {
|
||||
@@ -509,6 +568,10 @@ func resolveWorktree(target string) (managedWt, error) {
|
||||
// proved the commits survive elsewhere may set it.
|
||||
func removeWorktree(out io.Writer, wt managedWt, deleteBranch, forceBranch bool) error {
|
||||
switch {
|
||||
case wt.inspectErr != nil:
|
||||
// No srcDir to act through and no idea what is in there; --include-keep
|
||||
// must not turn that into a delete.
|
||||
return fmt.Errorf("refusing to remove %s: git state unreadable: %w", wt.path, wt.inspectErr)
|
||||
case wt.orphan:
|
||||
return removeOrphanDir(out, wt)
|
||||
case wt.missing:
|
||||
|
||||
Reference in New Issue
Block a user