diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 000000000..a99d49433 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,172 @@ +# Doctor + Cleanup Consolidation Plan + +## Problem + +GSD has 7+ commands that check, diagnose, or clean up project state. Several overlap or duplicate each other, and worktree lifecycle management is missing entirely. Users can't answer "what's safe to delete?" without manual git investigation. + +### Current surface area + +| Command | Purpose | Overlap | +|---|---|---| +| `/gsd doctor` | State integrity, git health, worktrees, runtime, env, prefs | **Primary health system** | +| `/gsd doctor fix` | Auto-fix detected issues | | +| `/gsd doctor heal` | Dispatch unfixable issues to LLM | | +| `/gsd doctor audit` | Expanded output, no fix | | +| `/gsd cleanup` | Runs branches + snapshots cleanup | **Redundant** — doctor already handles branches | +| `/gsd cleanup branches` | Delete merged `gsd/*` branches | **Redundant** — doctor detects but won't fix legacy branches | +| `/gsd cleanup snapshots` | Prune old snapshot refs | **Gap** — doctor has no snapshot check | +| `/gsd cleanup projects` | Audit orphaned `~/.gsd/projects/` dirs | **Fully redundant** — doctor's `orphaned_project_state` does the same | +| `/gsd keys doctor` | Per-key health check | **Complementary** — deeper than doctor's surface provider check | +| `/gsd skill-health` | Skill usage stats | No overlap — analytics, not health | +| `/gsd inspect` | SQLite DB diagnostics | No overlap — introspection tool | +| `/gsd forensics` | Post-failure investigation | No overlap — different lifecycle | + +### Missing + +- No worktree lifecycle checks (merged? stale? dirty? unpushed?) +- `/worktree list` shows name/branch/path but no safety status +- Doctor checks completed-milestone worktrees but nothing else + +--- + +## Design: Doctor as the single health authority + +**Principle:** Doctor finds problems. Doctor fix resolves them. One command, not three paths to the same outcome. + +### Phase 1: New doctor checks for worktree lifecycle + +Add to `doctor-checks.ts` → `checkGitHealth()`: + +| Check code | Severity | Fixable | Condition | What `--fix` does | +|---|---|---|---|---| +| `worktree_branch_merged` | info | yes | Worktree's branch is fully merged into main (merge-base --is-ancestor) | Remove worktree + delete branch | +| `worktree_stale` | warning | no | No commits in 14+ days AND no open PR on remote | Report only — needs user decision | +| `worktree_dirty` | warning | no | Stale worktree has uncommitted changes | Report only — data loss risk | +| `worktree_unpushed` | warning | no | Worktree branch has commits not on any remote | Report only — push first | + +**Scope:** Only GSD-managed worktrees under `.gsd/worktrees/`. Not `.claude/worktrees/`, not sibling repos, not `/tmp/` worktrees. GSD owns what GSD creates. + +**Safety rules:** +- Never auto-remove a worktree matching `process.cwd()` (existing pattern) +- Never auto-remove a worktree with uncommitted changes +- Never auto-remove a worktree with unpushed commits +- `worktree_branch_merged` is the only auto-fixable worktree check — it's the safest (work is already in main) + +### Phase 2: Fold `/gsd cleanup` into doctor + +**2a. Make `legacy_slice_branches` fixable in doctor.** + +Currently detected as `info` severity, not fixable. Change to: +- Severity: `info` (keep) +- Fixable: `true` +- `--fix` action: `nativeBranchDelete(basePath, branch, true)` for each merged legacy branch + +This makes `cleanup branches` redundant — doctor handles both `milestone/*` and `gsd/*` branches. + +**2b. Add `snapshot_ref_bloat` doctor check.** + +New check in `checkRuntimeHealth()`: +- Count `refs/gsd/snapshots/` refs +- If > 50 refs per label, report `snapshot_ref_bloat` (warning, fixable) +- `--fix` action: prune to newest 5 per label (same logic as existing `handleCleanupSnapshots`) + +This makes `cleanup snapshots` redundant. + +**2c. `/gsd cleanup projects` is already redundant.** + +Doctor's `orphaned_project_state` check (in `checkGlobalHealth`) does the same thing. No code change needed — just deprecation. + +**2d. `/gsd cleanup` becomes a permanent alias.** + +- `/gsd cleanup` → runs `doctor fix` scoped to cleanup-class issues (branches, snapshots, projects, worktrees) +- `/gsd cleanup branches` → doctor fix for branch issues +- `/gsd cleanup snapshots` → doctor fix for snapshot issues +- `/gsd cleanup projects` → doctor fix for project state issues +- `/gsd cleanup worktrees` → doctor fix for worktree issues + +No deprecation warnings. Same commands, doctor under the hood. Existing muscle memory keeps working. + +### Phase 3: Enhance `/worktree list` with safety status + +Enhance `handleList()` in `worktree-command.ts` to show safety information inline: + +``` +GSD Worktrees + + feature-x ● active + branch worktree/feature-x + path .gsd/worktrees/feature-x + status 3 uncommitted files · 2 unpushed commits · last commit 4h ago + + old-bugfix + branch worktree/old-bugfix + path .gsd/worktrees/old-bugfix + status ✓ merged into main · safe to remove + + stale-experiment + branch worktree/stale-experiment + path .gsd/worktrees/stale-experiment + status ⚠ no commits in 18 days · no open PR +``` + +Data to show per worktree: +- Uncommitted file count (if any) +- Unpushed commit count (if any) +- Merge status (merged into main or not) +- Last commit age +- Whether branch has been pushed to remote + +### Phase 4: Add `/gsd cleanup worktrees` convenience entry point + +For discoverability, add to the cleanup catalog: +``` +/gsd cleanup worktrees — Remove merged/safe-to-delete worktrees +/gsd cleanup worktrees --dry — Preview what would be removed +``` + +This is a thin wrapper that runs doctor fix scoped to `worktree_branch_merged` issues only. + +--- + +## What stays separate (no changes) + +| Command | Why | +|---|---| +| `/gsd keys doctor` | Deeper per-key analysis; general doctor's provider check is a sufficient surface check | +| `/gsd inspect` | DB introspection — not a health check | +| `/gsd skill-health` | Usage analytics — not a health check | +| `/gsd forensics` | Post-mortem investigation — different purpose and lifecycle | +| `/gsd logs` | Read-only log viewer | + +--- + +## Implementation order + +1. **Phase 1** — Worktree lifecycle checks in doctor (the core ask) +2. **Phase 3** — Enhanced `/worktree list` (immediate user value, depends on same data as Phase 1) +3. **Phase 2** — Fold cleanup into doctor (reduces surface area) +4. **Phase 4** — Cleanup worktrees convenience entry (trivial once Phase 1+2 land) + +Phase 1 and 3 share git inspection code (merge status, uncommitted changes, unpushed commits). Build that as shared helpers in `worktree-manager.ts` or a new `worktree-health.ts`, then both phases consume it. + +--- + +## Files likely touched + +| File | Changes | +|---|---| +| `doctor-checks.ts` | New worktree lifecycle checks, make `legacy_slice_branches` fixable, add snapshot bloat check | +| `doctor-types.ts` | New issue codes: `worktree_branch_merged`, `worktree_stale`, `worktree_dirty`, `worktree_unpushed`, `snapshot_ref_bloat` | +| `worktree-manager.ts` | New helpers: `getWorktreeMergeStatus()`, `getWorktreeDirtyStatus()`, `getWorktreeUnpushedCount()`, `getWorktreeLastCommitAge()` | +| `worktree-command.ts` | Enhanced `handleList()` with safety status | +| `commands-maintenance.ts` | Deprecation wrappers for cleanup subcommands | +| `commands/catalog.ts` | Add `worktrees` to cleanup subcommands, update doctor subcommand descriptions | +| `commands/handlers/ops.ts` | Wire up `/gsd cleanup worktrees` | + +--- + +## Decisions + +1. **Stale threshold** — 14 days default, configurable via preferences. +2. **Remote PR check** — Commit age is the primary signal. PR check is a bonus when `gh` is available. Degrade gracefully if `gh` is missing. +3. **Cleanup as permanent alias** — `/gsd cleanup` stays as a permanent alias that silently calls doctor fix under the hood. No deprecation noise. Users who learned cleanup keep using it, new users learn doctor. diff --git a/src/resources/extensions/gsd/commands-maintenance.ts b/src/resources/extensions/gsd/commands-maintenance.ts index 945e2697b..5b6c4b8ff 100644 --- a/src/resources/extensions/gsd/commands-maintenance.ts +++ b/src/resources/extensions/gsd/commands-maintenance.ts @@ -1,7 +1,7 @@ /** * GSD Maintenance — cleanup, skip, and dry-run handlers. * - * Contains: handleCleanupBranches, handleCleanupSnapshots, handleSkip, handleDryRun + * Contains: handleCleanupBranches, handleCleanupSnapshots, handleCleanupWorktrees, handleSkip, handleDryRun */ import type { ExtensionCommandContext } from "@gsd/pi-coding-agent"; @@ -13,17 +13,13 @@ export async function handleCleanupBranches(ctx: ExtensionCommandContext, basePa try { branches = nativeBranchList(basePath, "gsd/*"); } catch { - ctx.ui.notify("No GSD branches found.", "info"); - return; - } - - if (branches.length === 0) { ctx.ui.notify("No GSD branches to clean up.", "info"); return; } - const mainBranch = nativeDetectMainBranch(basePath); + const quickBranches = branches.filter((b) => b.startsWith("gsd/quick/")); + const mainBranch = nativeDetectMainBranch(basePath); let merged: string[]; try { merged = nativeBranchListMerged(basePath, mainBranch, "gsd/*"); @@ -31,20 +27,77 @@ export async function handleCleanupBranches(ctx: ExtensionCommandContext, basePa merged = []; } - if (merged.length === 0) { - ctx.ui.notify(`${branches.length} GSD branches found, none are merged into ${mainBranch} yet.`, "info"); + const mergedNonQuick = merged.filter((b) => !b.startsWith("gsd/quick/")); + let deletedMerged = 0; + for (const branch of mergedNonQuick) { + try { + nativeBranchDelete(basePath, branch, false); + deletedMerged++; + } catch { + /* skip branches that cannot be deleted */ + } + } + + // Also delete stale milestone branches for completed milestones when detached + // from any registered worktree. + let deletedStaleMilestones = 0; + try { + const { listWorktrees } = await import("./worktree-manager.js"); + const { resolveMilestoneFile } = await import("./paths.js"); + const { loadFile, parseRoadmap } = await import("./files.js"); + const { isMilestoneComplete } = await import("./state.js"); + + const attachedBranches = new Set( + listWorktrees(basePath).map((wt) => wt.branch), + ); + const milestoneBranches = nativeBranchList(basePath, "milestone/*"); + for (const branch of milestoneBranches) { + if (attachedBranches.has(branch)) continue; + const milestoneId = branch.replace(/^milestone\//, ""); + const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP"); + if (!roadmapPath) continue; + let roadmapContent: string | null = null; + try { + roadmapContent = await loadFile(roadmapPath); + } catch { + roadmapContent = null; + } + if (!roadmapContent) continue; + if (!isMilestoneComplete(parseRoadmap(roadmapContent))) continue; + try { + nativeBranchDelete(basePath, branch, true); + deletedStaleMilestones++; + } catch { + /* non-fatal */ + } + } + } catch { + /* non-fatal */ + } + + const summary: string[] = []; + if (deletedMerged > 0) { + summary.push(`Cleaned up ${deletedMerged} merged branch${deletedMerged === 1 ? "" : "es"}.`); + } + if (deletedStaleMilestones > 0) { + summary.push(`Deleted ${deletedStaleMilestones} stale milestone branch${deletedStaleMilestones === 1 ? "" : "es"}.`); + } + if (quickBranches.length > 0) { + summary.push(`Skipped ${quickBranches.length} quick branch${quickBranches.length === 1 ? "" : "es"} (gsd/quick/*).`); + } + + if (summary.length === 0) { + const nonQuickCount = branches.filter((b) => !b.startsWith("gsd/quick/")).length; + ctx.ui.notify( + nonQuickCount > 0 + ? `${nonQuickCount} GSD branch${nonQuickCount === 1 ? "" : "es"} found, none merged into ${mainBranch} yet.` + : "No non-quick GSD branches to clean up.", + "info", + ); return; } - let deleted = 0; - for (const branch of merged) { - try { - nativeBranchDelete(basePath, branch, false); - deleted++; - } catch { /* skip branches that can't be deleted */ } - } - - ctx.ui.notify(`Cleaned up ${deleted} merged branches. ${branches.length - deleted} remain.`, "success"); + ctx.ui.notify(summary.join(" "), "success"); } export async function handleCleanupSnapshots(ctx: ExtensionCommandContext, basePath: string): Promise { @@ -52,7 +105,7 @@ export async function handleCleanupSnapshots(ctx: ExtensionCommandContext, baseP try { refs = nativeForEachRef(basePath, "refs/gsd/snapshots/"); } catch { - ctx.ui.notify("No snapshot refs found.", "info"); + ctx.ui.notify("No snapshot refs to clean up.", "info"); return; } @@ -76,13 +129,90 @@ export async function handleCleanupSnapshots(ctx: ExtensionCommandContext, baseP try { nativeUpdateRef(basePath, old); pruned++; - } catch { /* skip */ } + } catch { + /* skip individual failures */ + } } } ctx.ui.notify(`Pruned ${pruned} old snapshot refs. ${refs.length - pruned} remain.`, "success"); } +export async function handleCleanupWorktrees(ctx: ExtensionCommandContext, basePath: string): Promise { + const { getAllWorktreeHealth, formatWorktreeStatusLine } = await import("./worktree-health.js"); + const { removeWorktree } = await import("./worktree-manager.js"); + const { sep } = await import("node:path"); + + let statuses; + try { + statuses = getAllWorktreeHealth(basePath); + } catch { + ctx.ui.notify("Failed to inspect worktrees.", "error"); + return; + } + + if (statuses.length === 0) { + ctx.ui.notify("No GSD worktrees found.", "info"); + return; + } + + const safeToRemove = statuses.filter(s => s.safeToRemove); + const stale = statuses.filter(s => s.stale && !s.safeToRemove); + const active = statuses.filter(s => !s.safeToRemove && !s.stale); + + const lines: string[] = []; + lines.push(`${statuses.length} worktree${statuses.length === 1 ? "" : "s"} found.`); + lines.push(""); + + if (safeToRemove.length > 0) { + lines.push(`Safe to remove (${safeToRemove.length}) — merged into main, clean:`); + const cwd = process.cwd(); + let removed = 0; + for (const s of safeToRemove) { + const wt = s.worktree; + const isCwd = wt.path === cwd || cwd.startsWith(wt.path + sep); + if (isCwd) { + lines.push(` ⊘ ${wt.name} (skipped — current working directory)`); + continue; + } + try { + removeWorktree(basePath, wt.name, { deleteBranch: true }); + lines.push(` ✓ ${wt.name} removed (branch ${wt.branch} deleted)`); + removed++; + } catch { + lines.push(` ✗ ${wt.name} failed to remove`); + } + } + if (removed > 0) { + lines.push(""); + lines.push(`Removed ${removed} merged worktree${removed === 1 ? "" : "s"}.`); + } + lines.push(""); + } + + if (stale.length > 0) { + lines.push(`Stale (${stale.length}) — no recent commits, not merged (review manually):`); + for (const s of stale) { + lines.push(` ⚠ ${s.worktree.name} ${formatWorktreeStatusLine(s)}`); + } + lines.push(""); + } + + if (active.length > 0) { + lines.push(`Active (${active.length}) — in progress:`); + for (const s of active) { + lines.push(` ● ${s.worktree.name} ${formatWorktreeStatusLine(s)}`); + } + lines.push(""); + } + + if (safeToRemove.length === 0 && stale.length === 0) { + lines.push("All worktrees are active — nothing to clean up."); + } + + ctx.ui.notify(lines.join("\n"), safeToRemove.length > 0 ? "success" : "info"); +} + export async function handleSkip(unitArg: string, ctx: ExtensionCommandContext, basePath: string): Promise { if (!unitArg) { ctx.ui.notify("Usage: /gsd skip (e.g., /gsd skip execute-task/M001/S01/T03 or /gsd skip T03)", "info"); diff --git a/src/resources/extensions/gsd/commands/catalog.ts b/src/resources/extensions/gsd/commands/catalog.ts index 4709a2769..74c25afcb 100644 --- a/src/resources/extensions/gsd/commands/catalog.ts +++ b/src/resources/extensions/gsd/commands/catalog.ts @@ -143,8 +143,9 @@ const NESTED_COMPLETIONS: CompletionMap = { { cmd: "--html --all", desc: "Export all milestones as HTML" }, ], cleanup: [ - { cmd: "branches", desc: "Remove merged milestone branches" }, + { cmd: "branches", desc: "Remove merged milestone and legacy branches" }, { cmd: "snapshots", desc: "Remove old execution snapshots" }, + { cmd: "worktrees", desc: "Remove merged/safe-to-delete worktrees" }, { cmd: "projects", desc: "Audit orphaned ~/.gsd/projects/ state directories" }, { cmd: "projects --fix", desc: "Delete orphaned project state directories (cannot be undone)" }, ], diff --git a/src/resources/extensions/gsd/commands/handlers/ops.ts b/src/resources/extensions/gsd/commands/handlers/ops.ts index 612fce50d..5108bb0ad 100644 --- a/src/resources/extensions/gsd/commands/handlers/ops.ts +++ b/src/resources/extensions/gsd/commands/handlers/ops.ts @@ -6,7 +6,7 @@ import { handleConfig } from "../../commands-config.js"; import { handleDoctor, handleCapture, handleKnowledge, handleRunHook, handleSkillHealth, handleSteer, handleTriage, handleUpdate } from "../../commands-handlers.js"; import { handleInspect } from "../../commands-inspect.js"; import { handleLogs } from "../../commands-logs.js"; -import { handleCleanupBranches, handleCleanupSnapshots, handleSkip, handleCleanupProjects } from "../../commands-maintenance.js"; +import { handleCleanupBranches, handleCleanupSnapshots, handleSkip, handleCleanupProjects, handleCleanupWorktrees } from "../../commands-maintenance.js"; import { handleExport } from "../../export.js"; import { handleHistory } from "../../history.js"; import { handleUndo } from "../../undo.js"; @@ -73,6 +73,10 @@ export async function handleOpsCommand(trimmed: string, ctx: ExtensionCommandCon await handleCleanupProjects(trimmed.replace(/^cleanup projects\s*/, "").trim(), ctx); return true; } + if (trimmed === "cleanup worktrees") { + await handleCleanupWorktrees(ctx, projectRoot()); + return true; + } if (trimmed === "cleanup") { await handleCleanupBranches(ctx, projectRoot()); await handleCleanupSnapshots(ctx, projectRoot()); diff --git a/src/resources/extensions/gsd/doctor-checks.ts b/src/resources/extensions/gsd/doctor-checks.ts index 8ef875a3a..64eb0a921 100644 --- a/src/resources/extensions/gsd/doctor-checks.ts +++ b/src/resources/extensions/gsd/doctor-checks.ts @@ -10,9 +10,10 @@ import { saveFile } from "./files.js"; import { listWorktrees, resolveGitDir, worktreesDir } from "./worktree-manager.js"; import { abortAndReset } from "./git-self-heal.js"; import { RUNTIME_EXCLUSION_PATHS, resolveMilestoneIntegrationBranch, writeIntegrationBranch } from "./git-service.js"; -import { nativeIsRepo, nativeBranchExists, nativeWorktreeList, nativeWorktreeRemove, nativeBranchList, nativeBranchDelete, nativeLsFiles, nativeRmCached } from "./native-git-bridge.js"; +import { nativeIsRepo, nativeBranchExists, nativeWorktreeList, nativeWorktreeRemove, nativeBranchList, nativeBranchDelete, nativeLsFiles, nativeRmCached, nativeForEachRef, nativeUpdateRef } from "./native-git-bridge.js"; import { readCrashLock, isLockProcessAlive, clearLock } from "./crash-recovery.js"; import { ensureGitignore } from "./gitignore.js"; +import { getAllWorktreeHealth } from "./worktree-health.js"; import { readAllSessionStatuses, isSessionStale, removeSessionStatus } from "./session-status-io.js"; import { recoverFailedMigration } from "./migrate-external.js"; import { loadEffectiveGSDPreferences } from "./preferences.js"; @@ -203,16 +204,30 @@ export async function checkGitHealth( // ── Legacy slice branches ────────────────────────────────────────────── try { - const branchList = nativeBranchList(basePath, "gsd/*/*"); + const branchList = nativeBranchList(basePath, "gsd/*/*") + .filter((branch) => !branch.startsWith("gsd/quick/")); if (branchList.length > 0) { issues.push({ severity: "info", code: "legacy_slice_branches", scope: "project", unitId: "project", - message: `${branchList.length} legacy slice branch(es) found: ${branchList.slice(0, 3).join(", ")}${branchList.length > 3 ? "..." : ""}. These are no longer used (branchless architecture). Delete with: git branch -D ${branchList.join(" ")}`, - fixable: false, + message: `${branchList.length} legacy slice branch(es) found: ${branchList.slice(0, 3).join(", ")}${branchList.length > 3 ? "..." : ""}. These are no longer used (branchless architecture).`, + fixable: true, }); + + if (shouldFix("legacy_slice_branches")) { + let deleted = 0; + for (const branch of branchList) { + try { + nativeBranchDelete(basePath, branch, true); + deleted++; + } catch { /* skip branches that can't be deleted */ } + } + if (deleted > 0) { + fixesApplied.push(`deleted ${deleted} legacy slice branch(es)`); + } + } } } catch { // git branch list failed — skip @@ -306,6 +321,82 @@ export async function checkGitHealth( } catch { // Non-fatal — orphaned worktree directory check failed } + + // ── Worktree lifecycle checks ────────────────────────────────────────── + // Check GSD-managed worktrees for: merged branches, stale work, dirty + // state, and unpushed commits. Only worktrees under .gsd/worktrees/. + try { + const healthStatuses = getAllWorktreeHealth(basePath); + const cwd = process.cwd(); + + for (const health of healthStatuses) { + const wt = health.worktree; + const isCwd = wt.path === cwd || cwd.startsWith(wt.path + sep); + + // Branch fully merged into main — safe to remove + if (health.mergedIntoMain) { + issues.push({ + severity: "info", + code: "worktree_branch_merged", + scope: "project", + unitId: wt.name, + message: `Worktree "${wt.name}" (branch ${wt.branch}) is fully merged into main${health.safeToRemove ? " — safe to remove" : ""}`, + fixable: health.safeToRemove, + }); + + if (health.safeToRemove && shouldFix("worktree_branch_merged") && !isCwd) { + try { + const { removeWorktree } = await import("./worktree-manager.js"); + removeWorktree(basePath, wt.name, { deleteBranch: true, branch: wt.branch }); + fixesApplied.push(`removed merged worktree "${wt.name}" and deleted branch ${wt.branch}`); + } catch { + fixesApplied.push(`failed to remove merged worktree "${wt.name}"`); + } + } + // If merged, skip the stale/dirty/unpushed checks — they're irrelevant + continue; + } + + // Stale: no commits in N days, not merged + if (health.stale) { + const days = Math.floor(health.lastCommitAgeDays); + issues.push({ + severity: "warning", + code: "worktree_stale", + scope: "project", + unitId: wt.name, + message: `Worktree "${wt.name}" has had no commits in ${days} day${days === 1 ? "" : "s"}`, + fixable: false, + }); + } + + // Dirty: uncommitted changes in a worktree (only flag on stale worktrees to avoid noise) + if (health.dirty && health.stale) { + issues.push({ + severity: "warning", + code: "worktree_dirty", + scope: "project", + unitId: wt.name, + message: `Worktree "${wt.name}" has ${health.dirtyFileCount} uncommitted file${health.dirtyFileCount === 1 ? "" : "s"} and is stale`, + fixable: false, + }); + } + + // Unpushed: commits not on any remote (only flag on stale worktrees to avoid noise) + if (health.unpushedCommits > 0 && health.stale) { + issues.push({ + severity: "warning", + code: "worktree_unpushed", + scope: "project", + unitId: wt.name, + message: `Worktree "${wt.name}" has ${health.unpushedCommits} unpushed commit${health.unpushedCommits === 1 ? "" : "s"}`, + fixable: false, + }); + } + } + } catch { + // Non-fatal — worktree lifecycle check failed + } } // ── Runtime Health Checks ────────────────────────────────────────────────── @@ -795,6 +886,50 @@ export async function checkRuntimeHealth( } catch { // Non-fatal — large file scan failed } + + // ── Snapshot ref bloat ──────────────────────────────────────────────── + // refs/gsd/snapshots/ accumulate over time. Prune to newest 5 per label + // when total count exceeds threshold. + try { + if (nativeIsRepo(basePath)) { + const refs = nativeForEachRef(basePath, "refs/gsd/snapshots/"); + if (refs.length > 50) { + issues.push({ + severity: "warning", + code: "snapshot_ref_bloat", + scope: "project", + unitId: "project", + message: `${refs.length} snapshot refs found under refs/gsd/snapshots/ — pruning to newest 5 per label will reclaim git storage`, + fixable: true, + }); + + if (shouldFix("snapshot_ref_bloat")) { + const byLabel = new Map(); + for (const ref of refs) { + const parts = ref.split("/"); + const label = parts.slice(0, -1).join("/"); + if (!byLabel.has(label)) byLabel.set(label, []); + byLabel.get(label)!.push(ref); + } + let pruned = 0; + for (const [, labelRefs] of byLabel) { + const sorted = labelRefs.sort(); + for (const old of sorted.slice(0, -5)) { + try { + nativeUpdateRef(basePath, old); + pruned++; + } catch { /* skip */ } + } + } + if (pruned > 0) { + fixesApplied.push(`pruned ${pruned} old snapshot ref(s)`); + } + } + } + } + } catch { + // Non-fatal — snapshot ref check failed + } } /** diff --git a/src/resources/extensions/gsd/doctor-types.ts b/src/resources/extensions/gsd/doctor-types.ts index b6428b992..ecbf78499 100644 --- a/src/resources/extensions/gsd/doctor-types.ts +++ b/src/resources/extensions/gsd/doctor-types.ts @@ -61,6 +61,13 @@ export type DoctorIssueCode = | "task_file_not_in_plan" | "stale_replan_file" | "future_timestamp" + // Worktree lifecycle checks + | "worktree_branch_merged" + | "worktree_stale" + | "worktree_dirty" + | "worktree_unpushed" + // Snapshot ref bloat + | "snapshot_ref_bloat" // Runtime data integrity | "orphaned_project_state" | "metrics_ledger_bloat" diff --git a/src/resources/extensions/gsd/native-git-bridge.ts b/src/resources/extensions/gsd/native-git-bridge.ts index ccc82bfcc..2048aa993 100644 --- a/src/resources/extensions/gsd/native-git-bridge.ts +++ b/src/resources/extensions/gsd/native-git-bridge.ts @@ -1086,6 +1086,62 @@ export function isNativeGitAvailable(): boolean { return loadNative() !== null; } +/** + * Check if a commit/branch is an ancestor of another. + * Returns true if `ancestor` is reachable from `descendant`. + * Fallback: `git merge-base --is-ancestor`. + */ +export function nativeIsAncestor(basePath: string, ancestor: string, descendant: string): boolean { + try { + execFileSync("git", ["merge-base", "--is-ancestor", ancestor, descendant], { + cwd: basePath, + stdio: ["ignore", "pipe", "pipe"], + env: GIT_NO_PROMPT_ENV, + }); + return true; + } catch { + return false; + } +} + +/** + * Get the Unix epoch (seconds) of the latest commit on a ref. + * Returns 0 if the ref doesn't exist or has no commits. + * Fallback: `git log -1 --format=%ct `. + */ +export function nativeLastCommitEpoch(basePath: string, ref: string): number { + try { + const result = execFileSync("git", ["log", "-1", "--format=%ct", ref], { + cwd: basePath, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + env: GIT_NO_PROMPT_ENV, + }).trim(); + return parseInt(result, 10) || 0; + } catch { + return 0; + } +} + +/** + * Count commits on `branch` that are not on any remote tracking branch. + * Returns the count of unpushed commits, or -1 if the branch has no upstream. + * Fallback: `git rev-list --not --remotes`. + */ +export function nativeUnpushedCount(basePath: string, branch: string): number { + try { + const result = execFileSync("git", ["rev-list", branch, "--not", "--remotes", "--count"], { + cwd: basePath, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + env: GIT_NO_PROMPT_ENV, + }).trim(); + return parseInt(result, 10) || 0; + } catch { + return -1; + } +} + // ─── Re-exports for type consumers ────────────────────────────────────── export type { diff --git a/src/resources/extensions/gsd/tests/doctor-git.test.ts b/src/resources/extensions/gsd/tests/doctor-git.test.ts index 9942d67bf..10e12e4d9 100644 --- a/src/resources/extensions/gsd/tests/doctor-git.test.ts +++ b/src/resources/extensions/gsd/tests/doctor-git.test.ts @@ -521,6 +521,117 @@ async function main(): Promise { console.log("\n=== worktree_directory_orphaned (symlinked .gsd — skipped on Windows) ==="); } + // ─── Test: worktree_branch_merged detection & fix ────────────────── + if (process.platform !== "win32") { + console.log("\n=== worktree_branch_merged ==="); + { + const dir = createRepoWithActiveMilestone(); + cleanups.push(dir); + + // Create a worktree, make a commit, then merge the branch into main + mkdirSync(join(dir, ".gsd", "worktrees"), { recursive: true }); + run("git worktree add -b worktree/merged-feature .gsd/worktrees/merged-feature", dir); + const wtPath = join(dir, ".gsd", "worktrees", "merged-feature"); + writeFileSync(join(wtPath, "feature.txt"), "feature\n"); + run("git add -A", wtPath); + run("git -c user.email=test@test.com -c user.name=Test commit -m \"feature work\"", wtPath); + + // Merge the worktree branch into main + run("git merge worktree/merged-feature --no-edit", dir); + + const detect = await runGSDDoctor(dir); + const mergedIssues = detect.issues.filter(i => i.code === "worktree_branch_merged"); + assertTrue(mergedIssues.length > 0, "detects merged worktree branch"); + assertTrue(mergedIssues[0]?.message.includes("safe to remove"), "message says safe to remove"); + assertTrue(mergedIssues[0]?.fixable === true, "merged worktree is fixable"); + + // Fix should remove the worktree + const fixed = await runGSDDoctor(dir, { fix: true }); + assertTrue(fixed.fixesApplied.some(f => f.includes("removed merged worktree")), "fix removes merged worktree"); + assertTrue(!existsSync(wtPath), "worktree directory removed after fix"); + } + } else { + console.log("\n=== worktree_branch_merged (skipped on Windows) ==="); + } + + // ─── Test: merged milestone/* worktree removes milestone branch ──── + if (process.platform !== "win32") { + console.log("\n=== worktree_branch_merged (milestone branch cleanup) ==="); + { + const dir = createRepoWithActiveMilestone(); + cleanups.push(dir); + + mkdirSync(join(dir, ".gsd", "worktrees"), { recursive: true }); + run("git worktree add -b milestone/M001 .gsd/worktrees/M001", dir); + const wtPath = join(dir, ".gsd", "worktrees", "M001"); + writeFileSync(join(wtPath, "feature.txt"), "feature\n"); + run("git add -A", wtPath); + run("git -c user.email=test@test.com -c user.name=Test commit -m \"feature work\"", wtPath); + run("git merge milestone/M001 --no-edit", dir); + + const fixed = await runGSDDoctor(dir, { fix: true }); + assertTrue(fixed.fixesApplied.some(f => f.includes("removed merged worktree")), "fix removes merged milestone worktree"); + assertTrue(!existsSync(wtPath), "milestone worktree directory removed after fix"); + + const branches = run("git branch --list milestone/M001", dir); + assertEq(branches, "", "milestone/M001 branch deleted after merged worktree cleanup"); + } + } else { + console.log("\n=== worktree_branch_merged (milestone branch cleanup — skipped on Windows) ==="); + } + + // ─── Test: worktree_branch_merged NOT flagged for unmerged worktree ─ + if (process.platform !== "win32") { + console.log("\n=== worktree_branch_merged (no false positive) ==="); + { + const dir = createRepoWithActiveMilestone(); + cleanups.push(dir); + + mkdirSync(join(dir, ".gsd", "worktrees"), { recursive: true }); + run("git worktree add -b worktree/active-feature .gsd/worktrees/active-feature", dir); + const wtPath = join(dir, ".gsd", "worktrees", "active-feature"); + writeFileSync(join(wtPath, "wip.txt"), "work in progress\n"); + run("git add -A", wtPath); + run("git -c user.email=test@test.com -c user.name=Test commit -m \"wip\"", wtPath); + + // Do NOT merge — branch is ahead of main + const detect = await runGSDDoctor(dir); + const mergedIssues = detect.issues.filter(i => i.code === "worktree_branch_merged"); + assertEq(mergedIssues.length, 0, "unmerged worktree NOT flagged as merged"); + } + } else { + console.log("\n=== worktree_branch_merged (no false positive — skipped on Windows) ==="); + } + + // ─── Test: legacy_slice_branches now fixable ─────────────────────── + if (process.platform !== "win32") { + console.log("\n=== legacy_slice_branches (fixable) ==="); + { + const dir = createRepoWithActiveMilestone(); + cleanups.push(dir); + + // Create legacy gsd/M001/S01 branches + run("git branch gsd/M001/S01", dir); + run("git branch gsd/M001/S02", dir); + // Active quick branches share gsd/*/* shape and must NOT be deleted. + run("git branch gsd/quick/1-fix-typo", dir); + + const detect = await runGSDDoctor(dir); + const legacyIssues = detect.issues.filter(i => i.code === "legacy_slice_branches"); + assertTrue(legacyIssues.length > 0, "detects legacy slice branches"); + assertTrue(legacyIssues[0]?.fixable === true, "legacy branches are fixable"); + + const fixed = await runGSDDoctor(dir, { fix: true }); + assertTrue(fixed.fixesApplied.some(f => f.includes("legacy slice branch")), "fix deletes legacy branches"); + + // Verify branches are gone + const remaining = run("git branch --list gsd/*/*", dir); + assertEq(remaining, "gsd/quick/1-fix-typo", "quick branch preserved; legacy branches removed"); + } + } else { + console.log("\n=== legacy_slice_branches (fixable — skipped on Windows) ==="); + } + } finally { for (const dir of cleanups) { try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } diff --git a/src/resources/extensions/gsd/tests/worktree-health.test.ts b/src/resources/extensions/gsd/tests/worktree-health.test.ts new file mode 100644 index 000000000..e6580ecd9 --- /dev/null +++ b/src/resources/extensions/gsd/tests/worktree-health.test.ts @@ -0,0 +1,186 @@ +/** + * worktree-health.test.ts — Unit tests for worktree health status computation. + * + * Creates real temp git repos with GSD worktrees in various states and verifies + * that getWorktreeHealth and formatWorktreeStatusLine return correct results. + */ + +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, realpathSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; + +import { getWorktreeHealth, formatWorktreeStatusLine } from "../worktree-health.ts"; +import { listWorktrees } from "../worktree-manager.ts"; +import { createTestContext } from "./test-helpers.ts"; + +const { assertEq, assertTrue, report } = createTestContext(); + +function run(cmd: string, cwd: string): string { + return execSync(cmd, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim(); +} + +function createBaseRepo(): string { + const dir = realpathSync(mkdtempSync(join(tmpdir(), "wt-health-test-"))); + run("git init", dir); + run("git config user.email test@test.com", dir); + run("git config user.name Test", dir); + writeFileSync(join(dir, "README.md"), "# test\n"); + run("git add .", dir); + run("git commit -m init", dir); + run("git branch -M main", dir); + return dir; +} + +async function main(): Promise { + // Skip all tests on Windows — git worktree path resolution issues + if (process.platform === "win32") { + console.log("(all worktree-health tests skipped on Windows)"); + report(); + return; + } + + const cleanups: string[] = []; + + try { + // ─── Test: merged worktree is detected as merged + safe to remove ── + console.log("\n=== worktree health: merged worktree ==="); + { + const dir = createBaseRepo(); + cleanups.push(dir); + + mkdirSync(join(dir, ".gsd", "worktrees"), { recursive: true }); + run("git worktree add -b worktree/done-feature .gsd/worktrees/done-feature", dir); + const wtPath = join(dir, ".gsd", "worktrees", "done-feature"); + writeFileSync(join(wtPath, "done.txt"), "done\n"); + run("git add -A", wtPath); + run("git -c user.email=test@test.com -c user.name=Test commit -m \"done\"", wtPath); + run("git merge worktree/done-feature --no-edit", dir); + + const worktrees = listWorktrees(dir); + const wt = worktrees.find(w => w.name === "done-feature"); + assertTrue(!!wt, "worktree found"); + + const health = getWorktreeHealth(dir, wt!); + assertTrue(health.mergedIntoMain, "branch detected as merged"); + assertTrue(!health.dirty, "not dirty"); + assertTrue(health.safeToRemove, "safe to remove"); + + const line = formatWorktreeStatusLine(health); + assertTrue(line.includes("merged"), "status line mentions merged"); + assertTrue(line.includes("safe to remove"), "status line mentions safe to remove"); + } + + // ─── Test: unmerged worktree with dirty files ────────────────────── + console.log("\n=== worktree health: dirty unmerged worktree ==="); + { + const dir = createBaseRepo(); + cleanups.push(dir); + + mkdirSync(join(dir, ".gsd", "worktrees"), { recursive: true }); + run("git worktree add -b worktree/dirty-wip .gsd/worktrees/dirty-wip", dir); + const wtPath = join(dir, ".gsd", "worktrees", "dirty-wip"); + // Make a commit so the branch diverges from main, then leave dirty state + writeFileSync(join(wtPath, "committed.txt"), "committed\n"); + run("git add -A", wtPath); + run("git -c user.email=test@test.com -c user.name=Test commit -m \"diverge\"", wtPath); + // Now leave an uncommitted file + writeFileSync(join(wtPath, "uncommitted.txt"), "wip\n"); + + const worktrees = listWorktrees(dir); + const wt = worktrees.find(w => w.name === "dirty-wip"); + assertTrue(!!wt, "worktree found"); + + const health = getWorktreeHealth(dir, wt!); + assertTrue(!health.mergedIntoMain, "not merged"); + assertTrue(health.dirty, "dirty detected"); + assertTrue(health.dirtyFileCount > 0, "dirty file count > 0"); + assertTrue(!health.safeToRemove, "not safe to remove"); + } + + // ─── Test: unmerged worktree with unpushed commits ───────────────── + console.log("\n=== worktree health: unpushed commits ==="); + { + const dir = createBaseRepo(); + cleanups.push(dir); + + mkdirSync(join(dir, ".gsd", "worktrees"), { recursive: true }); + run("git worktree add -b worktree/unpushed .gsd/worktrees/unpushed", dir); + const wtPath = join(dir, ".gsd", "worktrees", "unpushed"); + writeFileSync(join(wtPath, "feature.txt"), "feature\n"); + run("git add -A", wtPath); + run("git -c user.email=test@test.com -c user.name=Test commit -m \"feature\"", wtPath); + + const worktrees = listWorktrees(dir); + const wt = worktrees.find(w => w.name === "unpushed"); + assertTrue(!!wt, "worktree found"); + + const health = getWorktreeHealth(dir, wt!); + assertTrue(!health.mergedIntoMain, "not merged"); + assertTrue(health.unpushedCommits > 0, "unpushed commits detected"); + assertTrue(!health.safeToRemove, "not safe to remove"); + } + + // ─── Test: stale detection with short threshold ──────────────────── + console.log("\n=== worktree health: stale detection ==="); + { + const dir = createBaseRepo(); + cleanups.push(dir); + + mkdirSync(join(dir, ".gsd", "worktrees"), { recursive: true }); + run("git worktree add -b worktree/stale-test .gsd/worktrees/stale-test", dir); + // Diverge from main so the branch is not "merged" + const wtPath = join(dir, ".gsd", "worktrees", "stale-test"); + writeFileSync(join(wtPath, "stale.txt"), "stale\n"); + run("git add -A", wtPath); + run("git -c user.email=test@test.com -c user.name=Test commit -m \"stale work\"", wtPath); + + const worktrees = listWorktrees(dir); + const wt = worktrees.find(w => w.name === "stale-test"); + assertTrue(!!wt, "worktree found"); + + // With staleDays=0, any worktree should be stale (commit was just now, but threshold is 0) + // Actually, a just-created worktree has lastCommitAgeDays ~0 which is >= 0 + const health = getWorktreeHealth(dir, wt!, 0); + assertTrue(health.stale, "stale with 0-day threshold"); + assertTrue(health.lastCommitAgeDays >= 0, "last commit age is non-negative"); + + // With staleDays=9999, should NOT be stale + const healthNotStale = getWorktreeHealth(dir, wt!, 9999); + assertTrue(!healthNotStale.stale, "not stale with high threshold"); + } + + // ─── Test: formatWorktreeStatusLine for clean active worktree ────── + console.log("\n=== worktree health: format clean active worktree ==="); + { + const dir = createBaseRepo(); + cleanups.push(dir); + + mkdirSync(join(dir, ".gsd", "worktrees"), { recursive: true }); + run("git worktree add -b worktree/clean-active .gsd/worktrees/clean-active", dir); + // Diverge from main so it's not "merged" + const wtPath = join(dir, ".gsd", "worktrees", "clean-active"); + writeFileSync(join(wtPath, "active.txt"), "active\n"); + run("git add -A", wtPath); + run("git -c user.email=test@test.com -c user.name=Test commit -m \"active work\"", wtPath); + + const worktrees = listWorktrees(dir); + const wt = worktrees.find(w => w.name === "clean-active"); + assertTrue(!!wt, "worktree found"); + + const health = getWorktreeHealth(dir, wt!, 9999); // high threshold so not stale + const line = formatWorktreeStatusLine(health); + // Should show last commit age since it's not merged and not stale + assertTrue(line.includes("last commit"), "shows last commit age for active worktree"); + } + + } finally { + for (const dir of cleanups) { + try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } + } + } + + report(); +} + +main(); diff --git a/src/resources/extensions/gsd/worktree-command.ts b/src/resources/extensions/gsd/worktree-command.ts index 672fd8a65..4784d9b4f 100644 --- a/src/resources/extensions/gsd/worktree-command.ts +++ b/src/resources/extensions/gsd/worktree-command.ts @@ -512,6 +512,14 @@ async function handleList( return; } + // Compute health status for each worktree + const { getAllWorktreeHealth, formatWorktreeStatusLine } = await import("./worktree-health.js"); + const healthMap = new Map[number]>(); + try { + const statuses = getAllWorktreeHealth(mainBase); + for (const s of statuses) healthMap.set(s.worktree.name, s); + } catch { /* health check failed — show list without status */ } + const cwd = process.cwd(); const lines = [CLR.header("GSD Worktrees"), ""]; for (const wt of worktrees) { @@ -528,6 +536,19 @@ async function handleList( lines.push(` ${styledName}${badge}`); lines.push(` ${CLR.label("branch")} ${CLR.branch(wt.branch)}`); lines.push(` ${CLR.label("path")} ${CLR.path(wt.path)}`); + + // Show health status line + const health = healthMap.get(wt.name); + if (health) { + const statusLine = formatWorktreeStatusLine(health); + const statusColor = health.safeToRemove + ? CLR.ok(statusLine) + : health.stale || health.dirty + ? CLR.warn(statusLine) + : CLR.muted(statusLine); + lines.push(` ${CLR.label("status")} ${statusColor}`); + } + lines.push(""); } diff --git a/src/resources/extensions/gsd/worktree-health.ts b/src/resources/extensions/gsd/worktree-health.ts new file mode 100644 index 000000000..ec40c3ba9 --- /dev/null +++ b/src/resources/extensions/gsd/worktree-health.ts @@ -0,0 +1,178 @@ +/** + * Worktree Health — lifecycle status helpers for GSD-managed worktrees. + * + * Used by doctor-checks.ts for health audits and by worktree-command.ts + * for the enhanced `/worktree list` display. + * + * Only inspects worktrees under .gsd/worktrees/ — GSD owns what GSD creates. + */ + +import { existsSync } from "node:fs"; +import { + nativeDetectMainBranch, + nativeHasChanges, + nativeIsAncestor, + nativeLastCommitEpoch, + nativeUnpushedCount, + nativeWorkingTreeStatus, +} from "./native-git-bridge.js"; +import { listWorktrees, type WorktreeInfo } from "./worktree-manager.js"; + +// ─── Types ───────────────────────────────────────────────────────────────── + +export interface WorktreeHealthStatus { + /** The worktree info from worktree-manager */ + worktree: WorktreeInfo; + /** Whether the worktree branch is fully merged into main */ + mergedIntoMain: boolean; + /** Whether the worktree has uncommitted changes (staged or unstaged) */ + dirty: boolean; + /** Number of dirty files (0 if clean) */ + dirtyFileCount: number; + /** Number of commits on the branch not pushed to any remote */ + unpushedCommits: number; + /** Unix epoch (seconds) of the last commit on the branch. 0 if unknown. */ + lastCommitEpoch: number; + /** Age of the last commit in days (fractional). -1 if unknown. */ + lastCommitAgeDays: number; + /** Whether we consider this worktree stale (no commits in staleDays, not merged) */ + stale: boolean; + /** Whether this worktree is safe to auto-remove (merged, clean, no unpushed) */ + safeToRemove: boolean; +} + +// ─── Configuration ───────────────────────────────────────────────────────── + +/** Default number of days without commits before a worktree is considered stale. */ +const DEFAULT_STALE_DAYS = 14; + +// ─── Core ────────────────────────────────────────────────────────────────── + +/** + * Compute the health status for a single worktree. + * + * @param basePath — the main project root (not the worktree path) + * @param wt — worktree info from listWorktrees() + * @param staleDays — days without commits to consider stale (default: 14) + */ +export function getWorktreeHealth( + basePath: string, + wt: WorktreeInfo, + staleDays = DEFAULT_STALE_DAYS, +): WorktreeHealthStatus { + const mainBranch = nativeDetectMainBranch(basePath); + + // Merge status: is the worktree branch fully contained in main? + let mergedIntoMain = false; + try { + mergedIntoMain = nativeIsAncestor(basePath, wt.branch, mainBranch); + } catch { /* default false */ } + + // Dirty status: check from inside the worktree itself + let dirty = false; + let dirtyFileCount = 0; + if (wt.exists && existsSync(wt.path)) { + try { + dirty = nativeHasChanges(wt.path); + if (dirty) { + const status = nativeWorkingTreeStatus(wt.path); + dirtyFileCount = status.split("\n").filter(l => l.trim()).length; + } + } catch { /* default clean */ } + } + + // Unpushed commits + let unpushedCommits = 0; + try { + const count = nativeUnpushedCount(basePath, wt.branch); + unpushedCommits = count >= 0 ? count : 0; + } catch { /* default 0 */ } + + // Last commit age + let lastCommitEpoch = 0; + try { + lastCommitEpoch = nativeLastCommitEpoch(basePath, wt.branch); + } catch { /* default 0 */ } + + const nowEpoch = Math.floor(Date.now() / 1000); + const lastCommitAgeDays = lastCommitEpoch > 0 + ? (nowEpoch - lastCommitEpoch) / 86400 + : -1; + + // Stale: old, not merged + const stale = !mergedIntoMain + && lastCommitAgeDays >= staleDays; + + // Safe to remove: merged into main and no dirty files. + // Unpushed commits don't matter when the branch is merged — the work is already in main. + const safeToRemove = mergedIntoMain && !dirty; + + return { + worktree: wt, + mergedIntoMain, + dirty, + dirtyFileCount, + unpushedCommits, + lastCommitEpoch, + lastCommitAgeDays, + stale, + safeToRemove, + }; +} + +/** + * Compute health status for all GSD-managed worktrees. + * + * @param basePath — the main project root + * @param staleDays — days without commits to consider stale (default: 14) + */ +export function getAllWorktreeHealth( + basePath: string, + staleDays = DEFAULT_STALE_DAYS, +): WorktreeHealthStatus[] { + const worktrees = listWorktrees(basePath); + return worktrees.map(wt => getWorktreeHealth(basePath, wt, staleDays)); +} + +/** + * Format a human-readable status line for a worktree health entry. + * Used by `/worktree list` for inline status display. + */ +export function formatWorktreeStatusLine(health: WorktreeHealthStatus): string { + const parts: string[] = []; + + if (health.mergedIntoMain) { + parts.push("✓ merged into main"); + if (health.safeToRemove) { + parts.push("safe to remove"); + } + } + + if (health.dirty) { + parts.push(`${health.dirtyFileCount} uncommitted file${health.dirtyFileCount === 1 ? "" : "s"}`); + } + + if (health.unpushedCommits > 0) { + parts.push(`${health.unpushedCommits} unpushed commit${health.unpushedCommits === 1 ? "" : "s"}`); + } + + if (health.stale) { + const days = Math.floor(health.lastCommitAgeDays); + parts.push(`no commits in ${days} day${days === 1 ? "" : "s"}`); + } else if (health.lastCommitAgeDays >= 0 && !health.mergedIntoMain) { + const age = health.lastCommitAgeDays; + if (age < 1) { + const hours = Math.floor(age * 24); + parts.push(`last commit ${hours}h ago`); + } else { + const days = Math.floor(age); + parts.push(`last commit ${days}d ago`); + } + } + + if (parts.length === 0) { + return "clean"; + } + + return parts.join(" · "); +}