From 40a0babe0ac20ed2cbe15cddaf14da5146f46d65 Mon Sep 17 00:00:00 2001 From: Bailey Pumfleet Date: Tue, 27 Oct 2026 11:03:52 -0500 Subject: [PATCH 1/9] fix(box): worktrees with submodules can be removed when nothing would be lost git refuses to remove any worktree with a submodule checked out, however clean, so removing or archiving one always failed. On that refusal, RemoveWorktree now checks for the work git won't (changes in the worktree or its submodules, submodule commits no remote has) and forces the removal only when there is none. Otherwise it answers 409 in git's words, so the app keeps the worktree and offers Remove anyway. Co-Authored-By: Claude Opus 5.5 --- app/src/lib/remove-worktree.ts | 7 ++-- docs/changelog.mdx | 7 ++++ internal/box/locations.go | 34 +++++++++++++++++++- internal/box/locations_test.go | 59 ++++++++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 3 deletions(-) diff --git a/app/src/lib/remove-worktree.ts b/app/src/lib/remove-worktree.ts index c81622336..0cfe718f9 100644 --- a/app/src/lib/remove-worktree.ts +++ b/app/src/lib/remove-worktree.ts @@ -72,11 +72,14 @@ async function drain(box: string) { } catch (err) { clearRemoving(box, wt.path); // git's own words for work it would lose. - if (!force && /modified|untracked|uncommitted|contains/i.test(errorMessage(err))) { + const msg = errorMessage(err); + if (!force && /modified|untracked|uncommitted|contains/i.test(msg)) { toastManager.add({ type: "warning", title: `Kept ${wt.name}`, - description: "It has uncommitted changes, so git left it as it is.", + description: /submodule commits/i.test(msg) + ? "A submodule in it has commits no remote has, so it was left as it is." + : "It has uncommitted changes, so git left it as it is.", actionProps: { children: "Remove anyway", onClick: () => queueRemoval(box, location, wt, { force: true }) }, }); } else toastError(err, { title: `Couldn't remove ${wt.name}`, box }); diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 4b2d489d1..310b6586b 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -11,6 +11,13 @@ again. ## Unreleased +- **Worktrees with submodules can be removed.** git refuses to remove any + worktree with a submodule checked out, even a clean one, so removing or + archiving one failed. Shipyard now checks the worktree and its submodules + itself and removes it when nothing would be lost. If a submodule has + changes, or commits that aren't pushed anywhere, the worktree is kept and + you can still choose **Remove anyway**. + - **Pin worktrees to the top of the sidebar.** Right-click a worktree, or open its ⋯ menu, and choose **Pin to top**. Pinned worktrees sit in their own list above your projects, in the order you pinned them, whether or not diff --git a/internal/box/locations.go b/internal/box/locations.go index c9367126a..7c8c82140 100644 --- a/internal/box/locations.go +++ b/internal/box/locations.go @@ -417,7 +417,17 @@ func (l *Locations) RemoveWorktree(ctx context.Context, location, name string, f if force { args = append(args, "--force") } - if out, err := git(ctx, args...); err != nil { + out, err := git(ctx, args...) + // git refuses any worktree with a submodule checked out, however + // clean, and only --force gets past it. Look for the work git won't, + // and force only when there is none to lose. + if err != nil && !force && strings.Contains(string(out), "containing submodules") { + if why := unsavedWork(ctx, w.Path); why != "" { + return httpError{status: http.StatusConflict, msg: fmt.Sprintf("git worktree remove: '%s' %s, use --force to delete it", w.Path, why)} + } + out, err = git(ctx, append(args, "--force")...) + } + if err != nil { return fmt.Errorf("git worktree remove: %s", strings.TrimSpace(string(out))) } // A worktree made again under the same name starts without a title. @@ -549,6 +559,28 @@ func dirtyWorktree(dir string) bool { return err == nil && strings.TrimSpace(string(out)) != "" } +// unsavedWork says what removing a worktree with submodules would lose, or +// "" for nothing: changes in it or its submodules, or submodule commits no +// remote has, since a worktree's submodules keep their history inside it. +// When git can't tell, that counts as something to lose. +func unsavedWork(ctx context.Context, dir string) string { + out, err := git(ctx, "-C", dir, "status", "--porcelain", "--ignore-submodules=none") + if err != nil { + return "could not be checked for changes" + } + if strings.TrimSpace(string(out)) != "" { + return "contains modified or untracked files" + } + out, err = git(ctx, "-C", dir, "submodule", "foreach", "--quiet", "--recursive", "git rev-list -1 HEAD --branches --not --remotes") + if err != nil { + return "has submodules that could not be checked for unpushed commits" + } + if strings.TrimSpace(string(out)) != "" { + return "contains submodule commits that no remote has" + } + return "" +} + func expandHome(path string) string { if path == "~" || strings.HasPrefix(path, "~/") { if home, err := os.UserHomeDir(); err == nil { diff --git a/internal/box/locations_test.go b/internal/box/locations_test.go index 5898deecb..17610b66b 100644 --- a/internal/box/locations_test.go +++ b/internal/box/locations_test.go @@ -215,6 +215,65 @@ func TestAWorktreeSomeoneLockedIsLeftAlone(t *testing.T) { } } +func TestAWorktreeWithSubmodulesIsRemovedWhenNothingWouldBeLost(t *testing.T) { + repo := gitRepo(t) + ctx := context.Background() + run := func(dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-c", "protocol.file.allow=always"}, args...)...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %s", args, out) + } + } + lib := filepath.Join(filepath.Dir(repo), "lib") + os.MkdirAll(lib, 0o755) + run(lib, "init", "-q", "-b", "main") + run(lib, "commit", "-q", "--allow-empty", "-m", "lib") + run(repo, "submodule", "add", "-q", lib, "lib") + run(repo, "commit", "-q", "-m", "add lib") + + l := NewLocations(filepath.Join(t.TempDir(), "locations.json")) + if _, err := l.Add(ctx, "cal", repo); err != nil { + t.Fatal(err) + } + wt, err := l.CreateWorktree(ctx, "cal", "vendor", "vendor", "main") + if err != nil { + t.Fatal(err) + } + run(wt.Path, "submodule", "update", "-q", "--init") + sub := filepath.Join(wt.Path, "lib") + + kept := func(what, want string) { + t.Helper() + err := l.RemoveWorktree(ctx, "cal", "vendor", false) + if err == nil || !strings.Contains(err.Error(), want) || statusFor(err) != 409 { + t.Fatalf("%s: err = %v (status %d), want a 409 saying %q", what, err, statusFor(err), want) + } + if _, err := os.Stat(wt.Path); err != nil { + t.Fatalf("%s: the worktree went: %v", what, err) + } + } + os.WriteFile(filepath.Join(sub, "notes"), []byte("wip"), 0o644) + kept("a file in the submodule", "modified or untracked") + os.Remove(filepath.Join(sub, "notes")) + run(sub, "commit", "-q", "--allow-empty", "-m", "local only") + kept("a submodule commit, uncommitted in the worktree", "modified or untracked") + // Committed in the worktree, git status is clean, but the submodule's + // commit is only in the worktree's own copy of its history. + run(wt.Path, "commit", "-q", "-am", "bump lib") + kept("a submodule commit no remote has", "submodule commits that no remote has") + run(sub, "push", "-q", "origin", "HEAD:refs/heads/shared") + + if err := l.RemoveWorktree(ctx, "cal", "vendor", false); err != nil { + t.Fatalf("a clean worktree with a submodule: %v", err) + } + if _, err := os.Stat(wt.Path); !os.IsNotExist(err) { + t.Fatalf("worktree folder still there: %v", err) + } +} + func TestAFailedAddLeavesNothingBehind(t *testing.T) { repo := gitRepo(t) ctx := context.Background() From 37d7cfcdda15947f3e51aad1eef55793f0988b5c Mon Sep 17 00:00:00 2001 From: Bailey Pumfleet Date: Tue, 27 Oct 2026 11:19:56 -0500 Subject: [PATCH 2/9] fix(box): removing a worktree with submodules keeps untracked files and stashes Address Codex review (round 1): - the safety check counts untracked files even when status.showUntrackedFiles hides them, in the worktree and in each submodule (each submodule is now asked itself, so its own config can't hide a change) - a submodule stash counts as work to keep, since it lives only in the worktree's copy of the submodule's history Co-Authored-By: Claude Opus 5.5 --- app/src/lib/remove-worktree.ts | 4 ++-- docs/changelog.mdx | 4 ++-- internal/box/locations.go | 18 +++++++++++------- internal/box/locations_test.go | 15 ++++++++++++++- 4 files changed, 29 insertions(+), 12 deletions(-) diff --git a/app/src/lib/remove-worktree.ts b/app/src/lib/remove-worktree.ts index 0cfe718f9..1e92feb68 100644 --- a/app/src/lib/remove-worktree.ts +++ b/app/src/lib/remove-worktree.ts @@ -77,8 +77,8 @@ async function drain(box: string) { toastManager.add({ type: "warning", title: `Kept ${wt.name}`, - description: /submodule commits/i.test(msg) - ? "A submodule in it has commits no remote has, so it was left as it is." + description: /submodule changes/i.test(msg) + ? "A submodule in it has changes, commits or stashes no remote has, so it was left as it is." : "It has uncommitted changes, so git left it as it is.", actionProps: { children: "Remove anyway", onClick: () => queueRemoval(box, location, wt, { force: true }) }, }); diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 310b6586b..1618d86e7 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -15,8 +15,8 @@ again. worktree with a submodule checked out, even a clean one, so removing or archiving one failed. Shipyard now checks the worktree and its submodules itself and removes it when nothing would be lost. If a submodule has - changes, or commits that aren't pushed anywhere, the worktree is kept and - you can still choose **Remove anyway**. + changes, stashes, or commits that aren't pushed anywhere, the worktree is + kept and you can still choose **Remove anyway**. - **Pin worktrees to the top of the sidebar.** Right-click a worktree, or open its ⋯ menu, and choose **Pin to top**. Pinned worktrees sit in their diff --git a/internal/box/locations.go b/internal/box/locations.go index 7c8c82140..8c0bbf2c9 100644 --- a/internal/box/locations.go +++ b/internal/box/locations.go @@ -560,23 +560,27 @@ func dirtyWorktree(dir string) bool { } // unsavedWork says what removing a worktree with submodules would lose, or -// "" for nothing: changes in it or its submodules, or submodule commits no -// remote has, since a worktree's submodules keep their history inside it. -// When git can't tell, that counts as something to lose. +// "" for nothing: changes in it or its submodules, or submodule commits or +// stashes no remote has, since a worktree's submodules keep their history +// inside it. Untracked files count whatever status.showUntrackedFiles says, +// and when git can't tell, that counts as something to lose. func unsavedWork(ctx context.Context, dir string) string { - out, err := git(ctx, "-C", dir, "status", "--porcelain", "--ignore-submodules=none") + out, err := git(ctx, "-C", dir, "status", "--porcelain", "--untracked-files=normal", "--ignore-submodules=none") if err != nil { return "could not be checked for changes" } if strings.TrimSpace(string(out)) != "" { return "contains modified or untracked files" } - out, err = git(ctx, "-C", dir, "submodule", "foreach", "--quiet", "--recursive", "git rev-list -1 HEAD --branches --not --remotes") + // Each submodule is asked itself too, so its own config can't hide a + // change from the status above. + out, err = git(ctx, "-C", dir, "submodule", "foreach", "--quiet", "--recursive", + "git status --porcelain --untracked-files=normal --ignore-submodules=all && git rev-list -1 HEAD --branches --not --remotes && { git rev-parse -q --verify refs/stash || :; }") if err != nil { - return "has submodules that could not be checked for unpushed commits" + return "has submodules that could not be checked for unpushed work" } if strings.TrimSpace(string(out)) != "" { - return "contains submodule commits that no remote has" + return "contains submodule changes, commits or stashes that no remote has" } return "" } diff --git a/internal/box/locations_test.go b/internal/box/locations_test.go index 17610b66b..1c0cf33a9 100644 --- a/internal/box/locations_test.go +++ b/internal/box/locations_test.go @@ -257,13 +257,26 @@ func TestAWorktreeWithSubmodulesIsRemovedWhenNothingWouldBeLost(t *testing.T) { } os.WriteFile(filepath.Join(sub, "notes"), []byte("wip"), 0o644) kept("a file in the submodule", "modified or untracked") + // Config that hides untracked files from status doesn't hide them here. + run(wt.Path, "config", "status.showUntrackedFiles", "no") + run(sub, "config", "status.showUntrackedFiles", "no") + kept("a file in the submodule, hidden from status", "submodule changes") + os.WriteFile(filepath.Join(wt.Path, "todo"), []byte("wip"), 0o644) os.Remove(filepath.Join(sub, "notes")) + kept("a file in the worktree, hidden from status", "modified or untracked") + os.Remove(filepath.Join(wt.Path, "todo")) + + os.WriteFile(filepath.Join(sub, "notes"), []byte("wip"), 0o644) + run(sub, "stash", "push", "-q", "-u") + kept("a stash in the submodule", "stashes that no remote has") + run(sub, "stash", "drop", "-q") + run(sub, "commit", "-q", "--allow-empty", "-m", "local only") kept("a submodule commit, uncommitted in the worktree", "modified or untracked") // Committed in the worktree, git status is clean, but the submodule's // commit is only in the worktree's own copy of its history. run(wt.Path, "commit", "-q", "-am", "bump lib") - kept("a submodule commit no remote has", "submodule commits that no remote has") + kept("a submodule commit no remote has", "commits or stashes that no remote has") run(sub, "push", "-q", "origin", "HEAD:refs/heads/shared") if err := l.RemoveWorktree(ctx, "cal", "vendor", false); err != nil { From 1e80b296f66150b72be48e89aa068277399fe5b2 Mon Sep 17 00:00:00 2001 From: Bailey Pumfleet Date: Tue, 27 Oct 2026 11:40:20 -0500 Subject: [PATCH 3/9] fix(box): removing a worktree with submodules keeps tagged commits and deinitialized submodules' history Address Codex review (round 2): - every submodule repository under the worktree's git folder is checked, not only checked-out ones: a deinit leaves the repository (and its branches) behind, and the removal would delete it - each one is checked against all its local refs (branches, tags, stashes, HEAD), so a commit only a local tag holds counts as work to keep Co-Authored-By: Claude Opus 5.5 --- app/src/lib/remove-worktree.ts | 4 +- docs/changelog.mdx | 4 +- internal/box/locations.go | 68 +++++++++++++++++++++++++++++----- internal/box/locations_test.go | 25 ++++++++++++- 4 files changed, 86 insertions(+), 15 deletions(-) diff --git a/app/src/lib/remove-worktree.ts b/app/src/lib/remove-worktree.ts index 1e92feb68..dce363748 100644 --- a/app/src/lib/remove-worktree.ts +++ b/app/src/lib/remove-worktree.ts @@ -77,8 +77,8 @@ async function drain(box: string) { toastManager.add({ type: "warning", title: `Kept ${wt.name}`, - description: /submodule changes/i.test(msg) - ? "A submodule in it has changes, commits or stashes no remote has, so it was left as it is." + description: /submodule commits/i.test(msg) + ? "A submodule in it has commits, tags or stashes no remote has, so it was left as it is." : "It has uncommitted changes, so git left it as it is.", actionProps: { children: "Remove anyway", onClick: () => queueRemoval(box, location, wt, { force: true }) }, }); diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 1618d86e7..a0bf5b10f 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -15,8 +15,8 @@ again. worktree with a submodule checked out, even a clean one, so removing or archiving one failed. Shipyard now checks the worktree and its submodules itself and removes it when nothing would be lost. If a submodule has - changes, stashes, or commits that aren't pushed anywhere, the worktree is - kept and you can still choose **Remove anyway**. + changes, or commits, tags or stashes that aren't pushed anywhere, the + worktree is kept and you can still choose **Remove anyway**. - **Pin worktrees to the top of the sidebar.** Right-click a worktree, or open its ⋯ menu, and choose **Pin to top**. Pinned worktrees sit in their diff --git a/internal/box/locations.go b/internal/box/locations.go index 8c0bbf2c9..c2bcd8101 100644 --- a/internal/box/locations.go +++ b/internal/box/locations.go @@ -560,10 +560,11 @@ func dirtyWorktree(dir string) bool { } // unsavedWork says what removing a worktree with submodules would lose, or -// "" for nothing: changes in it or its submodules, or submodule commits or -// stashes no remote has, since a worktree's submodules keep their history -// inside it. Untracked files count whatever status.showUntrackedFiles says, -// and when git can't tell, that counts as something to lose. +// "" for nothing: changes in it or its submodules, or anything in a +// submodule's history no remote has (commits, tags, stashes), since a +// worktree keeps its submodules' repositories inside its own git folder. +// Untracked files count whatever status.showUntrackedFiles says, and when +// git can't tell, that counts as something to lose. func unsavedWork(ctx context.Context, dir string) string { out, err := git(ctx, "-C", dir, "status", "--porcelain", "--untracked-files=normal", "--ignore-submodules=none") if err != nil { @@ -572,19 +573,68 @@ func unsavedWork(ctx context.Context, dir string) string { if strings.TrimSpace(string(out)) != "" { return "contains modified or untracked files" } - // Each submodule is asked itself too, so its own config can't hide a - // change from the status above. + // Each checked-out submodule is asked itself too, so its own config + // can't hide a change from the status above. out, err = git(ctx, "-C", dir, "submodule", "foreach", "--quiet", "--recursive", - "git status --porcelain --untracked-files=normal --ignore-submodules=all && git rev-list -1 HEAD --branches --not --remotes && { git rev-parse -q --verify refs/stash || :; }") + "git status --porcelain --untracked-files=normal --ignore-submodules=all") if err != nil { - return "has submodules that could not be checked for unpushed work" + return "has submodules that could not be checked for changes" } if strings.TrimSpace(string(out)) != "" { - return "contains submodule changes, commits or stashes that no remote has" + return "contains modified or untracked files in a submodule" + } + // Every submodule repository the removal deletes, checked out or not + // (a deinit leaves it behind), against every local ref it has. + gitDir, err := git(ctx, "-C", dir, "rev-parse", "--absolute-git-dir") + if err != nil { + return "has submodules that could not be checked for unpushed work" + } + repos, err := moduleRepos(filepath.Join(strings.TrimSpace(string(gitDir)), "modules")) + if err != nil { + return "has submodules that could not be checked for unpushed work" + } + for _, repo := range repos { + out, err := git(ctx, "--git-dir="+repo, "rev-list", "-1", "--all", "--not", "--remotes") + if err != nil { + return "has submodules that could not be checked for unpushed work" + } + if strings.TrimSpace(string(out)) != "" { + return "contains submodule commits, tags or stashes that no remote has" + } } return "" } +// moduleRepos lists the submodule repositories git keeps under a modules +// folder, nested submodules' included. A name may hold slashes, so any +// folder that isn't a repository is looked into. +func moduleRepos(dir string) ([]string, error) { + entries, err := os.ReadDir(dir) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + var repos []string + for _, e := range entries { + if !e.IsDir() { + continue + } + p := filepath.Join(dir, e.Name()) + if _, err := os.Stat(filepath.Join(p, "HEAD")); err == nil { + repos = append(repos, p) + p = filepath.Join(p, "modules") + } + nested, err := moduleRepos(p) + if err != nil { + return nil, err + } + repos = append(repos, nested...) + } + return repos, nil +} + func expandHome(path string) string { if path == "~" || strings.HasPrefix(path, "~/") { if home, err := os.UserHomeDir(); err == nil { diff --git a/internal/box/locations_test.go b/internal/box/locations_test.go index 1c0cf33a9..efea07557 100644 --- a/internal/box/locations_test.go +++ b/internal/box/locations_test.go @@ -260,7 +260,7 @@ func TestAWorktreeWithSubmodulesIsRemovedWhenNothingWouldBeLost(t *testing.T) { // Config that hides untracked files from status doesn't hide them here. run(wt.Path, "config", "status.showUntrackedFiles", "no") run(sub, "config", "status.showUntrackedFiles", "no") - kept("a file in the submodule, hidden from status", "submodule changes") + kept("a file in the submodule, hidden from status", "untracked files in a submodule") os.WriteFile(filepath.Join(wt.Path, "todo"), []byte("wip"), 0o644) os.Remove(filepath.Join(sub, "notes")) kept("a file in the worktree, hidden from status", "modified or untracked") @@ -276,9 +276,30 @@ func TestAWorktreeWithSubmodulesIsRemovedWhenNothingWouldBeLost(t *testing.T) { // Committed in the worktree, git status is clean, but the submodule's // commit is only in the worktree's own copy of its history. run(wt.Path, "commit", "-q", "-am", "bump lib") - kept("a submodule commit no remote has", "commits or stashes that no remote has") + kept("a submodule commit no remote has", "submodule commits, tags or stashes that no remote has") run(sub, "push", "-q", "origin", "HEAD:refs/heads/shared") + // A commit only a local tag holds, with the submodule back where the + // worktree has it: clean, but the tag's commit would go. + run(sub, "commit", "-q", "--allow-empty", "-m", "tagged") + run(sub, "-c", "tag.gpgSign=false", "tag", "keep") + run(sub, "checkout", "-q", "HEAD~1") + kept("a commit only a submodule tag holds", "submodule commits, tags or stashes") + run(sub, "tag", "-d", "keep") + + // A deinit takes the submodule's checkout, not its repository: its + // branches stay in the worktree's git folder, and so go with it. + run(sub, "commit", "-q", "--allow-empty", "-m", "branched") + run(sub, "branch", "keep") + run(sub, "checkout", "-q", "HEAD~1") + run(wt.Path, "submodule", "deinit", "-q", "-f", "lib") + kept("a branch in a deinitialized submodule", "submodule commits, tags or stashes") + out, err := exec.Command("git", "-C", wt.Path, "rev-parse", "--absolute-git-dir").Output() + if err != nil { + t.Fatal(err) + } + run(wt.Path, "--git-dir="+filepath.Join(strings.TrimSpace(string(out)), "modules", "lib"), "branch", "-D", "-q", "keep") + if err := l.RemoveWorktree(ctx, "cal", "vendor", false); err != nil { t.Fatalf("a clean worktree with a submodule: %v", err) } From 646df7f5b5b667e00413a9f09e233c1b0b670ec3 Mon Sep 17 00:00:00 2001 From: Bailey Pumfleet Date: Tue, 27 Oct 2026 11:48:34 -0500 Subject: [PATCH 4/9] fix(box): removing a worktree with submodules keeps commits in a submodule's own .git Address Codex review (round 3): - a checked-out submodule's own git folder is checked too, so one cloned by hand (its repository embedded in the worktree's folder, not under the worktree's modules) can't lose commits no remote has Co-Authored-By: Claude Opus 5.5 --- internal/box/locations.go | 15 +++++++++++++-- internal/box/locations_test.go | 9 +++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/internal/box/locations.go b/internal/box/locations.go index c2bcd8101..bbbfbf72d 100644 --- a/internal/box/locations.go +++ b/internal/box/locations.go @@ -583,8 +583,10 @@ func unsavedWork(ctx context.Context, dir string) string { if strings.TrimSpace(string(out)) != "" { return "contains modified or untracked files in a submodule" } - // Every submodule repository the removal deletes, checked out or not - // (a deinit leaves it behind), against every local ref it has. + // Every submodule repository the removal deletes, against every local + // ref it has: those in the worktree's git folder, checked out or not (a + // deinit leaves one behind), and those a checked-out submodule keeps in + // its own .git (one made with git clone). gitDir, err := git(ctx, "-C", dir, "rev-parse", "--absolute-git-dir") if err != nil { return "has submodules that could not be checked for unpushed work" @@ -593,6 +595,15 @@ func unsavedWork(ctx context.Context, dir string) string { if err != nil { return "has submodules that could not be checked for unpushed work" } + out, err = git(ctx, "-C", dir, "submodule", "foreach", "--quiet", "--recursive", "git rev-parse --absolute-git-dir") + if err != nil { + return "has submodules that could not be checked for unpushed work" + } + for _, repo := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if repo != "" && !slices.Contains(repos, repo) { + repos = append(repos, repo) + } + } for _, repo := range repos { out, err := git(ctx, "--git-dir="+repo, "rev-list", "-1", "--all", "--not", "--remotes") if err != nil { diff --git a/internal/box/locations_test.go b/internal/box/locations_test.go index efea07557..a83b21c98 100644 --- a/internal/box/locations_test.go +++ b/internal/box/locations_test.go @@ -300,6 +300,15 @@ func TestAWorktreeWithSubmodulesIsRemovedWhenNothingWouldBeLost(t *testing.T) { } run(wt.Path, "--git-dir="+filepath.Join(strings.TrimSpace(string(out)), "modules", "lib"), "branch", "-D", "-q", "keep") + // A submodule cloned by hand keeps its repository in its own .git, + // inside the worktree's folder, and so goes with it too. + run(wt.Path, "clone", "-q", lib, "lib") + run(sub, "checkout", "-q", "origin/shared") + run(sub, "commit", "-q", "--allow-empty", "-m", "embedded") + run(wt.Path, "commit", "-q", "-am", "bump lib again") + kept("a commit in an embedded submodule repository", "submodule commits, tags or stashes") + run(sub, "push", "-q", "origin", "HEAD:refs/heads/embedded") + if err := l.RemoveWorktree(ctx, "cal", "vendor", false); err != nil { t.Fatalf("a clean worktree with a submodule: %v", err) } From 46b44ff45b020b29f4ef19043b01a80621e77b09 Mon Sep 17 00:00:00 2001 From: Bailey Pumfleet Date: Tue, 27 Oct 2026 11:58:23 -0500 Subject: [PATCH 5/9] fix(box): say exactly what removing a worktree with submodules keeps Address Codex review (round 4): - the refusal, the app's toast and the changelog promised to keep "tags"; what is kept is commits no remote has (one only a tag holds included) and stashes. A local tag's name on history a remote has isn't checked: that would take asking the remote on every removal Co-Authored-By: Claude Opus 5.5 --- app/src/lib/remove-worktree.ts | 2 +- docs/changelog.mdx | 5 +++-- internal/box/locations.go | 10 ++++++---- internal/box/locations_test.go | 8 ++++---- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/app/src/lib/remove-worktree.ts b/app/src/lib/remove-worktree.ts index dce363748..8abc627f6 100644 --- a/app/src/lib/remove-worktree.ts +++ b/app/src/lib/remove-worktree.ts @@ -78,7 +78,7 @@ async function drain(box: string) { type: "warning", title: `Kept ${wt.name}`, description: /submodule commits/i.test(msg) - ? "A submodule in it has commits, tags or stashes no remote has, so it was left as it is." + ? "A submodule in it has commits or stashes no remote has, so it was left as it is." : "It has uncommitted changes, so git left it as it is.", actionProps: { children: "Remove anyway", onClick: () => queueRemoval(box, location, wt, { force: true }) }, }); diff --git a/docs/changelog.mdx b/docs/changelog.mdx index a0bf5b10f..7d41eaf0f 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -15,8 +15,9 @@ again. worktree with a submodule checked out, even a clean one, so removing or archiving one failed. Shipyard now checks the worktree and its submodules itself and removes it when nothing would be lost. If a submodule has - changes, or commits, tags or stashes that aren't pushed anywhere, the - worktree is kept and you can still choose **Remove anyway**. + changes, stashes, or commits that aren't pushed anywhere (a commit only a + local tag points at counts), the worktree is kept and you can still choose + **Remove anyway**. - **Pin worktrees to the top of the sidebar.** Right-click a worktree, or open its ⋯ menu, and choose **Pin to top**. Pinned worktrees sit in their diff --git a/internal/box/locations.go b/internal/box/locations.go index bbbfbf72d..c0c0a02f2 100644 --- a/internal/box/locations.go +++ b/internal/box/locations.go @@ -560,9 +560,11 @@ func dirtyWorktree(dir string) bool { } // unsavedWork says what removing a worktree with submodules would lose, or -// "" for nothing: changes in it or its submodules, or anything in a -// submodule's history no remote has (commits, tags, stashes), since a -// worktree keeps its submodules' repositories inside its own git folder. +// "" for nothing: changes in it or its submodules, or submodule commits or +// stashes no remote has (any local ref's: a branch's, a tag's, the stash), +// since a worktree keeps its submodules' repositories inside its own git +// folder. A ref's name on history a remote has is not kept: whether a +// remote has the tag itself would take asking it, on every removal. // Untracked files count whatever status.showUntrackedFiles says, and when // git can't tell, that counts as something to lose. func unsavedWork(ctx context.Context, dir string) string { @@ -610,7 +612,7 @@ func unsavedWork(ctx context.Context, dir string) string { return "has submodules that could not be checked for unpushed work" } if strings.TrimSpace(string(out)) != "" { - return "contains submodule commits, tags or stashes that no remote has" + return "contains submodule commits or stashes that no remote has" } } return "" diff --git a/internal/box/locations_test.go b/internal/box/locations_test.go index a83b21c98..7665a2eec 100644 --- a/internal/box/locations_test.go +++ b/internal/box/locations_test.go @@ -276,7 +276,7 @@ func TestAWorktreeWithSubmodulesIsRemovedWhenNothingWouldBeLost(t *testing.T) { // Committed in the worktree, git status is clean, but the submodule's // commit is only in the worktree's own copy of its history. run(wt.Path, "commit", "-q", "-am", "bump lib") - kept("a submodule commit no remote has", "submodule commits, tags or stashes that no remote has") + kept("a submodule commit no remote has", "submodule commits or stashes that no remote has") run(sub, "push", "-q", "origin", "HEAD:refs/heads/shared") // A commit only a local tag holds, with the submodule back where the @@ -284,7 +284,7 @@ func TestAWorktreeWithSubmodulesIsRemovedWhenNothingWouldBeLost(t *testing.T) { run(sub, "commit", "-q", "--allow-empty", "-m", "tagged") run(sub, "-c", "tag.gpgSign=false", "tag", "keep") run(sub, "checkout", "-q", "HEAD~1") - kept("a commit only a submodule tag holds", "submodule commits, tags or stashes") + kept("a commit only a submodule tag holds", "submodule commits or stashes") run(sub, "tag", "-d", "keep") // A deinit takes the submodule's checkout, not its repository: its @@ -293,7 +293,7 @@ func TestAWorktreeWithSubmodulesIsRemovedWhenNothingWouldBeLost(t *testing.T) { run(sub, "branch", "keep") run(sub, "checkout", "-q", "HEAD~1") run(wt.Path, "submodule", "deinit", "-q", "-f", "lib") - kept("a branch in a deinitialized submodule", "submodule commits, tags or stashes") + kept("a branch in a deinitialized submodule", "submodule commits or stashes") out, err := exec.Command("git", "-C", wt.Path, "rev-parse", "--absolute-git-dir").Output() if err != nil { t.Fatal(err) @@ -306,7 +306,7 @@ func TestAWorktreeWithSubmodulesIsRemovedWhenNothingWouldBeLost(t *testing.T) { run(sub, "checkout", "-q", "origin/shared") run(sub, "commit", "-q", "--allow-empty", "-m", "embedded") run(wt.Path, "commit", "-q", "-am", "bump lib again") - kept("a commit in an embedded submodule repository", "submodule commits, tags or stashes") + kept("a commit in an embedded submodule repository", "submodule commits or stashes") run(sub, "push", "-q", "origin", "HEAD:refs/heads/embedded") if err := l.RemoveWorktree(ctx, "cal", "vendor", false); err != nil { From 7f810d3c96bc8fa4cd1b4d4f1151dc2e0457e5b5 Mon Sep 17 00:00:00 2001 From: Bailey Pumfleet Date: Tue, 27 Oct 2026 12:20:37 -0500 Subject: [PATCH 6/9] fix(app): a worktree the box couldn't check for unsaved work still offers remove anyway Address cubic review on #197: - a refusal because the box couldn't check a worktree with submodules got a plain error with no way forward; it now gets the Kept toast with Remove anyway, in its own words rather than "uncommitted changes" - which refusal says what is now keptReason, with a unit test Co-Authored-By: Claude Opus 5.5 --- app/src/lib/kept-reason.test.ts | 26 ++++++++++++++++++++++++++ app/src/lib/kept-reason.ts | 10 ++++++++++ app/src/lib/remove-worktree.ts | 10 ++++------ 3 files changed, 40 insertions(+), 6 deletions(-) create mode 100644 app/src/lib/kept-reason.test.ts create mode 100644 app/src/lib/kept-reason.ts diff --git a/app/src/lib/kept-reason.test.ts b/app/src/lib/kept-reason.test.ts new file mode 100644 index 000000000..aba893f1d --- /dev/null +++ b/app/src/lib/kept-reason.test.ts @@ -0,0 +1,26 @@ +// node --experimental-strip-types --test src/lib/kept-reason.test.ts (pnpm test) +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { keptReason } from "./kept-reason.ts"; + +const box = (why: string) => `git worktree remove: '/home/acme/code/shop-fix' ${why}, use --force to delete it`; + +test("work git or the box would lose keeps the worktree, and says what it is", () => { + assert.match(keptReason(box("contains modified or untracked files"))!, /uncommitted changes/); + assert.match(keptReason(box("contains modified or untracked files in a submodule"))!, /uncommitted changes/); + assert.match(keptReason(box("contains submodule commits or stashes that no remote has"))!, /commits or stashes no remote has/); +}); + +test("a check that couldn't run still offers Remove anyway, without calling it uncommitted changes", () => { + for (const why of ["could not be checked for changes", "has submodules that could not be checked for unpushed work"]) { + const said = keptReason(box(why)); + assert.ok(said, why); + assert.doesNotMatch(said!, /uncommitted/); + } +}); + +test("other failures aren't a reason to force", () => { + assert.equal(keptReason("git worktree remove: fatal: 'shop-fix' is not a working tree"), null); + assert.equal(keptReason("not connected"), null); +}); diff --git a/app/src/lib/kept-reason.ts b/app/src/lib/kept-reason.ts new file mode 100644 index 000000000..53b23e0cd --- /dev/null +++ b/app/src/lib/kept-reason.ts @@ -0,0 +1,10 @@ +// keptReason reads a box's refusal to remove a worktree unforced: why it +// was kept, in words for the "Kept" toast that offers Remove anyway, or +// null when the error is something else that forcing wouldn't fix. +export function keptReason(msg: string): string | null { + if (/submodule commits/i.test(msg)) return "A submodule in it has commits or stashes no remote has, so it was left as it is."; + if (/could not be checked/i.test(msg)) return "Shipyard couldn't check it for work that removing it would lose, so it was left as it is."; + // git's own words for work it would lose. + if (/modified|untracked|uncommitted|contains/i.test(msg)) return "It has uncommitted changes, so git left it as it is."; + return null; +} diff --git a/app/src/lib/remove-worktree.ts b/app/src/lib/remove-worktree.ts index 8abc627f6..b196e66e2 100644 --- a/app/src/lib/remove-worktree.ts +++ b/app/src/lib/remove-worktree.ts @@ -2,6 +2,7 @@ import { toastError } from "@/components/error-note"; import { toastManager } from "@/components/ui/toast"; import type { Client } from "@/lib/api"; import { errorMessage } from "@/lib/format"; +import { keptReason } from "@/lib/kept-reason"; import { unpinWorktree } from "@/lib/pinned-worktrees"; import { clearRemoving, markRemoving, markScript, pruneRemovals, type RemovalKind, removalOf, removeResult, useRemovals } from "@/lib/removing"; import { scheduleRefresh, useStore } from "@/lib/store"; @@ -71,15 +72,12 @@ async function drain(box: string) { await removeWorktreeOnBox(client, box, location, wt, "remove", { force }); } catch (err) { clearRemoving(box, wt.path); - // git's own words for work it would lose. - const msg = errorMessage(err); - if (!force && /modified|untracked|uncommitted|contains/i.test(msg)) { + const why = force ? null : keptReason(errorMessage(err)); + if (why) { toastManager.add({ type: "warning", title: `Kept ${wt.name}`, - description: /submodule commits/i.test(msg) - ? "A submodule in it has commits or stashes no remote has, so it was left as it is." - : "It has uncommitted changes, so git left it as it is.", + description: why, actionProps: { children: "Remove anyway", onClick: () => queueRemoval(box, location, wt, { force: true }) }, }); } else toastError(err, { title: `Couldn't remove ${wt.name}`, box }); From 0415ae9a66093195eb68eead5517e456a59fd67e Mon Sep 17 00:00:00 2001 From: Bailey Pumfleet Date: Tue, 27 Oct 2026 12:27:25 -0500 Subject: [PATCH 7/9] fix(box): removing a worktree checks deinitialized submodules inside hand-cloned ones Address Codex review (round 6): - a hand-cloned submodule keeps its own submodules' repositories under its own .git/modules; a deinitialized one there is no longer skipped, so its unpushed branches and stashes keep the worktree Co-Authored-By: Claude Opus 5.5 --- internal/box/locations.go | 16 ++++++++-- internal/box/locations_test.go | 58 +++++++++++++++++++++++++++++++--- 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/internal/box/locations.go b/internal/box/locations.go index c0c0a02f2..a9f92512b 100644 --- a/internal/box/locations.go +++ b/internal/box/locations.go @@ -588,7 +588,8 @@ func unsavedWork(ctx context.Context, dir string) string { // Every submodule repository the removal deletes, against every local // ref it has: those in the worktree's git folder, checked out or not (a // deinit leaves one behind), and those a checked-out submodule keeps in - // its own .git (one made with git clone). + // its own .git (one made with git clone), with the ones under that .git + // in turn. gitDir, err := git(ctx, "-C", dir, "rev-parse", "--absolute-git-dir") if err != nil { return "has submodules that could not be checked for unpushed work" @@ -602,8 +603,17 @@ func unsavedWork(ctx context.Context, dir string) string { return "has submodules that could not be checked for unpushed work" } for _, repo := range strings.Split(strings.TrimSpace(string(out)), "\n") { - if repo != "" && !slices.Contains(repos, repo) { - repos = append(repos, repo) + if repo == "" { + continue + } + nested, err := moduleRepos(filepath.Join(repo, "modules")) + if err != nil { + return "has submodules that could not be checked for unpushed work" + } + for _, r := range append([]string{repo}, nested...) { + if !slices.Contains(repos, r) { + repos = append(repos, r) + } } } for _, repo := range repos { diff --git a/internal/box/locations_test.go b/internal/box/locations_test.go index 7665a2eec..f467d688a 100644 --- a/internal/box/locations_test.go +++ b/internal/box/locations_test.go @@ -215,10 +215,9 @@ func TestAWorktreeSomeoneLockedIsLeftAlone(t *testing.T) { } } -func TestAWorktreeWithSubmodulesIsRemovedWhenNothingWouldBeLost(t *testing.T) { - repo := gitRepo(t) - ctx := context.Background() - run := func(dir string, args ...string) { +// submoduleGit runs git in dir for a test, local submodule clones allowed. +func submoduleGit(t *testing.T) func(dir string, args ...string) { + return func(dir string, args ...string) { t.Helper() cmd := exec.Command("git", append([]string{"-c", "protocol.file.allow=always"}, args...)...) cmd.Dir = dir @@ -227,6 +226,12 @@ func TestAWorktreeWithSubmodulesIsRemovedWhenNothingWouldBeLost(t *testing.T) { t.Fatalf("git %v: %s", args, out) } } +} + +func TestAWorktreeWithSubmodulesIsRemovedWhenNothingWouldBeLost(t *testing.T) { + repo := gitRepo(t) + ctx := context.Background() + run := submoduleGit(t) lib := filepath.Join(filepath.Dir(repo), "lib") os.MkdirAll(lib, 0o755) run(lib, "init", "-q", "-b", "main") @@ -317,6 +322,51 @@ func TestAWorktreeWithSubmodulesIsRemovedWhenNothingWouldBeLost(t *testing.T) { } } +func TestADeinitializedSubmoduleInsideAClonedOneIsChecked(t *testing.T) { + repo := gitRepo(t) + ctx := context.Background() + run := submoduleGit(t) + // cal has lib, and lib has deep. + deep := filepath.Join(filepath.Dir(repo), "deep") + lib := filepath.Join(filepath.Dir(repo), "lib") + os.MkdirAll(deep, 0o755) + os.MkdirAll(lib, 0o755) + run(deep, "init", "-q", "-b", "main") + run(deep, "commit", "-q", "--allow-empty", "-m", "deep") + run(lib, "init", "-q", "-b", "main") + run(lib, "submodule", "add", "-q", deep, "deep") + run(lib, "commit", "-q", "-m", "add deep") + run(repo, "submodule", "add", "-q", lib, "lib") + run(repo, "commit", "-q", "-m", "add lib") + + l := NewLocations(filepath.Join(t.TempDir(), "locations.json")) + if _, err := l.Add(ctx, "cal", repo); err != nil { + t.Fatal(err) + } + wt, err := l.CreateWorktree(ctx, "cal", "vendor", "vendor", "main") + if err != nil { + t.Fatal(err) + } + // lib cloned by hand: deep's repository lives in lib's own .git. A + // branch only deep has, then deep deinitialized. + sub := filepath.Join(wt.Path, "lib") + run(wt.Path, "clone", "-q", lib, "lib") + run(sub, "submodule", "update", "-q", "--init") + run(filepath.Join(sub, "deep"), "commit", "-q", "--allow-empty", "-m", "only here") + run(filepath.Join(sub, "deep"), "branch", "keep") + run(filepath.Join(sub, "deep"), "checkout", "-q", "HEAD~1") + run(sub, "submodule", "deinit", "-q", "-f", "deep") + + err = l.RemoveWorktree(ctx, "cal", "vendor", false) + if err == nil || !strings.Contains(err.Error(), "submodule commits or stashes") || statusFor(err) != 409 { + t.Fatalf("err = %v (status %d), want a 409 about submodule commits", err, statusFor(err)) + } + run(sub, "--git-dir="+filepath.Join(sub, ".git", "modules", "deep"), "branch", "-D", "-q", "keep") + if err := l.RemoveWorktree(ctx, "cal", "vendor", false); err != nil { + t.Fatalf("with nothing left to lose: %v", err) + } +} + func TestAFailedAddLeavesNothingBehind(t *testing.T) { repo := gitRepo(t) ctx := context.Background() From 6dec9767f554e72a88cb8b3b6fa7ff347825f1e8 Mon Sep 17 00:00:00 2001 From: Bailey Pumfleet Date: Tue, 27 Oct 2026 12:39:32 -0500 Subject: [PATCH 8/9] fix(box): removing a worktree sees a nested submodule moved behind an ignore setting Address Codex review (round 7): - each submodule's own status now overrides submodule..ignore, so a nested submodule checked out at another commit (an uncommitted change) keeps the worktree even when the submodule above it ignores it Co-Authored-By: Claude Opus 5.5 --- internal/box/locations.go | 5 ++-- internal/box/locations_test.go | 43 ++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/internal/box/locations.go b/internal/box/locations.go index a9f92512b..ec985aa96 100644 --- a/internal/box/locations.go +++ b/internal/box/locations.go @@ -576,9 +576,10 @@ func unsavedWork(ctx context.Context, dir string) string { return "contains modified or untracked files" } // Each checked-out submodule is asked itself too, so its own config - // can't hide a change from the status above. + // (status.showUntrackedFiles, submodule..ignore for the ones + // inside it) can't hide a change from the status above. out, err = git(ctx, "-C", dir, "submodule", "foreach", "--quiet", "--recursive", - "git status --porcelain --untracked-files=normal --ignore-submodules=all") + "git status --porcelain --untracked-files=normal --ignore-submodules=none") if err != nil { return "has submodules that could not be checked for changes" } diff --git a/internal/box/locations_test.go b/internal/box/locations_test.go index f467d688a..9488844d7 100644 --- a/internal/box/locations_test.go +++ b/internal/box/locations_test.go @@ -367,6 +367,49 @@ func TestADeinitializedSubmoduleInsideAClonedOneIsChecked(t *testing.T) { } } +func TestANestedSubmoduleMovedBehindAnIgnoreIsChecked(t *testing.T) { + repo := gitRepo(t) + ctx := context.Background() + run := submoduleGit(t) + // cal has lib, and lib has deep at deep's second commit. + deep := filepath.Join(filepath.Dir(repo), "deep") + lib := filepath.Join(filepath.Dir(repo), "lib") + os.MkdirAll(deep, 0o755) + os.MkdirAll(lib, 0o755) + run(deep, "init", "-q", "-b", "main") + run(deep, "commit", "-q", "--allow-empty", "-m", "deep") + run(deep, "commit", "-q", "--allow-empty", "-m", "deep 2") + run(lib, "init", "-q", "-b", "main") + run(lib, "submodule", "add", "-q", deep, "deep") + run(lib, "commit", "-q", "-m", "add deep") + run(repo, "submodule", "add", "-q", lib, "lib") + run(repo, "commit", "-q", "-m", "add lib") + + l := NewLocations(filepath.Join(t.TempDir(), "locations.json")) + if _, err := l.Add(ctx, "cal", repo); err != nil { + t.Fatal(err) + } + wt, err := l.CreateWorktree(ctx, "cal", "vendor", "vendor", "main") + if err != nil { + t.Fatal(err) + } + run(wt.Path, "submodule", "update", "-q", "--init", "--recursive") + sub := filepath.Join(wt.Path, "lib") + // lib ignores deep, and deep moves to a commit its remote has: only + // asking lib itself, ignores overridden, shows the move. + run(sub, "config", "submodule.deep.ignore", "all") + run(filepath.Join(sub, "deep"), "checkout", "-q", "HEAD~1") + + err = l.RemoveWorktree(ctx, "cal", "vendor", false) + if err == nil || !strings.Contains(err.Error(), "files in a submodule") || statusFor(err) != 409 { + t.Fatalf("err = %v (status %d), want a 409 about changes in a submodule", err, statusFor(err)) + } + run(filepath.Join(sub, "deep"), "checkout", "-q", "main") + if err := l.RemoveWorktree(ctx, "cal", "vendor", false); err != nil { + t.Fatalf("with deep back where lib has it: %v", err) + } +} + func TestAFailedAddLeavesNothingBehind(t *testing.T) { repo := gitRepo(t) ctx := context.Background() From d56e6e088c926b460e635d19b711ff43e3dd2196 Mon Sep 17 00:00:00 2001 From: Bailey Pumfleet Date: Tue, 27 Oct 2026 13:07:17 -0500 Subject: [PATCH 9/9] fix(app): only a real refusal over unsaved work offers remove anyway Address cubic review on #197: - keptReason matched bare words (contains, modified, untracked), so an unrelated removal failure could be called uncommitted changes and offer a force; it now matches the refusal's own words, "modified or untracked files", which git and the box both use Co-Authored-By: Claude Opus 5.5 --- app/src/lib/kept-reason.test.ts | 3 +++ app/src/lib/kept-reason.ts | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/lib/kept-reason.test.ts b/app/src/lib/kept-reason.test.ts index aba893f1d..d2d005b08 100644 --- a/app/src/lib/kept-reason.test.ts +++ b/app/src/lib/kept-reason.test.ts @@ -23,4 +23,7 @@ test("a check that couldn't run still offers Remove anyway, without calling it u test("other failures aren't a reason to force", () => { assert.equal(keptReason("git worktree remove: fatal: 'shop-fix' is not a working tree"), null); assert.equal(keptReason("not connected"), null); + // Words that only sound like unsaved work. + assert.equal(keptReason("git worktree remove: fatal: '/home/acme/code/shop-fix' contains a .git file that is not a gitlink"), null); + assert.equal(keptReason("git worktree remove: error: unable to unlink old 'src/modified.ts': Permission denied"), null); }); diff --git a/app/src/lib/kept-reason.ts b/app/src/lib/kept-reason.ts index 53b23e0cd..f42905eb9 100644 --- a/app/src/lib/kept-reason.ts +++ b/app/src/lib/kept-reason.ts @@ -4,7 +4,7 @@ export function keptReason(msg: string): string | null { if (/submodule commits/i.test(msg)) return "A submodule in it has commits or stashes no remote has, so it was left as it is."; if (/could not be checked/i.test(msg)) return "Shipyard couldn't check it for work that removing it would lose, so it was left as it is."; - // git's own words for work it would lose. - if (/modified|untracked|uncommitted|contains/i.test(msg)) return "It has uncommitted changes, so git left it as it is."; + // git's own words for work it would lose, which the box's refusals use too. + if (/modified or untracked files/i.test(msg)) return "It has uncommitted changes, so git left it as it is."; return null; }