@workit-dev/core 0.0.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +22 -43
- package/package.json +56 -6
- package/schemas/config.json +74 -0
- package/schemas/vcs.json +79 -0
- package/schemas/workspaces.json +1029 -0
- package/schemas/youtrack.json +84 -0
- package/scripts/analyze-release-scope.ts +215 -0
- package/scripts/bundled-deps.json +131 -0
- package/scripts/fixtures/sample-plan.md +15 -0
- package/scripts/fixtures/sample-sdd/progress.md +2 -0
- package/scripts/install-codex-plugin.sh +28 -0
- package/scripts/install-cursor-plugin.sh +146 -0
- package/scripts/install-opencode-plugin.sh +79 -0
- package/scripts/install-pi-package.sh +56 -0
- package/scripts/lib/config-dir.sh +28 -0
- package/scripts/publish-changed-packages.ts +395 -0
- package/scripts/rewrite-workspace-deps.ts +53 -0
- package/scripts/run-cursor-mcp.sh +6 -0
- package/scripts/sync-release-manifests.ts +82 -0
- package/scripts/sync-runtime.sh +142 -0
- package/scripts/validate-cursor-marketplace.ts +171 -0
- package/scripts/verify-bundle-sources.ts +134 -0
- package/scripts/write-schemas.ts +16 -0
- package/skills/workit-architecture/SKILL.md +72 -0
- package/skills/workit-architecture/agents/openai.yaml +3 -0
- package/skills/workit-architecture/references/instruction-files.md +52 -0
- package/skills/workit-architecture/references/vocabulary.md +53 -0
- package/skills/workit-bdd/SKILL.md +53 -0
- package/skills/workit-continue/SKILL.md +54 -0
- package/skills/workit-debug/SKILL.md +45 -0
- package/skills/workit-deslop/SKILL.md +58 -0
- package/skills/workit-fanout/SKILL.md +71 -0
- package/skills/workit-fanout/references/brief.md +141 -0
- package/skills/workit-fanout/references/orchestration.md +169 -0
- package/skills/workit-implement/SKILL.md +71 -0
- package/skills/workit-retro/SKILL.md +72 -0
- package/skills/workit-retro/agents/openai.yaml +3 -0
- package/skills/workit-retro/references/sources.md +35 -0
- package/skills/workit-retro/references/steering.md +39 -0
- package/skills/workit-review/SKILL.md +66 -0
- package/skills/workit-review/references/impact.md +24 -0
- package/skills/workit-shape/SKILL.md +71 -0
- package/skills/workit-shape/references/diagrams.md +17 -0
- package/skills/workit-shape/references/knowledge.md +61 -0
- package/skills/workit-shape/references/mockups.md +15 -0
- package/skills/workit-shape/references/slicing.md +44 -0
- package/skills/workit-ship/SKILL.md +71 -0
- package/skills/workit-ship/references/changelog.md +44 -0
- package/skills/workit-ship/references/pr-body.md +32 -0
- package/skills/workit-ship/references/release.md +39 -0
- package/skills/workit-test-audit/SKILL.md +54 -0
- package/skills/workit-verify-app/SKILL.md +68 -0
- package/skills/workit-verify-app/references/template.md +51 -0
- package/src/autonomy.ts +910 -0
- package/src/check-config.ts +613 -0
- package/src/checks.ts +534 -0
- package/src/ci-waiter.ts +159 -0
- package/src/core/boundary.ts +55 -0
- package/src/core/branch-policy.ts +112 -0
- package/src/core/branch.ts +1192 -0
- package/src/core/changelog-gate.ts +349 -0
- package/src/core/changelog-merge.ts +214 -0
- package/src/core/changelog.ts +392 -0
- package/src/core/cleanup.ts +638 -0
- package/src/core/cli-locate.ts +173 -0
- package/src/core/commit-flavors.ts +65 -0
- package/src/core/config-schemas.ts +653 -0
- package/src/core/config.ts +351 -0
- package/src/core/context-read.ts +144 -0
- package/src/core/docs-gate.ts +69 -0
- package/src/core/finding-evidence.ts +89 -0
- package/src/core/finding-queue.ts +36 -0
- package/src/core/git.ts +73 -0
- package/src/core/gitignore.ts +52 -0
- package/src/core/heavy-eta.ts +170 -0
- package/src/core/heavy-lease.ts +290 -0
- package/src/core/heavy-rules.ts +373 -0
- package/src/core/heavy-scope.ts +131 -0
- package/src/core/heavy-words.ts +83 -0
- package/src/core/heavy.ts +535 -0
- package/src/core/hygiene.ts +149 -0
- package/src/core/init.ts +218 -0
- package/src/core/live-session.ts +333 -0
- package/src/core/logger.ts +310 -0
- package/src/core/methods.ts +185 -0
- package/src/core/model-fallback.ts +208 -0
- package/src/core/node-resolve.mjs +249 -0
- package/src/core/operation-input.ts +632 -0
- package/src/core/package-root.ts +28 -0
- package/src/core/policy/attributes.ts +97 -0
- package/src/core/policy/bookkeeping.ts +119 -0
- package/src/core/policy/carry.ts +122 -0
- package/src/core/policy/check-evidence.ts +115 -0
- package/src/core/policy/current-judgment.ts +20 -0
- package/src/core/policy/derive.ts +387 -0
- package/src/core/policy/judgment.ts +246 -0
- package/src/core/policy/lane.ts +562 -0
- package/src/core/policy/pr-lane.ts +32 -0
- package/src/core/policy/substance.ts +365 -0
- package/src/core/policy-resolver.ts +94 -0
- package/src/core/pr-create.ts +448 -0
- package/src/core/pr-template.ts +162 -0
- package/src/core/release-notes.ts +197 -0
- package/src/core/release-tracks.ts +779 -0
- package/src/core/remotes.ts +436 -0
- package/src/core/repo-context.ts +542 -0
- package/src/core/repo-paths.ts +29 -0
- package/src/core/resources.ts +1299 -0
- package/src/core/role-models.ts +603 -0
- package/src/core/route-intent.ts +305 -0
- package/src/core/rules.ts +65 -0
- package/src/core/runtime-identity.ts +63 -0
- package/src/core/safe-write.ts +87 -0
- package/src/core/scope-glob.ts +56 -0
- package/src/core/scripts.ts +6 -0
- package/src/core/skill-manifests.ts +173 -0
- package/src/core/store-lock.ts +374 -0
- package/src/core/support-matrix.ts +24 -0
- package/src/core/task-context.ts +247 -0
- package/src/core/task-contract.ts +1279 -0
- package/src/core/task-delivery.ts +109 -0
- package/src/core/task-engine.ts +2248 -0
- package/src/core/task-evaluation.ts +1147 -0
- package/src/core/task-store.ts +2649 -0
- package/src/core/task-view.ts +180 -0
- package/src/core/templates.ts +22 -0
- package/src/core/tracker-issues.ts +174 -0
- package/src/core/vcs-config.ts +540 -0
- package/src/core/workers.ts +62 -0
- package/src/core/workit-paths.mjs +242 -0
- package/src/core/workspace-branch-policy.ts +123 -0
- package/src/core/workspaces.ts +1745 -0
- package/src/core/youtrack-backend.ts +288 -0
- package/src/core/youtrack-tools.ts +260 -0
- package/src/core/youtrack-writes.ts +73 -0
- package/src/core/youtrack-ytrack.ts +217 -0
- package/src/core/youtrack.ts +454 -0
- package/src/core/ytrack-skill.ts +297 -0
- package/src/core.ts +171 -0
- package/src/deslop/lex.ts +271 -0
- package/src/deslop/scan.ts +232 -0
- package/src/duration.ts +27 -0
- package/src/fanout-brief.ts +486 -0
- package/src/fanout-check.ts +455 -0
- package/src/fanout-landed.ts +370 -0
- package/src/fanout-stack.ts +108 -0
- package/src/fanout-status.ts +362 -0
- package/src/fanout-worktree.ts +893 -0
- package/src/fanout.ts +1473 -0
- package/src/forge/exec.ts +385 -0
- package/src/forge/github.ts +831 -0
- package/src/forge/gitlab.ts +906 -0
- package/src/forge/issue-forge.ts +209 -0
- package/src/forge/issue-link.ts +468 -0
- package/src/forge/issue-ops.ts +314 -0
- package/src/forge/merge-gate-base-red.ts +72 -0
- package/src/forge/pr-body.ts +100 -0
- package/src/forge/pr-edit.ts +292 -0
- package/src/forge/pr-ops.ts +1109 -0
- package/src/forge/pr-status.ts +10 -0
- package/src/forge/redact.ts +195 -0
- package/src/forge/report.ts +994 -0
- package/src/forge/reruns.ts +132 -0
- package/src/forge/resolve.ts +509 -0
- package/src/forge/types.ts +242 -0
- package/src/forge/verify.ts +398 -0
- package/src/git/default-base.ts +130 -0
- package/src/git/env-redirect.ts +28 -0
- package/src/git/facts.ts +122 -0
- package/src/git/ops.ts +1823 -0
- package/src/git/refs-read.ts +332 -0
- package/src/git/release.ts +1020 -0
- package/src/git/rev.ts +1175 -0
- package/src/git/session-hook.ts +597 -0
- package/src/git/stage.ts +63 -0
- package/src/hooks/agent-model.ts +149 -0
- package/src/hooks/budget-signal.ts +284 -0
- package/src/hooks/context.ts +461 -0
- package/src/hooks/cwd-normalize.ts +79 -0
- package/src/hooks/deadline.ts +228 -0
- package/src/hooks/delivery-verbs.ts +182 -0
- package/src/hooks/descriptor.ts +124 -0
- package/src/hooks/handle.ts +433 -0
- package/src/hooks/heavy-gate.ts +99 -0
- package/src/hooks/hook-runs.ts +130 -0
- package/src/hooks/hosts/claude-code.ts +380 -0
- package/src/hooks/hosts/codex.ts +372 -0
- package/src/hooks/hosts/cursor.ts +419 -0
- package/src/hooks/hosts/fields.ts +98 -0
- package/src/hooks/hosts/opencode.ts +79 -0
- package/src/hooks/hosts/pi.ts +78 -0
- package/src/hooks/index.ts +60 -0
- package/src/hooks/model-limit.ts +378 -0
- package/src/hooks/model-switch.ts +35 -0
- package/src/hooks/policy.ts +29 -0
- package/src/hooks/process.ts +48 -0
- package/src/hooks/protocol.ts +135 -0
- package/src/hooks/raw-git.ts +2289 -0
- package/src/hooks/repeat-failure.ts +115 -0
- package/src/hooks/run.ts +54 -0
- package/src/hooks/session-marker.ts +151 -0
- package/src/hooks/shell-words.ts +532 -0
- package/src/hooks/skill-nudge.ts +295 -0
- package/src/hooks/spawn-gate.ts +72 -0
- package/src/hooks/standing-signal.ts +99 -0
- package/src/hooks/standing.ts +225 -0
- package/src/hooks/steering.ts +74 -0
- package/src/hooks/stop.ts +408 -0
- package/src/hooks/worktree-confine.ts +415 -0
- package/src/hooks/write-gate.ts +1174 -0
- package/src/host-session.ts +459 -0
- package/src/knowledge.ts +368 -0
- package/src/ledger.ts +2913 -0
- package/src/stack.ts +2570 -0
- package/src/store/event-log.ts +217 -0
- package/src/store/notes.ts +29 -0
- package/src/store/patch.ts +135 -0
- package/src/store/paths.ts +368 -0
- package/src/store/reduce.ts +203 -0
- package/src/test-audit-mocks.ts +309 -0
- package/src/verdict-cover.ts +151 -0
- package/src/worktree-deps.ts +142 -0
- package/templates/headers.md +3 -0
- package/templates/hygiene/.editorconfig +8 -0
- package/templates/hygiene/.gitattributes +3 -0
- package/templates/hygiene/CHANGELOG.md +14 -0
- package/templates/hygiene/CONTRIBUTING.md +3 -0
- package/templates/hygiene/LICENSE +21 -0
- package/templates/hygiene/README.md +3 -0
- package/templates/issue-update.md +5 -0
- package/templates/plan-template.md +26 -0
- package/templates/spec-template.md +64 -0
- package/templates/workit-contract.md +13 -0
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
// Nothing a finished change made stays on disk (#653). After a merge, the
|
|
2
|
+
// branch's clean worktree (made by `workit fanout worktree create`, or under
|
|
3
|
+
// the managed root) and its local branch go, and the ledger says what went.
|
|
4
|
+
// `workit gc --worktrees` sweeps what earlier changes left: merged or
|
|
5
|
+
// squash-merged clean worktrees in Workit's places (the managed root
|
|
6
|
+
// `<state>/worktrees`, the pre-move root `~/.cache/workit-wt`, legacy
|
|
7
|
+
// `<repo>-wt`/`workit-wt` dirs beside a project, the temp dir), stale
|
|
8
|
+
// registrations, and local branches whose remote is gone once merged.
|
|
9
|
+
// Ignored files under a regenerable dir (the built-in list, plus the repo's
|
|
10
|
+
// `cleanup.regenerable` globs in .workit/checks.json) do not keep a worktree.
|
|
11
|
+
// Never forced: a dirty, locked or unmerged worktree, the main checkout and
|
|
12
|
+
// the checkout the command runs in are kept and named.
|
|
13
|
+
import { spawnSync } from "node:child_process";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { loadCheckConfig } from "../check-config";
|
|
18
|
+
import { defaultTrunk, gitRun, nulList, trunkRef } from "../fanout";
|
|
19
|
+
import { SCRATCH_DIR, listWorktrees, type WorktreeEntry } from "../fanout-worktree";
|
|
20
|
+
import { GIT_TIMEOUTS, pushRemoteName } from "../git/rev";
|
|
21
|
+
import { FANOUT_TOKEN_FILE } from "../hooks/worktree-confine";
|
|
22
|
+
import { appendObserved, type LedgerActor } from "../ledger";
|
|
23
|
+
import { legacyWorktreesDir, worktreesDir } from "./workit-paths.mjs";
|
|
24
|
+
import { matchWorkspace } from "./workspaces";
|
|
25
|
+
|
|
26
|
+
const SLOW = GIT_TIMEOUTS.worktree;
|
|
27
|
+
/** Dirty paths named in output and ledger rows. */
|
|
28
|
+
const DIRTY_SHOWN = 5;
|
|
29
|
+
/** Run once a dirty worktree is dealt with; gc removes it when it is merged and clean. */
|
|
30
|
+
export const RELEASE_COMMAND = "workit gc --worktrees --apply";
|
|
31
|
+
|
|
32
|
+
/** Where Workit makes worktrees: `<state>/worktrees/<repo>/<slice>`, never beside the project. */
|
|
33
|
+
export function managedWorktreeRoot(env: NodeJS.ProcessEnv = process.env): string {
|
|
34
|
+
return worktreesDir(env);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Every root whose worktrees are Workit's: the managed root, and the pre-move
|
|
39
|
+
* `~/.cache/workit-wt` (its worktrees are never moved; they go once merged).
|
|
40
|
+
*/
|
|
41
|
+
export function managedWorktreeRoots(env: NodeJS.ProcessEnv = process.env): string[] {
|
|
42
|
+
return [worktreesDir(env), legacyWorktreesDir(env)];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* One comparable form for a path from git (`C:/Users/runneradmin/...`) and one
|
|
47
|
+
* Workit computes (`C:\Users\RUNNER~1\...`): the real path (long names,
|
|
48
|
+
* symlinks resolved; resolved as written when missing), `/` separators, and
|
|
49
|
+
* case folded where the filesystem ignores case (as fanout's samePath does).
|
|
50
|
+
*/
|
|
51
|
+
const canonical = (target: string): string => {
|
|
52
|
+
let real: string;
|
|
53
|
+
try {
|
|
54
|
+
real = fs.realpathSync.native(target);
|
|
55
|
+
} catch {
|
|
56
|
+
real = path.resolve(target);
|
|
57
|
+
}
|
|
58
|
+
const slashed = real.replace(/\\/gu, "/").replace(/\/+$/u, "");
|
|
59
|
+
return process.platform === "win32" || process.platform === "darwin"
|
|
60
|
+
? slashed.toLowerCase()
|
|
61
|
+
: slashed;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** `child` is `parent` or under it, both read through `canonical`. */
|
|
65
|
+
const inside = (child: string, parent: string): boolean => {
|
|
66
|
+
const from = canonical(parent);
|
|
67
|
+
const to = canonical(child);
|
|
68
|
+
return to === from || to.startsWith(from.endsWith("/") ? from : `${from}/`);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** managed: under a managed root (current or pre-move); legacy: `<parent>/<repo>-wt/<x>`; scratch: the temp dir. */
|
|
72
|
+
export type WorktreePlace = "managed" | "legacy" | "scratch" | "other";
|
|
73
|
+
|
|
74
|
+
export function placeOf(worktree: string, env: NodeJS.ProcessEnv = process.env): WorktreePlace {
|
|
75
|
+
if (managedWorktreeRoots(env).some((root) => inside(worktree, root))) return "managed";
|
|
76
|
+
if (path.basename(path.dirname(canonical(worktree))).endsWith("-wt")) return "legacy";
|
|
77
|
+
if ([os.tmpdir(), "/tmp"].some((dir) => fs.existsSync(dir) && inside(worktree, dir)))
|
|
78
|
+
return "scratch";
|
|
79
|
+
return "other";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** `workit fanout worktree create` marked this worktree's admin dir with its token. */
|
|
83
|
+
const isFanoutWorktree = (worktree: string): boolean => {
|
|
84
|
+
const dir = gitRun(worktree, ["rev-parse", "--absolute-git-dir"]);
|
|
85
|
+
if (!dir.ok) return false;
|
|
86
|
+
try {
|
|
87
|
+
return fs.readFileSync(path.join(dir.stdout.trim(), FANOUT_TOKEN_FILE), "utf8").trim() !== "";
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** Uncommitted paths (the scratch dir left out); null when git cannot say. */
|
|
94
|
+
export function dirtyPaths(worktree: string): string[] | null {
|
|
95
|
+
const status = gitRun(worktree, [
|
|
96
|
+
"status",
|
|
97
|
+
"--porcelain=v1",
|
|
98
|
+
"-z",
|
|
99
|
+
"--untracked-files=all",
|
|
100
|
+
"--ignore-submodules=none",
|
|
101
|
+
]);
|
|
102
|
+
if (!status.ok) return null;
|
|
103
|
+
const out: string[] = [];
|
|
104
|
+
const fields = nulList(status.stdout);
|
|
105
|
+
for (let index = 0; index < fields.length; index++) {
|
|
106
|
+
const line = fields[index];
|
|
107
|
+
// A rename's source follows as its own field.
|
|
108
|
+
if (line[0] === "R" || line[0] === "C") index++;
|
|
109
|
+
const file = line.slice(3);
|
|
110
|
+
if (!file.startsWith(`${SCRATCH_DIR}/`)) out.push(file);
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Ignored paths under these directories are rebuilt by a tool; removing them loses nothing. */
|
|
116
|
+
const REGENERABLE = new Set([
|
|
117
|
+
"node_modules",
|
|
118
|
+
SCRATCH_DIR,
|
|
119
|
+
"dist",
|
|
120
|
+
"build",
|
|
121
|
+
"coverage",
|
|
122
|
+
".turbo",
|
|
123
|
+
".next",
|
|
124
|
+
".cache",
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The repo's `cleanup.regenerable` globs (.workit/checks.json, read from
|
|
129
|
+
* `cwd`); none when the config is missing or invalid, so nothing extra goes.
|
|
130
|
+
*/
|
|
131
|
+
export function repoRegenerable(cwd: string): string[] {
|
|
132
|
+
const config = loadCheckConfig(cwd);
|
|
133
|
+
return config.error ? [] : (config.regenerable ?? []);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* `file` (repo-relative, `/`-separated) is under a regenerable dir: one of its
|
|
138
|
+
* segments is in the built-in list, or it or a parent dir matches a repo glob.
|
|
139
|
+
* Anything nested under such a dir counts, a `.env` included.
|
|
140
|
+
*/
|
|
141
|
+
const regenerablePath = (file: string, globs: readonly string[]): boolean => {
|
|
142
|
+
const segments = file.replace(/\/+$/u, "").split("/");
|
|
143
|
+
if (segments.some((segment) => REGENERABLE.has(segment))) return true;
|
|
144
|
+
return segments.some((_, index) => {
|
|
145
|
+
const prefix = segments.slice(0, index + 1).join("/");
|
|
146
|
+
return globs.some((glob) => matchWorkspace(glob, prefix));
|
|
147
|
+
});
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Ignored paths that are not regenerable (a `.env`, a local config): `git
|
|
152
|
+
* status` omits them and `git worktree remove` deletes them, so they keep the
|
|
153
|
+
* worktree. `regenerable` adds repo globs to the built-in list. Null when git
|
|
154
|
+
* cannot say.
|
|
155
|
+
*/
|
|
156
|
+
export function ignoredKeepers(
|
|
157
|
+
worktree: string,
|
|
158
|
+
regenerable: readonly string[] = [],
|
|
159
|
+
): string[] | null {
|
|
160
|
+
const listed = gitRun(worktree, [
|
|
161
|
+
"ls-files",
|
|
162
|
+
"-z",
|
|
163
|
+
"--others",
|
|
164
|
+
"--ignored",
|
|
165
|
+
"--exclude-standard",
|
|
166
|
+
"--directory",
|
|
167
|
+
]);
|
|
168
|
+
if (!listed.ok) return null;
|
|
169
|
+
return nulList(listed.stdout).filter((file) => !regenerablePath(file, regenerable));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const IGNORED_REASON = "ignored files that are not regenerable";
|
|
173
|
+
|
|
174
|
+
const listed = (paths: readonly string[]): string =>
|
|
175
|
+
`${paths.slice(0, DIRTY_SHOWN).join(", ")}${paths.length > DIRTY_SHOWN ? `, … (${paths.length} in all)` : ""}`;
|
|
176
|
+
|
|
177
|
+
/** Why the worktree must stay (uncommitted or valuable ignored files), or null when it may go. */
|
|
178
|
+
function keepReason(
|
|
179
|
+
worktree: string,
|
|
180
|
+
regenerable: readonly string[],
|
|
181
|
+
): { paths: string[]; reason: string } | null {
|
|
182
|
+
const dirty = dirtyPaths(worktree);
|
|
183
|
+
if (dirty === null) return { paths: ["unreadable"], reason: "unreadable state" };
|
|
184
|
+
if (dirty.length > 0)
|
|
185
|
+
return {
|
|
186
|
+
paths: dirty,
|
|
187
|
+
reason: `${dirty.length} uncommitted change${dirty.length === 1 ? "" : "s"} (${listed(dirty)})`,
|
|
188
|
+
};
|
|
189
|
+
const ignored = ignoredKeepers(worktree, regenerable);
|
|
190
|
+
if (ignored === null) return { paths: ["unreadable"], reason: "unreadable state" };
|
|
191
|
+
if (ignored.length > 0)
|
|
192
|
+
return { paths: ignored, reason: `${IGNORED_REASON} (${listed(ignored)})` };
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Bytes under a directory, symlinks not followed. */
|
|
197
|
+
export function directoryBytes(dir: string): number {
|
|
198
|
+
let total = 0;
|
|
199
|
+
const pending = [dir];
|
|
200
|
+
while (pending.length) {
|
|
201
|
+
const current = pending.pop() as string;
|
|
202
|
+
let entries: fs.Dirent[];
|
|
203
|
+
try {
|
|
204
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
205
|
+
} catch {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
for (const entry of entries) {
|
|
209
|
+
const file = path.join(current, entry.name);
|
|
210
|
+
if (entry.isDirectory()) pending.push(file);
|
|
211
|
+
else
|
|
212
|
+
try {
|
|
213
|
+
total += fs.lstatSync(file).size;
|
|
214
|
+
} catch {
|
|
215
|
+
// gone meanwhile
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return total;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const patchId = (cwd: string, diff: string): string[] => {
|
|
223
|
+
const run = spawnSync("git", ["patch-id", "--stable"], {
|
|
224
|
+
cwd,
|
|
225
|
+
input: diff,
|
|
226
|
+
encoding: "utf8",
|
|
227
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
228
|
+
timeout: GIT_TIMEOUTS.local,
|
|
229
|
+
windowsHide: true,
|
|
230
|
+
});
|
|
231
|
+
return run.status === 0
|
|
232
|
+
? run.stdout
|
|
233
|
+
.split("\n")
|
|
234
|
+
.map((line) => line.split(" ")[0] ?? "")
|
|
235
|
+
.filter(Boolean)
|
|
236
|
+
: [];
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
export type MergeProof = "merged" | "squash-merged";
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Whether `tip` landed on `target`: an ancestor (merged), or its whole change
|
|
243
|
+
* since the merge base is one commit's patch on `target` (squash-merged).
|
|
244
|
+
* `trunkPatches` caches the target's patch ids per merge base.
|
|
245
|
+
*/
|
|
246
|
+
export function mergeProof(
|
|
247
|
+
cwd: string,
|
|
248
|
+
tip: string,
|
|
249
|
+
target: string,
|
|
250
|
+
trunkPatches: Map<string, Set<string>> = new Map(),
|
|
251
|
+
): MergeProof | null {
|
|
252
|
+
if (gitRun(cwd, ["merge-base", "--is-ancestor", tip, target]).ok) return "merged";
|
|
253
|
+
const base = gitRun(cwd, ["merge-base", target, tip]);
|
|
254
|
+
if (!base.ok) return null;
|
|
255
|
+
const from = base.stdout.trim();
|
|
256
|
+
const diff = gitRun(cwd, ["diff", "--no-ext-diff", "--binary", from, tip]);
|
|
257
|
+
if (!diff.ok || !diff.stdout) return null;
|
|
258
|
+
const own = patchId(cwd, diff.stdout)[0];
|
|
259
|
+
if (!own) return null;
|
|
260
|
+
let landed = trunkPatches.get(from);
|
|
261
|
+
if (!landed) {
|
|
262
|
+
const log = gitRun(cwd, ["log", "--no-ext-diff", "--binary", "-p", `${from}..${target}`]);
|
|
263
|
+
landed = new Set(log.ok ? patchId(cwd, log.stdout) : []);
|
|
264
|
+
trunkPatches.set(from, landed);
|
|
265
|
+
}
|
|
266
|
+
return landed.has(own) ? "squash-merged" : null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Remove `dir` when it is empty; true when it went. */
|
|
270
|
+
const removeIfEmpty = (dir: string): boolean => {
|
|
271
|
+
try {
|
|
272
|
+
if (fs.readdirSync(dir).length) return false;
|
|
273
|
+
fs.rmdirSync(dir);
|
|
274
|
+
return true;
|
|
275
|
+
} catch {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The managed `<repo>` dir or the legacy `-wt` dir that held a removed
|
|
282
|
+
* worktree, once empty; then the pre-move root too, once nothing is left in it.
|
|
283
|
+
*/
|
|
284
|
+
const tidyParent = (
|
|
285
|
+
worktree: string,
|
|
286
|
+
place: WorktreePlace,
|
|
287
|
+
env: NodeJS.ProcessEnv,
|
|
288
|
+
): string | null => {
|
|
289
|
+
if (place !== "managed" && place !== "legacy") return null;
|
|
290
|
+
const parent = path.dirname(worktree);
|
|
291
|
+
if (!removeIfEmpty(parent)) return null;
|
|
292
|
+
const old = legacyWorktreesDir(env);
|
|
293
|
+
if (place === "managed" && canonical(path.dirname(parent)) === canonical(old)) removeIfEmpty(old);
|
|
294
|
+
return parent;
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const firstLine = (text: string): string => text.trim().split("\n")[0] ?? "";
|
|
298
|
+
|
|
299
|
+
/** The remote no longer has `branch` (`git ls-remote` found no such head); false when it cannot tell. */
|
|
300
|
+
export function remoteBranchGone(cwd: string, branch: string): boolean {
|
|
301
|
+
if (!branch || branch.startsWith("-")) return false;
|
|
302
|
+
const remote = pushRemoteName(cwd, branch) ?? "origin";
|
|
303
|
+
const found = gitRun(
|
|
304
|
+
cwd,
|
|
305
|
+
["ls-remote", "--exit-code", "--heads", remote, `refs/heads/${branch}`],
|
|
306
|
+
GIT_TIMEOUTS.network,
|
|
307
|
+
);
|
|
308
|
+
// --exit-code: 2 = no matching ref; anything else (network, auth) is unknown.
|
|
309
|
+
return found.status === 2;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export type MergeCleanup = {
|
|
313
|
+
/** The local branch was deleted. */
|
|
314
|
+
branchDeleted: boolean;
|
|
315
|
+
/** The clean Workit worktree that was on the branch, removed. */
|
|
316
|
+
worktreeRemoved: string | null;
|
|
317
|
+
/** A worktree kept for uncommitted changes, with the paths. */
|
|
318
|
+
dirty: { path: string; paths: string[] } | null;
|
|
319
|
+
/** Why anything was kept: one line each. */
|
|
320
|
+
notes: string[];
|
|
321
|
+
/** The ledger row that lists what was removed, when anything was. */
|
|
322
|
+
recorded: { id: string } | { error: string } | null;
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
export type CleanupOptions = {
|
|
326
|
+
actor: LedgerActor;
|
|
327
|
+
/** What ran the cleanup: `pr merge`, `verify-delivery merge`. */
|
|
328
|
+
trigger: string;
|
|
329
|
+
env?: NodeJS.ProcessEnv;
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* After a merge of `branch` at `mergedHead`: remove its clean worktree when
|
|
334
|
+
* Workit made it (fanout-marked or under the managed root), then delete the
|
|
335
|
+
* local branch once nothing has it checked out. The main checkout, the one
|
|
336
|
+
* this runs in, someone else's worktree, a dirty one, and a local branch
|
|
337
|
+
* with commits beyond the merged head are kept and named.
|
|
338
|
+
*/
|
|
339
|
+
export function cleanupAfterMerge(
|
|
340
|
+
cwd: string,
|
|
341
|
+
branch: string,
|
|
342
|
+
mergedHead: string,
|
|
343
|
+
options: CleanupOptions,
|
|
344
|
+
): MergeCleanup {
|
|
345
|
+
const env = options.env ?? process.env;
|
|
346
|
+
const out: MergeCleanup = {
|
|
347
|
+
branchDeleted: false,
|
|
348
|
+
worktreeRemoved: null,
|
|
349
|
+
dirty: null,
|
|
350
|
+
notes: [],
|
|
351
|
+
recorded: null,
|
|
352
|
+
};
|
|
353
|
+
if (!branch || branch.startsWith("-") || !mergedHead) return out;
|
|
354
|
+
const ref = `refs/heads/${branch}`;
|
|
355
|
+
const tip = gitRun(cwd, ["rev-parse", "--verify", "--quiet", ref]);
|
|
356
|
+
if (!tip.ok) return out;
|
|
357
|
+
const head = tip.stdout.trim();
|
|
358
|
+
if (head !== mergedHead && !gitRun(cwd, ["merge-base", "--is-ancestor", head, mergedHead]).ok) {
|
|
359
|
+
out.notes.push(`local ${branch} has commits that were not merged; kept it`);
|
|
360
|
+
return out;
|
|
361
|
+
}
|
|
362
|
+
for (const [index, entry] of listWorktrees(cwd).entries()) {
|
|
363
|
+
if (entry.branch !== ref) continue;
|
|
364
|
+
if (index === 0 || inside(cwd, entry.path)) {
|
|
365
|
+
out.notes.push(`local ${branch} is checked out in ${entry.path}; kept it`);
|
|
366
|
+
return out;
|
|
367
|
+
}
|
|
368
|
+
const place = placeOf(entry.path, env);
|
|
369
|
+
if (place !== "managed" && !isFanoutWorktree(entry.path)) {
|
|
370
|
+
out.notes.push(
|
|
371
|
+
`local ${branch} is checked out in ${entry.path}, not a Workit worktree; kept both`,
|
|
372
|
+
);
|
|
373
|
+
return out;
|
|
374
|
+
}
|
|
375
|
+
const held = keepReason(entry.path, repoRegenerable(cwd));
|
|
376
|
+
if (held) {
|
|
377
|
+
out.dirty = { path: entry.path, paths: held.paths.slice(0, DIRTY_SHOWN) };
|
|
378
|
+
out.notes.push(
|
|
379
|
+
`worktree ${entry.path} has ${held.reason}; kept it and local ${branch}. Commit, move or discard them, then \`${RELEASE_COMMAND}\``,
|
|
380
|
+
);
|
|
381
|
+
return out;
|
|
382
|
+
}
|
|
383
|
+
const removed = gitRun(cwd, ["worktree", "remove", entry.path], SLOW);
|
|
384
|
+
if (!removed.ok) {
|
|
385
|
+
out.notes.push(
|
|
386
|
+
`could not remove worktree ${entry.path} (${firstLine(removed.stderr)}); kept local ${branch}`,
|
|
387
|
+
);
|
|
388
|
+
return out;
|
|
389
|
+
}
|
|
390
|
+
out.worktreeRemoved = entry.path;
|
|
391
|
+
tidyParent(entry.path, place, env);
|
|
392
|
+
}
|
|
393
|
+
if (!listWorktrees(cwd).some((entry) => entry.branch === ref)) {
|
|
394
|
+
const deleted = gitRun(cwd, ["branch", "-D", "--", branch]);
|
|
395
|
+
if (deleted.ok) out.branchDeleted = true;
|
|
396
|
+
else out.notes.push(`could not delete local ${branch}: ${firstLine(deleted.stderr)}`);
|
|
397
|
+
}
|
|
398
|
+
if (out.worktreeRemoved || out.branchDeleted) {
|
|
399
|
+
const row = appendObserved(cwd, {
|
|
400
|
+
type: "cleanup.removed",
|
|
401
|
+
actor: options.actor,
|
|
402
|
+
trigger: options.trigger,
|
|
403
|
+
branch,
|
|
404
|
+
head,
|
|
405
|
+
worktrees: out.worktreeRemoved ? [out.worktreeRemoved] : [],
|
|
406
|
+
branches: out.branchDeleted ? [branch] : [],
|
|
407
|
+
});
|
|
408
|
+
out.recorded = row.ok ? { id: String(row.value.id) } : { error: row.error };
|
|
409
|
+
}
|
|
410
|
+
return out;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** A worktree with no commits of its own is left alone this long after its last use. */
|
|
414
|
+
const FRESH_MS = 24 * 60 * 60 * 1000;
|
|
415
|
+
|
|
416
|
+
/** A commit was made on `branch` (its reflog has more than the creation entry). */
|
|
417
|
+
const committedOn = (cwd: string, branch: string): boolean => {
|
|
418
|
+
const log = gitRun(cwd, ["reflog", "show", "--format=%gs", `refs/heads/${branch}`, "--"]);
|
|
419
|
+
return (
|
|
420
|
+
log.ok &&
|
|
421
|
+
log.stdout.split("\n").some((line) => /^(commit|merge|cherry-pick|rebase)/u.test(line))
|
|
422
|
+
);
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* When the worktree last moved: its admin dir's HEAD (written on checkout and
|
|
427
|
+
* commit; `git status` may rewrite the index, so that is not used). 0 when unknown.
|
|
428
|
+
*/
|
|
429
|
+
const lastUsedMs = (worktree: string): number => {
|
|
430
|
+
const dir = gitRun(worktree, ["rev-parse", "--absolute-git-dir"]);
|
|
431
|
+
if (!dir.ok) return 0;
|
|
432
|
+
try {
|
|
433
|
+
return fs.statSync(path.join(dir.stdout.trim(), "HEAD")).mtimeMs;
|
|
434
|
+
} catch {
|
|
435
|
+
return 0;
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
export type SweepCandidate = {
|
|
440
|
+
path: string;
|
|
441
|
+
branch: string | null;
|
|
442
|
+
head: string | null;
|
|
443
|
+
place: WorktreePlace;
|
|
444
|
+
/** merged / squash-merged into the trunk, or stale (its directory is gone). */
|
|
445
|
+
reason: MergeProof | "stale";
|
|
446
|
+
bytes: number;
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
export type SweepKept = { path: string; branch: string | null; reason: string };
|
|
450
|
+
|
|
451
|
+
export type SweepBranch = { name: string; reason: MergeProof };
|
|
452
|
+
|
|
453
|
+
export type WorktreeSweep = {
|
|
454
|
+
apply: boolean;
|
|
455
|
+
trunk: string | null;
|
|
456
|
+
/** Worktrees gc removes (with --apply) or would remove. */
|
|
457
|
+
removable: SweepCandidate[];
|
|
458
|
+
/** Local branches whose remote is gone, merged, checked out nowhere. */
|
|
459
|
+
goneBranches: SweepBranch[];
|
|
460
|
+
kept: SweepKept[];
|
|
461
|
+
/** --apply only: what went. */
|
|
462
|
+
removed: string[];
|
|
463
|
+
deletedBranches: string[];
|
|
464
|
+
removedDirs: string[];
|
|
465
|
+
notes: string[];
|
|
466
|
+
recorded: { id: string } | { error: string } | null;
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
const shortBranch = (ref: string | null): string | null =>
|
|
470
|
+
ref?.startsWith("refs/heads/") ? ref.slice(11) : ref;
|
|
471
|
+
|
|
472
|
+
/** Local branches whose upstream is configured but gone: name → tip. */
|
|
473
|
+
function goneUpstreams(cwd: string): Map<string, string> {
|
|
474
|
+
const out = new Map<string, string>();
|
|
475
|
+
const refs = gitRun(cwd, [
|
|
476
|
+
"for-each-ref",
|
|
477
|
+
"--format=%(refname:short)%09%(objectname)%09%(upstream:track)",
|
|
478
|
+
"refs/heads",
|
|
479
|
+
]);
|
|
480
|
+
if (!refs.ok) return out;
|
|
481
|
+
for (const line of refs.stdout.split("\n")) {
|
|
482
|
+
const [name, sha, track] = line.split("\t");
|
|
483
|
+
if (name && sha && track === "[gone]") out.set(name, sha);
|
|
484
|
+
}
|
|
485
|
+
return out;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* List (and with `apply`, remove) what finished changes left: clean worktrees
|
|
490
|
+
* in Workit's places whose commit landed on the trunk, stale registrations,
|
|
491
|
+
* and merged local branches whose remote is gone. Every removal is recorded
|
|
492
|
+
* in one ledger row.
|
|
493
|
+
*/
|
|
494
|
+
export function sweepWorktrees(
|
|
495
|
+
cwd: string,
|
|
496
|
+
options: { apply: boolean; actor: LedgerActor; env?: NodeJS.ProcessEnv },
|
|
497
|
+
): WorktreeSweep {
|
|
498
|
+
const env = options.env ?? process.env;
|
|
499
|
+
const trunkName = defaultTrunk(cwd);
|
|
500
|
+
const trunk = trunkRef(cwd, trunkName);
|
|
501
|
+
const out: WorktreeSweep = {
|
|
502
|
+
apply: options.apply,
|
|
503
|
+
trunk,
|
|
504
|
+
removable: [],
|
|
505
|
+
goneBranches: [],
|
|
506
|
+
kept: [],
|
|
507
|
+
removed: [],
|
|
508
|
+
deletedBranches: [],
|
|
509
|
+
removedDirs: [],
|
|
510
|
+
notes: [],
|
|
511
|
+
recorded: null,
|
|
512
|
+
};
|
|
513
|
+
const patches = new Map<string, Set<string>>();
|
|
514
|
+
const proven = (tip: string | null): MergeProof | null =>
|
|
515
|
+
tip && trunk ? mergeProof(cwd, tip, trunk, patches) : null;
|
|
516
|
+
const trees = listWorktrees(cwd);
|
|
517
|
+
const gone = goneUpstreams(cwd);
|
|
518
|
+
const regenerable = repoRegenerable(cwd);
|
|
519
|
+
for (const [index, entry] of trees.entries()) {
|
|
520
|
+
if (index === 0) continue;
|
|
521
|
+
const branch = shortBranch(entry.branch);
|
|
522
|
+
const keep = (reason: string) => out.kept.push({ path: entry.path, branch, reason });
|
|
523
|
+
if (entry.prunable) {
|
|
524
|
+
out.removable.push({
|
|
525
|
+
...pick(entry, branch),
|
|
526
|
+
place: placeOf(entry.path, env),
|
|
527
|
+
reason: "stale",
|
|
528
|
+
bytes: 0,
|
|
529
|
+
});
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
if (inside(cwd, entry.path)) {
|
|
533
|
+
keep("the checkout this command runs in");
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
if (entry.locked) {
|
|
537
|
+
keep("locked");
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
const place = placeOf(entry.path, env);
|
|
541
|
+
if (place === "other" && !isFanoutWorktree(entry.path)) {
|
|
542
|
+
keep("not in a Workit worktree or temp location");
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
const proof = proven(entry.head);
|
|
546
|
+
if (!proof) {
|
|
547
|
+
keep(
|
|
548
|
+
`not merged into ${trunk ?? trunkName}${branch && gone.has(branch) ? " (its remote branch is gone)" : ""}`,
|
|
549
|
+
);
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
const held = keepReason(entry.path, regenerable);
|
|
553
|
+
if (held) {
|
|
554
|
+
keep(held.reason);
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
// A worktree with no landed work of its own (detached, or a branch never
|
|
558
|
+
// committed to) may be a live worker's fresh slice or a session's scratch base.
|
|
559
|
+
const finished = (branch && (gone.has(branch) || committedOn(cwd, branch))) || false;
|
|
560
|
+
if (!finished && Date.now() - lastUsedMs(entry.path) < FRESH_MS) {
|
|
561
|
+
keep("no commits of its own and used in the last 24 hours");
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
out.removable.push({
|
|
565
|
+
...pick(entry, branch),
|
|
566
|
+
place,
|
|
567
|
+
reason: proof,
|
|
568
|
+
bytes: directoryBytes(entry.path),
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
const sweptBranches = new Set(out.removable.map((entry) => entry.branch).filter(Boolean));
|
|
572
|
+
const checkedOut = new Set(trees.map((entry) => shortBranch(entry.branch)));
|
|
573
|
+
for (const [name, tip] of gone) {
|
|
574
|
+
if (name === trunkName || sweptBranches.has(name) || checkedOut.has(name)) continue;
|
|
575
|
+
const proof = proven(tip);
|
|
576
|
+
if (proof) out.goneBranches.push({ name, reason: proof });
|
|
577
|
+
else
|
|
578
|
+
out.kept.push({
|
|
579
|
+
path: "",
|
|
580
|
+
branch: name,
|
|
581
|
+
reason: `remote branch gone, but not merged into ${trunk ?? trunkName}`,
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
if (!options.apply) return out;
|
|
585
|
+
|
|
586
|
+
let stale = false;
|
|
587
|
+
for (const candidate of out.removable) {
|
|
588
|
+
if (candidate.reason === "stale") {
|
|
589
|
+
stale = true;
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
const removed = gitRun(cwd, ["worktree", "remove", candidate.path], SLOW);
|
|
593
|
+
if (!removed.ok) {
|
|
594
|
+
out.notes.push(`could not remove ${candidate.path}: ${firstLine(removed.stderr)}`);
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
out.removed.push(candidate.path);
|
|
598
|
+
const parent = tidyParent(candidate.path, candidate.place, env);
|
|
599
|
+
if (parent) out.removedDirs.push(parent);
|
|
600
|
+
if (
|
|
601
|
+
candidate.branch &&
|
|
602
|
+
!listWorktrees(cwd).some((entry) => entry.branch === `refs/heads/${candidate.branch}`)
|
|
603
|
+
)
|
|
604
|
+
deleteBranch(cwd, candidate.branch, out);
|
|
605
|
+
}
|
|
606
|
+
if (stale) {
|
|
607
|
+
const pruned = gitRun(cwd, ["worktree", "prune"], SLOW);
|
|
608
|
+
if (pruned.ok)
|
|
609
|
+
out.removed.push(...out.removable.filter((c) => c.reason === "stale").map((c) => c.path));
|
|
610
|
+
else out.notes.push(`git worktree prune failed: ${firstLine(pruned.stderr)}`);
|
|
611
|
+
}
|
|
612
|
+
for (const entry of out.goneBranches) deleteBranch(cwd, entry.name, out);
|
|
613
|
+
if (out.removed.length || out.deletedBranches.length) {
|
|
614
|
+
const row = appendObserved(cwd, {
|
|
615
|
+
type: "cleanup.removed",
|
|
616
|
+
actor: options.actor,
|
|
617
|
+
trigger: "gc --worktrees",
|
|
618
|
+
worktrees: out.removed,
|
|
619
|
+
branches: out.deletedBranches,
|
|
620
|
+
dirs: out.removedDirs,
|
|
621
|
+
});
|
|
622
|
+
out.recorded = row.ok ? { id: String(row.value.id) } : { error: row.error };
|
|
623
|
+
}
|
|
624
|
+
return out;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const pick = (entry: WorktreeEntry, branch: string | null) => ({
|
|
628
|
+
path: entry.path,
|
|
629
|
+
branch,
|
|
630
|
+
head: entry.head,
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
const deleteBranch = (cwd: string, branch: string, out: WorktreeSweep): void => {
|
|
634
|
+
if (branch.startsWith("-")) return;
|
|
635
|
+
const deleted = gitRun(cwd, ["branch", "-D", "--", branch]);
|
|
636
|
+
if (deleted.ok) out.deletedBranches.push(branch);
|
|
637
|
+
else out.notes.push(`could not delete local ${branch}: ${firstLine(deleted.stderr)}`);
|
|
638
|
+
};
|