opencode-worktree-guardian 0.1.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/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +353 -0
- package/codex/.codex-plugin/plugin.json +31 -0
- package/codex/hooks/guardian-hook.ts +265 -0
- package/codex/hooks/hooks.json +30 -0
- package/codex/skills/worktree-guardian/SKILL.md +29 -0
- package/commands/delete-paths.md +12 -0
- package/commands/delete-worktree.md +16 -0
- package/commands/done.md +14 -0
- package/commands/finish-workflow.md +18 -0
- package/commands/finish.md +16 -0
- package/commands/hygiene.md +16 -0
- package/commands/preserve.md +14 -0
- package/commands/recover.md +14 -0
- package/commands/report.md +14 -0
- package/commands/start.md +14 -0
- package/commands/status.md +14 -0
- package/commands/unblock-finish.md +16 -0
- package/docs/adr/0001-guardian-safety-policy.md +192 -0
- package/docs/publishing.md +55 -0
- package/docs/release-checklist.md +84 -0
- package/package.json +72 -0
- package/scripts/readiness.ts +75 -0
- package/scripts/with-safe-node-temp.mjs +78 -0
- package/skills/worktree-guardian/SKILL.md +73 -0
- package/src/config.ts +87 -0
- package/src/delete-paths-apply.ts +77 -0
- package/src/delete-paths-preflight.ts +146 -0
- package/src/delete-paths.ts +1 -0
- package/src/delete-worktree-preflight.ts +25 -0
- package/src/delete-worktree-report.ts +70 -0
- package/src/delete-worktree-targets.ts +152 -0
- package/src/delete-worktree.ts +222 -0
- package/src/delete.ts +1 -0
- package/src/deletion-fingerprint.ts +59 -0
- package/src/done-primary-publish.ts +129 -0
- package/src/done-primary-snapshot.ts +79 -0
- package/src/done-reattach.ts +32 -0
- package/src/done-shared.ts +28 -0
- package/src/done.ts +80 -0
- package/src/filesystem-boundaries.ts +49 -0
- package/src/finish-dirty-files.ts +56 -0
- package/src/finish-report.ts +80 -0
- package/src/finish.ts +212 -0
- package/src/git.ts +288 -0
- package/src/guards/allowlists.ts +83 -0
- package/src/guards/classifier.ts +39 -0
- package/src/guards/destructive-classifier.ts +189 -0
- package/src/guards/git-invocation.ts +145 -0
- package/src/guards/guard-types.ts +36 -0
- package/src/guards/options.ts +15 -0
- package/src/guards/path-policy.ts +31 -0
- package/src/guards/protected-branch-policy.ts +88 -0
- package/src/guards/shell-parser.ts +126 -0
- package/src/guards/shell-prefix.ts +100 -0
- package/src/guards.ts +3 -0
- package/src/hygiene-apply.ts +230 -0
- package/src/hygiene-scan.ts +200 -0
- package/src/hygiene.ts +10 -0
- package/src/index.ts +18 -0
- package/src/lifecycle.ts +31 -0
- package/src/plugin/direct-file-routing.ts +68 -0
- package/src/plugin/event-log.ts +60 -0
- package/src/plugin/guard-context.ts +40 -0
- package/src/plugin/hook-context.ts +35 -0
- package/src/plugin/invisible-policy.ts +28 -0
- package/src/plugin/native-tool.ts +82 -0
- package/src/plugin/plan-token-cache.ts +66 -0
- package/src/plugin/readable-output-cleanup.ts +141 -0
- package/src/plugin/readable-output-status.ts +86 -0
- package/src/plugin/readable-output-values.ts +21 -0
- package/src/plugin/readable-output-workflow.ts +70 -0
- package/src/plugin/readable-output.ts +16 -0
- package/src/plugin/server.ts +257 -0
- package/src/plugin/session-routing.ts +74 -0
- package/src/plugin/slash-commands.ts +20 -0
- package/src/preserve.ts +32 -0
- package/src/recover.ts +195 -0
- package/src/report.ts +168 -0
- package/src/session/context.ts +41 -0
- package/src/session/last-safe-state.ts +27 -0
- package/src/session/worktree-binding.ts +161 -0
- package/src/start.ts +157 -0
- package/src/state.ts +197 -0
- package/src/tool-registry.ts +35 -0
- package/src/tools.ts +8 -0
- package/src/tui.ts +113 -0
- package/src/types.ts +339 -0
- package/src/unblock-finish.ts +298 -0
- package/src/workflow-candidates.ts +111 -0
- package/src/workflow.ts +84 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { loadConfig } from "../config.ts";
|
|
3
|
+
import { getCurrentBranch } from "../git.ts";
|
|
4
|
+
import { classifyGuardCommand, classifyNormalAgentGitCommand, classifyReadOnlyInspectionCommand, extractCommandText } from "../guards.ts";
|
|
5
|
+
import { guardianStart } from "../start.ts";
|
|
6
|
+
import { collectKnownWorktreePaths, resolveSessionWorktree } from "../session/worktree-binding.ts";
|
|
7
|
+
import { recordLastSafeState } from "../session/last-safe-state.ts";
|
|
8
|
+
import { runGuardianTool } from "../tool-registry.ts";
|
|
9
|
+
import type { GuardCommandPayload, GuardianConfig, GuardianToolInput, GuardianToolResult, HookContext, PlanTokenCache, PluginServerOptions, RecordLike, SessionWorktreeResult } from "../types.ts";
|
|
10
|
+
import { errorMessage, isRecordLike } from "../types.ts";
|
|
11
|
+
import { routeDirectFileMutation, directFileMutationPathArg } from "./direct-file-routing.ts";
|
|
12
|
+
import { writeLog, createEvent } from "./event-log.ts";
|
|
13
|
+
import { collectGuardContext } from "./guard-context.ts";
|
|
14
|
+
import { getExecutionCwd, getIdleEventSessionId, getSessionId, getStringSessionId } from "./hook-context.ts";
|
|
15
|
+
import { injectInvisiblePolicy } from "./invisible-policy.ts";
|
|
16
|
+
import { guardianTool } from "./native-tool.ts";
|
|
17
|
+
import { rewriteGuardianCommand } from "./slash-commands.ts";
|
|
18
|
+
import { canFallbackToNormalGit, getActualWorktree, pathExists, rememberSessionWorktree, resolveActualWorktreeOrPath, routeRecordedSessionCommand } from "./session-routing.ts";
|
|
19
|
+
|
|
20
|
+
async function tryInvisibleStart(input: GuardCommandPayload, context: HookContext, config: GuardianConfig) {
|
|
21
|
+
const sessionId = getSessionId(input);
|
|
22
|
+
if (!config.autoStart || !sessionId || !context.directory) return null;
|
|
23
|
+
try {
|
|
24
|
+
return await guardianStart({
|
|
25
|
+
repoRoot: context.directory,
|
|
26
|
+
cwd: context.worktree ?? context.directory,
|
|
27
|
+
sessionId,
|
|
28
|
+
taskName: input?.taskName ?? "session",
|
|
29
|
+
createWorktree: context.worktree == null || context.worktree === context.directory,
|
|
30
|
+
config,
|
|
31
|
+
});
|
|
32
|
+
} catch (error) {
|
|
33
|
+
return { ok: false, reason: errorMessage(error) };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function createTools(planCache: PlanTokenCache) {
|
|
38
|
+
return {
|
|
39
|
+
guardian_done: guardianTool("guardian_done", "Plan or apply the safest implementation-done path for this repository state.", planCache),
|
|
40
|
+
guardian_start: guardianTool("guardian_start", "Create or attach this OpenCode session to a guardian-owned worktree.", planCache),
|
|
41
|
+
guardian_status: guardianTool("guardian_status", "Report guardian state, worktrees, safety refs, stash inventory, and blockers without mutating the repo.", planCache),
|
|
42
|
+
guardian_delete_paths: guardianTool("guardian_delete_paths", "Plan or apply exact path deletion with confirm-token, fingerprint, tracked-file, recursive, and protected-root gates.", planCache),
|
|
43
|
+
guardian_delete_worktree: guardianTool("guardian_delete_worktree", "Plan or apply safe Guardian-mediated worktree deletion with confirm-token and safety-ref gates.", planCache),
|
|
44
|
+
guardian_unblock_finish: guardianTool("guardian_unblock_finish", "Plan or apply safe finish blocker resolution, such as committing review artifacts with confirm-token gates.", planCache),
|
|
45
|
+
guardian_finish_workflow: guardianTool("guardian_finish_workflow", "Plan or apply an implementation-done workflow that verifies clean state and removes redundant merged worktrees and branches through Guardian gates.", planCache),
|
|
46
|
+
guardian_finish: guardianTool("guardian_finish", "Apply the configured gated finish mode for the current guardian-owned worktree.", planCache),
|
|
47
|
+
guardian_preserve: guardianTool("guardian_preserve", "Mark the current guardian-owned session as terminal/preserved with a safety ref.", planCache),
|
|
48
|
+
guardian_recover: guardianTool("guardian_recover", "List recovery refs, orphaned sessions, stash inventory, and suggested recovery commands without mutation.", planCache),
|
|
49
|
+
guardian_report_html: guardianTool("guardian_report_html", "Write a static offline HTML report for guardian sessions, worktrees, branches, risks, and recovery commands.", planCache),
|
|
50
|
+
guardian_hygiene: guardianTool("guardian_hygiene", "Scan, plan, or apply token-gated cleanup for workspace hygiene findings.", planCache),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function resolveHookSessionWorktree(input: GuardCommandPayload, output: GuardCommandPayload, context: HookContext, pluginDirectory: string | undefined, sessionWorktreeCache: Map<string, string>) {
|
|
55
|
+
const command = extractCommandText(input, output);
|
|
56
|
+
const directFileMutation = directFileMutationPathArg(input, output);
|
|
57
|
+
const executionCwd = getExecutionCwd(input, output, context);
|
|
58
|
+
let sessionWorktree: SessionWorktreeResult | null = null;
|
|
59
|
+
try {
|
|
60
|
+
const canResolveSession = Boolean(command || directFileMutation) && pluginDirectory !== undefined && await pathExists(pluginDirectory);
|
|
61
|
+
const actualWorktree = canResolveSession ? await resolveActualWorktreeOrPath(executionCwd) : executionCwd;
|
|
62
|
+
sessionWorktree = canResolveSession ? await resolveSessionWorktree({
|
|
63
|
+
repoRoot: context.directory,
|
|
64
|
+
cwd: executionCwd,
|
|
65
|
+
actualWorktree,
|
|
66
|
+
sessionId: getStringSessionId(input),
|
|
67
|
+
cache: sessionWorktreeCache,
|
|
68
|
+
validateBinding: true,
|
|
69
|
+
}) : { ok: true, sessionId: getStringSessionId(input), expectedWorktree: null, actualWorktree: executionCwd, matches: true, source: "unavailable" };
|
|
70
|
+
} catch (error) {
|
|
71
|
+
sessionWorktree = { ok: false, reason: errorMessage(error), sessionId: getStringSessionId(input), expectedWorktree: null, actualWorktree: executionCwd };
|
|
72
|
+
}
|
|
73
|
+
return { command, executionCwd, sessionWorktree };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const WorktreeGuardianPlugin = {
|
|
77
|
+
id: "opencode-worktree-guardian",
|
|
78
|
+
async server({ client, directory, worktree }: PluginServerOptions = {}) {
|
|
79
|
+
const context = { directory, worktree };
|
|
80
|
+
const pluginDirectory = typeof directory === "string" ? directory : undefined;
|
|
81
|
+
const activeToolCalls = new Set<unknown>();
|
|
82
|
+
const autoFinishedSessions = new Set<unknown>();
|
|
83
|
+
const sessionWorktreeCache = new Map<string, string>();
|
|
84
|
+
const planCache: PlanTokenCache = new Map();
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
tool: createTools(planCache),
|
|
88
|
+
|
|
89
|
+
async "experimental.chat.system.transform"(input: GuardCommandPayload, output: GuardianToolInput) {
|
|
90
|
+
let invisibleStart: GuardianToolResult | null = null;
|
|
91
|
+
try {
|
|
92
|
+
const directoryExists = pluginDirectory ? await fs.access(pluginDirectory).then(() => true, () => false) : false;
|
|
93
|
+
const { config } = directoryExists && pluginDirectory ? await loadConfig(pluginDirectory) : { config: null };
|
|
94
|
+
if (config) {
|
|
95
|
+
injectInvisiblePolicy(output, config);
|
|
96
|
+
invisibleStart = await tryInvisibleStart(input, context, config);
|
|
97
|
+
rememberSessionWorktree(sessionWorktreeCache, getSessionId(input), invisibleStart);
|
|
98
|
+
}
|
|
99
|
+
} catch (error) {
|
|
100
|
+
invisibleStart = { ok: false, reason: errorMessage(error) };
|
|
101
|
+
}
|
|
102
|
+
await writeLog(client, createEvent("chat.system.transform", input, output, context, { invisibleStart }));
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
async "tool.execute.before"(input: GuardCommandPayload, output: GuardCommandPayload) {
|
|
106
|
+
if (input.callID) activeToolCalls.add(input.callID);
|
|
107
|
+
const { command, executionCwd, sessionWorktree: initialSessionWorktree } = await resolveHookSessionWorktree(input, output, context, pluginDirectory, sessionWorktreeCache);
|
|
108
|
+
let sessionWorktree = initialSessionWorktree;
|
|
109
|
+
let effectiveCwd = executionCwd;
|
|
110
|
+
let knownWorktreePaths = typeof worktree === "string" ? [worktree] : [];
|
|
111
|
+
try {
|
|
112
|
+
knownWorktreePaths = await collectKnownWorktreePaths({ cwd: effectiveCwd, repoRoot: directory, currentWorktree: worktree });
|
|
113
|
+
} catch {}
|
|
114
|
+
const guardContext = await collectGuardContext({ pluginDirectory, effectiveCwd });
|
|
115
|
+
let guard = classifyGuardCommand(command, {
|
|
116
|
+
cwd: effectiveCwd,
|
|
117
|
+
repoRoot: directory,
|
|
118
|
+
knownWorktreePaths,
|
|
119
|
+
protectedBranches: guardContext.guardConfig?.protectedBranches,
|
|
120
|
+
branchPrefix: guardContext.guardConfig?.branchPrefix,
|
|
121
|
+
guardianBranches: guardContext.guardianBranches,
|
|
122
|
+
protectedBranchWorktreePaths: guardContext.protectedBranchWorktreePaths,
|
|
123
|
+
currentBranch: guardContext.currentBranch,
|
|
124
|
+
});
|
|
125
|
+
const readOnly = sessionWorktree?.ok === false ? classifyReadOnlyInspectionCommand(command) : { allowed: true, reason: null };
|
|
126
|
+
const normalAgentGit = sessionWorktree?.ok === false ? classifyNormalAgentGitCommand(command, {
|
|
127
|
+
cwd: effectiveCwd,
|
|
128
|
+
protectedBranches: guardContext.guardConfig?.protectedBranches,
|
|
129
|
+
branchPrefix: guardContext.guardConfig?.branchPrefix,
|
|
130
|
+
guardianBranches: guardContext.guardianBranches,
|
|
131
|
+
protectedBranchWorktreePaths: guardContext.protectedBranchWorktreePaths,
|
|
132
|
+
currentBranch: guardContext.currentBranch,
|
|
133
|
+
}) : { allowed: true, reason: null };
|
|
134
|
+
let routed = false;
|
|
135
|
+
const directFileRoute = await routeDirectFileMutation(input, output, sessionWorktree, directory, sessionWorktreeCache);
|
|
136
|
+
if (directFileRoute.blocked) {
|
|
137
|
+
if (input.callID) activeToolCalls.delete(input.callID);
|
|
138
|
+
await writeLog(client, createEvent("tool.execute.before", input, output, context, { guard, sessionWorktree, readOnly, normalAgentGit, routed, directFileRoute }));
|
|
139
|
+
throw new Error(`Worktree Guardian blocked direct file mutation: ${directFileRoute.reason}. Use guardian_status to inspect the recorded worktree.`);
|
|
140
|
+
}
|
|
141
|
+
if (directFileRoute.routed) routed = true;
|
|
142
|
+
if (guard.blocked) {
|
|
143
|
+
if (input.callID) activeToolCalls.delete(input.callID);
|
|
144
|
+
await writeLog(client, createEvent("tool.execute.before", input, output, context, { guard, sessionWorktree, readOnly, normalAgentGit, routed }));
|
|
145
|
+
throw new Error(`Worktree Guardian blocked command: ${guard.reason}. Use guardian_status or guardian_finish instead.`);
|
|
146
|
+
}
|
|
147
|
+
if (command && sessionWorktree?.ok === false && !readOnly.allowed) {
|
|
148
|
+
try {
|
|
149
|
+
sessionWorktree = await routeRecordedSessionCommand(input, output, sessionWorktree, directory, sessionWorktreeCache);
|
|
150
|
+
effectiveCwd = typeof output.args?.workdir === "string" ? output.args.workdir : effectiveCwd;
|
|
151
|
+
routed = true;
|
|
152
|
+
knownWorktreePaths = await collectKnownWorktreePaths({ cwd: effectiveCwd, repoRoot: directory, currentWorktree: worktree });
|
|
153
|
+
const currentBranch = await getCurrentBranch(effectiveCwd).catch(() => null);
|
|
154
|
+
guard = classifyGuardCommand(command, {
|
|
155
|
+
cwd: effectiveCwd,
|
|
156
|
+
repoRoot: directory,
|
|
157
|
+
knownWorktreePaths,
|
|
158
|
+
protectedBranches: guardContext.guardConfig?.protectedBranches,
|
|
159
|
+
branchPrefix: guardContext.guardConfig?.branchPrefix,
|
|
160
|
+
guardianBranches: guardContext.guardianBranches,
|
|
161
|
+
protectedBranchWorktreePaths: guardContext.protectedBranchWorktreePaths,
|
|
162
|
+
currentBranch,
|
|
163
|
+
});
|
|
164
|
+
} catch (error) {
|
|
165
|
+
await writeLog(client, createEvent("tool.execute.before", input, output, context, { guard, sessionWorktree, readOnly, normalAgentGit, routed, routeError: errorMessage(error) }));
|
|
166
|
+
if (!canFallbackToNormalGit(error, normalAgentGit)) {
|
|
167
|
+
if (input.callID) activeToolCalls.delete(input.callID);
|
|
168
|
+
throw new Error(`Worktree Guardian blocked command: ${errorMessage(error)}. Use guardian_status to inspect the recorded worktree.`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
await writeLog(client, createEvent("tool.execute.before", input, output, context, { guard, sessionWorktree, readOnly, normalAgentGit, routed }));
|
|
173
|
+
if (command && sessionWorktree?.ok === false && !normalAgentGit.allowed && (sessionWorktree.reason || !readOnly.allowed)) {
|
|
174
|
+
if (input.callID) activeToolCalls.delete(input.callID);
|
|
175
|
+
throw new Error(`Worktree Guardian blocked command: session ${sessionWorktree.sessionId} is recorded for expected worktree ${sessionWorktree.expectedWorktree ?? "an unknown worktree"} but actual cwd is ${executionCwd} and actual worktree is ${sessionWorktree.actualWorktree}. Use guardian_status to inspect the recorded worktree.`);
|
|
176
|
+
}
|
|
177
|
+
if (guard.blocked) {
|
|
178
|
+
if (input.callID) activeToolCalls.delete(input.callID);
|
|
179
|
+
throw new Error(`Worktree Guardian blocked command: ${guard.reason}. Use guardian_status or guardian_finish instead.`);
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
async "tool.execute.after"(input: GuardCommandPayload, output: GuardCommandPayload) {
|
|
184
|
+
if (input.callID) activeToolCalls.delete(input.callID);
|
|
185
|
+
let lastSafeState: GuardianToolResult | null = null;
|
|
186
|
+
try {
|
|
187
|
+
const executionCwd = getExecutionCwd(input, output, context);
|
|
188
|
+
const canResolveSession = await pathExists(directory);
|
|
189
|
+
const actualWorktree = canResolveSession ? await getActualWorktree(executionCwd) : executionCwd;
|
|
190
|
+
const sessionWorktree = canResolveSession ? await resolveSessionWorktree({
|
|
191
|
+
repoRoot: directory,
|
|
192
|
+
cwd: executionCwd,
|
|
193
|
+
actualWorktree,
|
|
194
|
+
sessionId: getStringSessionId(input),
|
|
195
|
+
cache: sessionWorktreeCache,
|
|
196
|
+
}) : null;
|
|
197
|
+
if (sessionWorktree?.ok === false) {
|
|
198
|
+
lastSafeState = { ok: false, reason: "session worktree mismatch", sessionWorktree };
|
|
199
|
+
await writeLog(client, createEvent("tool.execute.after", input, output, context, { lastSafeState }));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
lastSafeState = await recordLastSafeState({ cwd: executionCwd, repoRoot: directory, sessionId: getStringSessionId(input), tool: input.tool });
|
|
203
|
+
} catch (error) {
|
|
204
|
+
lastSafeState = { ok: false, reason: errorMessage(error) };
|
|
205
|
+
}
|
|
206
|
+
await writeLog(client, createEvent("tool.execute.after", input, output, context, { lastSafeState }));
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
async "command.execute.before"(input: GuardCommandPayload, output: GuardCommandPayload) {
|
|
210
|
+
const rewritten = rewriteGuardianCommand(input, output);
|
|
211
|
+
await writeLog(client, createEvent("command.execute.before", input, output, context, { rewritten }));
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
async event(input: RecordLike) {
|
|
215
|
+
const event = isRecordLike(input.event) ? input.event : null;
|
|
216
|
+
let autoFinish: GuardianToolResult | null = null;
|
|
217
|
+
if (event?.type === "session.idle") {
|
|
218
|
+
const sessionId = getIdleEventSessionId(input);
|
|
219
|
+
if (sessionId && !autoFinishedSessions.has(sessionId) && activeToolCalls.size === 0 && directory) {
|
|
220
|
+
try {
|
|
221
|
+
const { config } = await loadConfig(directory);
|
|
222
|
+
if (config.autoFinish === true) {
|
|
223
|
+
const executionCwd = worktree ?? directory;
|
|
224
|
+
const recordedSessionWorktree = await resolveSessionWorktree({ repoRoot: directory, cwd: executionCwd, actualWorktree: executionCwd, sessionId, cache: sessionWorktreeCache, config });
|
|
225
|
+
const validationCwd = recordedSessionWorktree.expectedWorktree ?? executionCwd;
|
|
226
|
+
const validationWorktree = await resolveActualWorktreeOrPath(validationCwd);
|
|
227
|
+
const sessionWorktree = await resolveSessionWorktree({ repoRoot: directory, cwd: validationCwd, actualWorktree: validationWorktree, sessionId, cache: sessionWorktreeCache, config, validateBinding: true });
|
|
228
|
+
if (sessionWorktree?.ok !== true) {
|
|
229
|
+
autoFinish = {
|
|
230
|
+
ok: false,
|
|
231
|
+
status: "blocked",
|
|
232
|
+
reason: `recorded session cannot be auto-finished: ${sessionWorktree?.reason ?? "session worktree binding is invalid"}; rerun guardian_start with createWorktree=true`,
|
|
233
|
+
sessionWorktree,
|
|
234
|
+
suggestedCommand: "guardian_start createWorktree=true",
|
|
235
|
+
};
|
|
236
|
+
} else {
|
|
237
|
+
autoFinish = await runGuardianTool("guardian_finish", {
|
|
238
|
+
repoRoot: directory,
|
|
239
|
+
cwd: sessionWorktree.expectedWorktree ?? executionCwd,
|
|
240
|
+
sessionId,
|
|
241
|
+
finishMode: config.finishMode,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
if (autoFinish?.ok === true) autoFinishedSessions.add(sessionId);
|
|
245
|
+
}
|
|
246
|
+
} catch (error) {
|
|
247
|
+
autoFinish = { ok: false, reason: errorMessage(error) };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (autoFinish) await writeLog(client, createEvent("event", input, {}, context, { autoFinish }));
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
export default WorktreeGuardianPlugin;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { getRepoRoot } from "../git.ts";
|
|
3
|
+
import { resolveSessionWorktree } from "../session/worktree-binding.ts";
|
|
4
|
+
import type { AllowDecision, GuardCommandPayload, GuardianToolResult, SessionWorktreeResult } from "../types.ts";
|
|
5
|
+
import { errorMessage, isMutableRecord, isRecordLike } from "../types.ts";
|
|
6
|
+
import { getSessionId } from "./hook-context.ts";
|
|
7
|
+
|
|
8
|
+
export function rememberSessionWorktree(cache: Map<string, string>, sessionId: unknown, result: GuardianToolResult | null) {
|
|
9
|
+
const session = isRecordLike(result?.session) ? result.session : null;
|
|
10
|
+
const worktreePath = session?.worktree_path;
|
|
11
|
+
if (typeof sessionId === "string" && sessionId.length > 0 && result?.ok === true && typeof worktreePath === "string") cache.set(sessionId, worktreePath);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function pathExists(candidate: string | undefined) {
|
|
15
|
+
if (!candidate) return false;
|
|
16
|
+
return fs.access(candidate).then(() => true, () => false);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function getActualWorktree(executionCwd: string) {
|
|
20
|
+
return await getRepoRoot(executionCwd);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function resolveActualWorktreeOrPath(executionCwd: string) {
|
|
24
|
+
try {
|
|
25
|
+
return await getActualWorktree(executionCwd);
|
|
26
|
+
} catch {
|
|
27
|
+
return executionCwd;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function validateRecordedSessionTarget(input: GuardCommandPayload, sessionWorktree: SessionWorktreeResult, repoRoot: string | undefined, cache: Map<string, string>) {
|
|
32
|
+
const expectedWorktree = sessionWorktree.expectedWorktree;
|
|
33
|
+
if (typeof expectedWorktree !== "string" || expectedWorktree.length === 0) {
|
|
34
|
+
throw new Error(`recorded worktree is unavailable for session ${sessionWorktree.sessionId}`);
|
|
35
|
+
}
|
|
36
|
+
if (!await pathExists(expectedWorktree)) {
|
|
37
|
+
throw new Error(`recorded worktree is missing for session ${sessionWorktree.sessionId}: ${expectedWorktree}`);
|
|
38
|
+
}
|
|
39
|
+
const actualWorktree = await getActualWorktree(expectedWorktree);
|
|
40
|
+
const routed = await resolveSessionWorktree({
|
|
41
|
+
repoRoot,
|
|
42
|
+
cwd: expectedWorktree,
|
|
43
|
+
actualWorktree,
|
|
44
|
+
sessionId: getSessionId(input),
|
|
45
|
+
cache,
|
|
46
|
+
validateBinding: true,
|
|
47
|
+
});
|
|
48
|
+
if (routed?.ok !== true) {
|
|
49
|
+
const reason = routed?.reason ? `${routed.reason}: ` : "";
|
|
50
|
+
throw new Error(`recorded worktree cannot be used for session ${sessionWorktree.sessionId}: ${reason}expected ${routed?.expectedWorktree ?? expectedWorktree}, actual ${routed?.actualWorktree ?? actualWorktree}`);
|
|
51
|
+
}
|
|
52
|
+
return { ...routed, actualWorktree };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function routeRecordedSessionCommand(input: GuardCommandPayload, output: GuardCommandPayload, sessionWorktree: SessionWorktreeResult, repoRoot: string | undefined, cache: Map<string, string>) {
|
|
56
|
+
const routed = await validateRecordedSessionTarget(input, sessionWorktree, repoRoot, cache);
|
|
57
|
+
const expectedWorktree = routed.expectedWorktree;
|
|
58
|
+
if (typeof expectedWorktree !== "string") throw new Error(`recorded worktree is unavailable for session ${sessionWorktree.sessionId}`);
|
|
59
|
+
if (!isMutableRecord(output.args)) output.args = {};
|
|
60
|
+
const args = output.args;
|
|
61
|
+
args.workdir = expectedWorktree;
|
|
62
|
+
args.cwd = expectedWorktree;
|
|
63
|
+
return {
|
|
64
|
+
...routed,
|
|
65
|
+
routed: true,
|
|
66
|
+
routedFrom: sessionWorktree.actualWorktree,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function canFallbackToNormalGit(error: unknown, normalAgentGit: AllowDecision) {
|
|
71
|
+
if (normalAgentGit.allowed !== true) return false;
|
|
72
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
73
|
+
return /recorded worktree is (missing|unavailable)/.test(message);
|
|
74
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { GuardianToolInput } from "../types.ts";
|
|
2
|
+
|
|
3
|
+
export function rewriteGuardianCommand(input: GuardianToolInput = {}, output: GuardianToolInput = {}) {
|
|
4
|
+
const command = input?.command;
|
|
5
|
+
if (typeof command !== "string") return false;
|
|
6
|
+
const match = command.trim().match(/^\/?guardian\s+(done|status|finish-workflow|finish|preserve|recover|report|start|hygiene|delete-paths|delete-worktree|unblock-finish)(?:\s+(.*))?$/);
|
|
7
|
+
if (!match) return false;
|
|
8
|
+
const action = match[1];
|
|
9
|
+
const rest = match[2] ?? "";
|
|
10
|
+
if (!action) return false;
|
|
11
|
+
const toolName = action === "report" ? "guardian_report_html" : action === "delete-worktree" ? "guardian_delete_worktree" : action === "delete-paths" ? "guardian_delete_paths" : action === "unblock-finish" ? "guardian_unblock_finish" : action === "finish-workflow" ? "guardian_finish_workflow" : `guardian_${action}`;
|
|
12
|
+
const deleteGuidance = action === "delete-worktree" ? " Run mode=plan first. Stale local Guardian branch cleanup requires an exact branch or terminal sessionId plus deleteBranch=true and Guardian ownership proof from terminal state or safety refs. Intentional unmerged local abandonment requires deleteBranch=true plus abandonUnmerged=true in both plan and apply after inspecting unmerged commit evidence." : "";
|
|
13
|
+
const deletePathsGuidance = action === "delete-paths" ? " Run mode=plan first with exact paths, inspect target status and blockers, get explicit user confirmation, then apply with confirmDelete=true. Tracked source deletion requires allowTracked=true; directory deletion requires allowRecursive=true." : "";
|
|
14
|
+
const hygieneCleanupGuidance = action === "hygiene" ? " With no mode it scans only. For cleanup, run mode=plan first, inspect exact targets/blockers, get explicit user confirmation, then apply with confirmDelete=true." : "";
|
|
15
|
+
const doneGuidance = action === "done" ? " Run mode=plan first. Dirty primary-main publishing requires an explicit commitMessage and explicit user confirmation; apply with confirm=true so the plugin reuses the matching internal plan token. Cleanup after publish returns a separate cleanup plan and must not be silently applied." : "";
|
|
16
|
+
const text = `Use the ${toolName} native tool.${deleteGuidance}${deletePathsGuidance}${hygieneCleanupGuidance}${doneGuidance}${rest.trim() ? ` User arguments: ${rest.trim()}` : ""}`;
|
|
17
|
+
if (!output || typeof output !== "object") return false;
|
|
18
|
+
output.parts = [{ type: "text", text }];
|
|
19
|
+
return true;
|
|
20
|
+
}
|
package/src/preserve.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { buildPreservedRef, createRef, getCurrentBranch, getHeadCommit, getRepoRoot } from "./git.ts";
|
|
2
|
+
import { recordSession } from "./state.ts";
|
|
3
|
+
import type { GuardianToolInput, GuardianToolResult } from "./types.ts";
|
|
4
|
+
import { configFromInput, sessionIdFromInput } from "./session/context.ts";
|
|
5
|
+
import { protectedBranchReason } from "./session/worktree-binding.ts";
|
|
6
|
+
|
|
7
|
+
export async function guardianPreserve(input: GuardianToolInput = {}): Promise<GuardianToolResult> {
|
|
8
|
+
const cwd = typeof input.cwd === "string" ? input.cwd : typeof input.repoRoot === "string" ? input.repoRoot : process.cwd();
|
|
9
|
+
const repoRoot = typeof input.repoRoot === "string" ? input.repoRoot : await getRepoRoot(cwd);
|
|
10
|
+
const config = await configFromInput(input, repoRoot);
|
|
11
|
+
const sessionId = sessionIdFromInput(input);
|
|
12
|
+
if (!sessionId) throw new Error("sessionId is required");
|
|
13
|
+
|
|
14
|
+
const worktreePath = await getRepoRoot(cwd);
|
|
15
|
+
const branch = await getCurrentBranch(worktreePath);
|
|
16
|
+
if (!branch) throw new Error("Cannot preserve detached HEAD");
|
|
17
|
+
const unsafeReason = protectedBranchReason(config, branch);
|
|
18
|
+
if (unsafeReason) return { ok: false, status: "blocked", reason: unsafeReason, branch, worktreePath };
|
|
19
|
+
const headCommit = await getHeadCommit(worktreePath);
|
|
20
|
+
const preservedRef = buildPreservedRef(sessionId, branch, typeof input.timestamp === "string" ? input.timestamp : undefined);
|
|
21
|
+
await createRef(worktreePath, preservedRef, headCommit);
|
|
22
|
+
const recordedState = await recordSession(repoRoot, config, {
|
|
23
|
+
session_id: sessionId,
|
|
24
|
+
status: "preserved",
|
|
25
|
+
branch,
|
|
26
|
+
worktree_path: worktreePath,
|
|
27
|
+
base_ref: `${config.remote}/${config.baseBranch}`,
|
|
28
|
+
head_commit: headCommit,
|
|
29
|
+
safety_refs: [preservedRef],
|
|
30
|
+
}, { event: { type: "guardian_preserve", session_id: sessionId, ref: preservedRef } });
|
|
31
|
+
return { ok: true, status: "preserved", session: recordedState.sessions[sessionId], preservedRef };
|
|
32
|
+
}
|
package/src/recover.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { expandWorktreeRoot, loadConfig, normalizeConfig } from "./config.ts";
|
|
4
|
+
import { getCommonGitDir, getDirtyFiles, getRepoRoot, listBranches, listRecoveryCandidates, listRefs, listStashes, listWorktrees } from "./git.ts";
|
|
5
|
+
import type { GitBranchEntry, GitRefEntry, GitStashEntry } from "./git.ts";
|
|
6
|
+
import { scanWorkspaceHygiene } from "./hygiene.ts";
|
|
7
|
+
import { isActiveSession, isTerminalSession } from "./lifecycle.ts";
|
|
8
|
+
import { getGuardianPaths, readState } from "./state.ts";
|
|
9
|
+
import type { GuardianConfig, GuardianSession, GuardianToolInput, GuardianToolResult, WorktreeEntry } from "./types.ts";
|
|
10
|
+
import { errorMessage, isRecordLike } from "./types.ts";
|
|
11
|
+
|
|
12
|
+
type WorktreeAnnotationMetadata = {
|
|
13
|
+
readonly guardianRoot: string;
|
|
14
|
+
readonly commonGitDir?: string;
|
|
15
|
+
readonly commonGitDirError?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type AnnotatedWorktreeEntry = WorktreeEntry & {
|
|
19
|
+
readonly category?: "external-temp-worktree" | "external-worktree";
|
|
20
|
+
readonly severity?: "fail";
|
|
21
|
+
readonly reason?: string;
|
|
22
|
+
readonly metadata?: WorktreeAnnotationMetadata;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type PoisonedSession = GuardianSession & {
|
|
26
|
+
readonly severity: "fail";
|
|
27
|
+
readonly reason: string;
|
|
28
|
+
readonly suggestedCommand: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type HygieneStatus = Record<string, unknown> & {
|
|
32
|
+
readonly ok?: unknown;
|
|
33
|
+
readonly summary?: Record<string, unknown> & { readonly findingCount?: unknown };
|
|
34
|
+
readonly findings: readonly (Record<string, unknown> & { readonly path?: unknown })[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type GuardianStatusResult = Omit<GuardianToolResult, "activeSessions" | "terminalSessions" | "worktrees" | "safetyRefs" | "sessions"> & {
|
|
38
|
+
readonly repoRoot: string;
|
|
39
|
+
readonly config: GuardianConfig;
|
|
40
|
+
readonly stateVersion: number | undefined;
|
|
41
|
+
readonly sessions: readonly GuardianSession[];
|
|
42
|
+
readonly activeSessions: readonly GuardianSession[];
|
|
43
|
+
readonly terminalSessions: readonly GuardianSession[];
|
|
44
|
+
readonly orphanedSessions: readonly GuardianSession[];
|
|
45
|
+
readonly poisonedSessions: readonly PoisonedSession[];
|
|
46
|
+
readonly worktrees: readonly WorktreeEntry[];
|
|
47
|
+
readonly branchesWithoutWorktrees: readonly GitBranchEntry[];
|
|
48
|
+
readonly worktreesWithoutState: readonly AnnotatedWorktreeEntry[];
|
|
49
|
+
readonly stateBranchesWithoutWorktrees: readonly string[];
|
|
50
|
+
readonly safetyRefs: readonly GitRefEntry[];
|
|
51
|
+
readonly preservedRefs: readonly GitRefEntry[];
|
|
52
|
+
readonly stashes: readonly GitStashEntry[];
|
|
53
|
+
readonly dirtyFiles: readonly string[];
|
|
54
|
+
readonly hygiene: HygieneStatus;
|
|
55
|
+
readonly suggestedCommands: readonly string[];
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
type GuardianRecoverResult = GuardianStatusResult & {
|
|
59
|
+
readonly recoveryCandidates: Awaited<ReturnType<typeof listRecoveryCandidates>>;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
async function pathExists(candidate: string) {
|
|
63
|
+
try {
|
|
64
|
+
await fs.access(candidate);
|
|
65
|
+
return true;
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isInside(candidate: string, parent: string) {
|
|
72
|
+
const relative = path.relative(parent, candidate);
|
|
73
|
+
return relative === "" || Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isTempPath(candidate: string) {
|
|
77
|
+
const normalized = path.resolve(candidate);
|
|
78
|
+
return path.basename(normalized).startsWith("opencode-") || normalized.includes(path.sep + "opencode" + path.sep) || normalized.includes(path.sep + "var" + path.sep + "folders" + path.sep) || normalized.startsWith(path.resolve("/private/tmp")) || normalized.startsWith(path.resolve("/tmp"));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function annotateWorktreeWithoutState(worktree: WorktreeEntry, repoRoot: string, config: GuardianConfig): Promise<AnnotatedWorktreeEntry> {
|
|
82
|
+
const worktreePath = path.resolve(worktree.path);
|
|
83
|
+
const guardianRoot = path.resolve(repoRoot, expandWorktreeRoot(config.worktreeRoot, repoRoot));
|
|
84
|
+
if (isInside(worktreePath, guardianRoot)) return worktree;
|
|
85
|
+
|
|
86
|
+
let metadata: WorktreeAnnotationMetadata = { guardianRoot };
|
|
87
|
+
try {
|
|
88
|
+
metadata = { ...metadata, commonGitDir: await getCommonGitDir(worktreePath) };
|
|
89
|
+
} catch (error) {
|
|
90
|
+
metadata = { ...metadata, commonGitDirError: errorMessage(error) };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
...worktree,
|
|
95
|
+
category: isTempPath(worktreePath) ? "external-temp-worktree" : "external-worktree",
|
|
96
|
+
severity: "fail",
|
|
97
|
+
reason: "linked Git worktree is outside Guardian ownership and outside the configured Guardian worktree root",
|
|
98
|
+
metadata,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function poisonedSessionReason(session: GuardianSession, repoRoot: string, config: GuardianConfig) {
|
|
103
|
+
const reasons: string[] = [];
|
|
104
|
+
if (typeof session.worktree_path === "string" && path.resolve(session.worktree_path) === path.resolve(repoRoot)) {
|
|
105
|
+
reasons.push("active session is recorded on the primary repository worktree");
|
|
106
|
+
}
|
|
107
|
+
if (typeof session.branch === "string" && Array.isArray(config.protectedBranches) && config.protectedBranches.includes(session.branch)) {
|
|
108
|
+
reasons.push("active session branch is protected");
|
|
109
|
+
}
|
|
110
|
+
return reasons.join("; ");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function annotatePoisonedSession(session: GuardianSession, repoRoot: string, config: GuardianConfig): PoisonedSession | null {
|
|
114
|
+
const reason = poisonedSessionReason(session, repoRoot, config);
|
|
115
|
+
if (!reason) return null;
|
|
116
|
+
return {
|
|
117
|
+
...session,
|
|
118
|
+
severity: "fail",
|
|
119
|
+
reason,
|
|
120
|
+
suggestedCommand: "guardian_start createWorktree=true",
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function configFromInput(input: GuardianToolInput, repoRoot: string): Promise<GuardianConfig> {
|
|
125
|
+
if (input.config === undefined || input.config === null) return (await loadConfig(repoRoot)).config;
|
|
126
|
+
if (!isRecordLike(input.config)) throw new Error("config must be an object");
|
|
127
|
+
return normalizeConfig(input.config);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function guardianStatus(input: GuardianToolInput = {}): Promise<GuardianStatusResult> {
|
|
131
|
+
const cwd = typeof input.cwd === "string" ? input.cwd : process.cwd();
|
|
132
|
+
const repoRoot = typeof input.repoRoot === "string" ? input.repoRoot : await getRepoRoot(cwd);
|
|
133
|
+
const config = await configFromInput(input, repoRoot);
|
|
134
|
+
const paths = await getGuardianPaths(repoRoot);
|
|
135
|
+
const state = await readState(paths, { repoRoot, config });
|
|
136
|
+
const worktrees = await listWorktrees(repoRoot);
|
|
137
|
+
const worktreePaths = new Set(worktrees.map((entry) => path.resolve(entry.path)));
|
|
138
|
+
const sessions = Object.values(state.sessions ?? {});
|
|
139
|
+
const activeSessions = sessions.filter(isActiveSession);
|
|
140
|
+
const terminalSessions = sessions.filter(isTerminalSession);
|
|
141
|
+
const sessionWorktreePaths = new Set(activeSessions.map((session) => session.worktree_path).filter((entry): entry is string => typeof entry === "string").map((entry) => path.resolve(entry)));
|
|
142
|
+
const sessionBranches = new Set(activeSessions.map((session) => session.branch).filter((entry): entry is string => typeof entry === "string"));
|
|
143
|
+
const orphanedSessions = [];
|
|
144
|
+
const poisonedSessions = [];
|
|
145
|
+
|
|
146
|
+
for (const session of activeSessions) {
|
|
147
|
+
if (!session.worktree_path || !worktreePaths.has(path.resolve(session.worktree_path)) || !(await pathExists(session.worktree_path))) {
|
|
148
|
+
orphanedSessions.push(session);
|
|
149
|
+
}
|
|
150
|
+
const poisonedSession = annotatePoisonedSession(session, repoRoot, config);
|
|
151
|
+
if (poisonedSession) poisonedSessions.push(poisonedSession);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const branches = await listBranches(repoRoot);
|
|
155
|
+
const branchesWithoutWorktrees = branches.filter((branch) => !worktrees.some((worktree) => worktree.branch === branch.name));
|
|
156
|
+
const worktreesWithoutState = await Promise.all(worktrees
|
|
157
|
+
.filter((worktree) => !sessionWorktreePaths.has(path.resolve(worktree.path)))
|
|
158
|
+
.map((worktree) => annotateWorktreeWithoutState(worktree, repoRoot, config)));
|
|
159
|
+
const stateBranchesWithoutWorktrees = [...sessionBranches].filter((branch) => !worktrees.some((worktree) => worktree.branch === branch));
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
repoRoot,
|
|
163
|
+
config,
|
|
164
|
+
stateVersion: state.state_version,
|
|
165
|
+
sessions,
|
|
166
|
+
activeSessions,
|
|
167
|
+
terminalSessions,
|
|
168
|
+
orphanedSessions,
|
|
169
|
+
poisonedSessions,
|
|
170
|
+
worktrees,
|
|
171
|
+
branchesWithoutWorktrees,
|
|
172
|
+
worktreesWithoutState,
|
|
173
|
+
stateBranchesWithoutWorktrees,
|
|
174
|
+
safetyRefs: await listRefs(repoRoot, "refs/opencode-guardian"),
|
|
175
|
+
preservedRefs: await listRefs(repoRoot, "refs/opencode-guardian/preserved"),
|
|
176
|
+
stashes: await listStashes(repoRoot),
|
|
177
|
+
dirtyFiles: await getDirtyFiles(repoRoot),
|
|
178
|
+
hygiene: await scanWorkspaceHygiene({ repoRoot, cwd: input.cwd, config }),
|
|
179
|
+
suggestedCommands: ["guardian_status", "guardian_recover"],
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function guardianRecover(input: GuardianToolInput = {}): Promise<GuardianRecoverResult> {
|
|
184
|
+
const status = await guardianStatus(input);
|
|
185
|
+
const candidates = await listRecoveryCandidates(status.repoRoot);
|
|
186
|
+
return {
|
|
187
|
+
...status,
|
|
188
|
+
recoveryCandidates: candidates,
|
|
189
|
+
suggestedCommands: [
|
|
190
|
+
...status.suggestedCommands,
|
|
191
|
+
...status.safetyRefs.map((ref) => `git branch recovery/<name> ${ref.commit}`),
|
|
192
|
+
...status.stashes.map((stash) => `git stash show -p ${stash.name}`),
|
|
193
|
+
],
|
|
194
|
+
};
|
|
195
|
+
}
|