sanjang 0.3.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 +218 -0
- package/bin/__tests__/sanjang.test.ts +42 -0
- package/bin/sanjang.js +17 -0
- package/bin/sanjang.ts +144 -0
- package/dashboard/app.js +1888 -0
- package/dashboard/app.test.js +2 -0
- package/dashboard/index.html +275 -0
- package/dashboard/style.css +2112 -0
- package/lib/config.ts +337 -0
- package/lib/engine/cache.ts +218 -0
- package/lib/engine/config-hotfix.ts +161 -0
- package/lib/engine/conflict.ts +33 -0
- package/lib/engine/diagnostics.ts +81 -0
- package/lib/engine/naming.ts +93 -0
- package/lib/engine/ports.ts +61 -0
- package/lib/engine/pr.ts +71 -0
- package/lib/engine/process.ts +283 -0
- package/lib/engine/self-heal.ts +130 -0
- package/lib/engine/smart-init.ts +136 -0
- package/lib/engine/smart-pr.ts +130 -0
- package/lib/engine/snapshot.ts +45 -0
- package/lib/engine/state.ts +60 -0
- package/lib/engine/suggest.ts +169 -0
- package/lib/engine/warp.ts +47 -0
- package/lib/engine/watcher.ts +40 -0
- package/lib/engine/worktree.ts +100 -0
- package/lib/server.ts +1560 -0
- package/lib/types.ts +130 -0
- package/package.json +48 -0
- package/templates/sanjang.config.js +32 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { simpleGit } from "simple-git";
|
|
2
|
+
import { campPath } from "./worktree.ts";
|
|
3
|
+
|
|
4
|
+
const STASH_PREFIX = "sanjang-snapshot:";
|
|
5
|
+
|
|
6
|
+
interface StashEntry {
|
|
7
|
+
index: number;
|
|
8
|
+
message: string;
|
|
9
|
+
isSanjangSnapshot: boolean;
|
|
10
|
+
date: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function saveSnapshot(name: string, label: string): Promise<void> {
|
|
14
|
+
const git = simpleGit(campPath(name));
|
|
15
|
+
await git.raw(["stash", "push", "--include-untracked", "-m", `${STASH_PREFIX}${label}`]);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function restoreSnapshot(name: string, index: number): Promise<void> {
|
|
19
|
+
const git = simpleGit(campPath(name));
|
|
20
|
+
await git.raw(["checkout", "--", "."]).catch(() => {});
|
|
21
|
+
await git.raw(["clean", "-fd"]).catch(() => {});
|
|
22
|
+
await git.raw(["stash", "apply", `stash@{${index}}`]);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function listSnapshots(name: string): Promise<StashEntry[]> {
|
|
26
|
+
const git = simpleGit(campPath(name));
|
|
27
|
+
try {
|
|
28
|
+
const result = await git.raw(["stash", "list", "--format=%gd|%s|%ci"]);
|
|
29
|
+
if (!result?.trim()) return [];
|
|
30
|
+
|
|
31
|
+
return result
|
|
32
|
+
.trim()
|
|
33
|
+
.split("\n")
|
|
34
|
+
.map((line: string) => {
|
|
35
|
+
const [ref, message, date] = line.split("|");
|
|
36
|
+
const match = ref?.match(/stash@\{(\d+)\}/);
|
|
37
|
+
const index = match ? parseInt(match[1]!, 10) : 0;
|
|
38
|
+
const isSanjangSnapshot = message ? message.includes(STASH_PREFIX) : false;
|
|
39
|
+
return { index, message: message || "", isSanjangSnapshot, date: date || "" };
|
|
40
|
+
})
|
|
41
|
+
.filter((entry: StashEntry) => entry.isSanjangSnapshot);
|
|
42
|
+
} catch {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { Camp } from "../types.ts";
|
|
4
|
+
|
|
5
|
+
let campsDir: string | null = null;
|
|
6
|
+
|
|
7
|
+
export function setCampsDir(dir: string): void {
|
|
8
|
+
campsDir = dir;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getCampsDir(): string {
|
|
12
|
+
if (!campsDir) throw new Error("campsDir not initialized. Call setCampsDir() first.");
|
|
13
|
+
return campsDir;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function stateFile(): string {
|
|
17
|
+
return join(getCampsDir(), "state.json");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function ensureDir(): void {
|
|
21
|
+
mkdirSync(getCampsDir(), { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function read(): Camp[] {
|
|
25
|
+
const f = stateFile();
|
|
26
|
+
if (!existsSync(f)) return [];
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(readFileSync(f, "utf8"));
|
|
29
|
+
} catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function write(records: Camp[]): void {
|
|
35
|
+
ensureDir();
|
|
36
|
+
// Atomic write: write to temp file then rename to prevent corruption
|
|
37
|
+
const tmp = stateFile() + ".tmp";
|
|
38
|
+
writeFileSync(tmp, JSON.stringify(records, null, 2), "utf8");
|
|
39
|
+
renameSync(tmp, stateFile());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getAll(): Camp[] {
|
|
43
|
+
return read();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getOne(name: string): Camp | null {
|
|
47
|
+
return read().find((r) => r.name === name) ?? null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function upsert(record: Camp): void {
|
|
51
|
+
const records = read();
|
|
52
|
+
const idx = records.findIndex((r) => r.name === record.name);
|
|
53
|
+
if (idx === -1) records.push(record);
|
|
54
|
+
else records[idx] = record;
|
|
55
|
+
write(records);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function remove(name: string): void {
|
|
59
|
+
write(read().filter((r) => r.name !== name));
|
|
60
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task suggestion engine.
|
|
3
|
+
*
|
|
4
|
+
* Aggregates open issues, PRs, and recent git activity to surface
|
|
5
|
+
* actionable suggestions on the dashboard — no LLM required.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Types
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
export interface Suggestion {
|
|
15
|
+
type: "issue" | "pr" | "recent";
|
|
16
|
+
title: string;
|
|
17
|
+
detail?: string;
|
|
18
|
+
action?: string; // e.g., branch name to create camp from
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface GhIssue {
|
|
22
|
+
number: number;
|
|
23
|
+
title: string;
|
|
24
|
+
labels: Array<{ name: string }>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface GhPr {
|
|
28
|
+
number: number;
|
|
29
|
+
title: string;
|
|
30
|
+
headRefName: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Helpers
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
const TIMEOUT_MS = 10_000;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Async spawn wrapper — resolves with stdout or rejects on timeout / error.
|
|
41
|
+
*/
|
|
42
|
+
function run(cmd: string, args: string[], cwd: string): Promise<string> {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const child = spawn(cmd, args, {
|
|
45
|
+
cwd,
|
|
46
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
47
|
+
env: { ...process.env },
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
let stdout = "";
|
|
51
|
+
let stderr = "";
|
|
52
|
+
|
|
53
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
54
|
+
stdout += chunk.toString();
|
|
55
|
+
});
|
|
56
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
57
|
+
stderr += chunk.toString();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const timer = setTimeout(() => {
|
|
61
|
+
child.kill("SIGTERM");
|
|
62
|
+
reject(new Error(`Command timed out: ${cmd} ${args.join(" ")}`));
|
|
63
|
+
}, TIMEOUT_MS);
|
|
64
|
+
|
|
65
|
+
child.on("close", (code) => {
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
if (code === 0) resolve(stdout);
|
|
68
|
+
else reject(new Error(`Exit ${code}: ${stderr || stdout}`));
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
child.on("error", (err) => {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
reject(err);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Data fetchers
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
async function fetchIssues(cwd: string): Promise<Suggestion[]> {
|
|
83
|
+
const raw = await run(
|
|
84
|
+
"gh",
|
|
85
|
+
["issue", "list", "--state", "open", "--limit", "5", "--json", "number,title,labels"],
|
|
86
|
+
cwd,
|
|
87
|
+
);
|
|
88
|
+
const issues: GhIssue[] = JSON.parse(raw);
|
|
89
|
+
return issues.map((i) => {
|
|
90
|
+
const labelStr = i.labels.map((l) => l.name).join(", ");
|
|
91
|
+
return {
|
|
92
|
+
type: "issue" as const,
|
|
93
|
+
title: `#${i.number} ${i.title}`,
|
|
94
|
+
detail: labelStr || undefined,
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function fetchMyPrs(cwd: string): Promise<Suggestion[]> {
|
|
100
|
+
const raw = await run(
|
|
101
|
+
"gh",
|
|
102
|
+
["pr", "list", "--state", "open", "--author", "@me", "--limit", "3", "--json", "number,title,headRefName"],
|
|
103
|
+
cwd,
|
|
104
|
+
);
|
|
105
|
+
const prs: GhPr[] = JSON.parse(raw);
|
|
106
|
+
return prs.map((p) => ({
|
|
107
|
+
type: "pr" as const,
|
|
108
|
+
title: `#${p.number} ${p.title}`,
|
|
109
|
+
detail: p.headRefName,
|
|
110
|
+
action: p.headRefName,
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function fetchRecentCommits(cwd: string): Promise<Suggestion[]> {
|
|
115
|
+
const raw = await run("git", ["log", "--oneline", "-10"], cwd);
|
|
116
|
+
const lines = raw.trim().split("\n").filter(Boolean);
|
|
117
|
+
return lines.map((line) => {
|
|
118
|
+
const spaceIdx = line.indexOf(" ");
|
|
119
|
+
const hash = spaceIdx > 0 ? line.slice(0, spaceIdx) : line;
|
|
120
|
+
const msg = spaceIdx > 0 ? line.slice(spaceIdx + 1) : "";
|
|
121
|
+
return {
|
|
122
|
+
type: "recent" as const,
|
|
123
|
+
title: msg || hash,
|
|
124
|
+
detail: hash,
|
|
125
|
+
};
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Public API
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Suggest tasks the user might work on next.
|
|
135
|
+
*
|
|
136
|
+
* Aggregates data from GitHub (issues, PRs) and git (recent commits).
|
|
137
|
+
* If `gh` CLI is unavailable, returns git-based suggestions only.
|
|
138
|
+
*
|
|
139
|
+
* Results are sorted by relevance: PRs (이어하기) > Issues (이슈) > Recent (최근 작업).
|
|
140
|
+
*/
|
|
141
|
+
export async function suggestTasks(projectRoot: string): Promise<Suggestion[]> {
|
|
142
|
+
const results: Suggestion[] = [];
|
|
143
|
+
|
|
144
|
+
// gh-dependent fetches — tolerate failure (gh not installed / no repo)
|
|
145
|
+
const [issues, prs] = await Promise.allSettled([
|
|
146
|
+
fetchIssues(projectRoot),
|
|
147
|
+
fetchMyPrs(projectRoot),
|
|
148
|
+
]);
|
|
149
|
+
|
|
150
|
+
// PRs first — most actionable ("이어하기")
|
|
151
|
+
if (prs.status === "fulfilled") {
|
|
152
|
+
results.push(...prs.value);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Issues next ("이슈")
|
|
156
|
+
if (issues.status === "fulfilled") {
|
|
157
|
+
results.push(...issues.value);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Recent commits always available ("최근 작업")
|
|
161
|
+
try {
|
|
162
|
+
const recent = await fetchRecentCommits(projectRoot);
|
|
163
|
+
results.push(...recent);
|
|
164
|
+
} catch {
|
|
165
|
+
// No git history — return whatever we have
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return results;
|
|
169
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
interface WarpDetectResult {
|
|
5
|
+
installed: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface WarpOpenResult {
|
|
9
|
+
opened: boolean;
|
|
10
|
+
terminal: string | null;
|
|
11
|
+
path?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Detect if Warp terminal is installed.
|
|
16
|
+
*/
|
|
17
|
+
export function detectWarp(): WarpDetectResult {
|
|
18
|
+
const installed = existsSync("/Applications/Warp.app");
|
|
19
|
+
return { installed };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Open a Warp tab at the given worktree path.
|
|
24
|
+
* Opens as a new tab in the existing Warp window (not a new window).
|
|
25
|
+
* The tab title naturally shows the directory name (= camp name).
|
|
26
|
+
*/
|
|
27
|
+
export function openWarpTab(campName: string, worktreePath: string): WarpOpenResult {
|
|
28
|
+
const { installed } = detectWarp();
|
|
29
|
+
if (!installed) {
|
|
30
|
+
return { opened: false, terminal: null, path: worktreePath };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// open -a Warp {path} → opens tab in existing window with dir name as title
|
|
34
|
+
const result = spawnSync("open", ["-a", "Warp", worktreePath], { stdio: "pipe" });
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
opened: result.status === 0,
|
|
38
|
+
terminal: "warp",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* No-op cleanup (launch config removed — using open -a instead).
|
|
44
|
+
*/
|
|
45
|
+
export function removeLaunchConfig(_campName: string): void {
|
|
46
|
+
// Intentionally empty — kept for API compatibility with server.ts
|
|
47
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { watch, type FSWatcher } from "node:fs";
|
|
2
|
+
|
|
3
|
+
export class CampWatcher {
|
|
4
|
+
private watcher: FSWatcher | null = null;
|
|
5
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
6
|
+
private stopped = false;
|
|
7
|
+
|
|
8
|
+
constructor(
|
|
9
|
+
private readonly dir: string,
|
|
10
|
+
private readonly onChange: () => void,
|
|
11
|
+
private readonly debounceMs: number = 500,
|
|
12
|
+
) {}
|
|
13
|
+
|
|
14
|
+
start(): void {
|
|
15
|
+
this.stopped = false;
|
|
16
|
+
try {
|
|
17
|
+
this.watcher = watch(this.dir, { recursive: true }, () => {
|
|
18
|
+
if (this.stopped) return;
|
|
19
|
+
if (this.timer) clearTimeout(this.timer);
|
|
20
|
+
this.timer = setTimeout(() => {
|
|
21
|
+
if (!this.stopped) this.onChange();
|
|
22
|
+
}, this.debounceMs);
|
|
23
|
+
});
|
|
24
|
+
} catch {
|
|
25
|
+
// fs.watch can fail on some platforms/dirs — silently degrade
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
stop(): void {
|
|
30
|
+
this.stopped = true;
|
|
31
|
+
if (this.timer) {
|
|
32
|
+
clearTimeout(this.timer);
|
|
33
|
+
this.timer = null;
|
|
34
|
+
}
|
|
35
|
+
if (this.watcher) {
|
|
36
|
+
this.watcher.close();
|
|
37
|
+
this.watcher = null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { type SimpleGit, simpleGit } from "simple-git";
|
|
3
|
+
import { getCampsDir } from "./state.ts";
|
|
4
|
+
|
|
5
|
+
let projectRoot: string | null = null;
|
|
6
|
+
|
|
7
|
+
export interface BranchInfo {
|
|
8
|
+
name: string;
|
|
9
|
+
remote: boolean;
|
|
10
|
+
local: boolean;
|
|
11
|
+
date: string;
|
|
12
|
+
category?: "default" | "feature" | "fix" | "other";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function setProjectRoot(root: string): void {
|
|
16
|
+
projectRoot = root;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getProjectRoot(): string {
|
|
20
|
+
if (!projectRoot) throw new Error("projectRoot not initialized. Call setProjectRoot() first.");
|
|
21
|
+
return projectRoot;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function campPath(name: string): string {
|
|
25
|
+
return join(getCampsDir(), name);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function git(): SimpleGit {
|
|
29
|
+
return simpleGit(getProjectRoot());
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function listBranches(): Promise<BranchInfo[]> {
|
|
33
|
+
// Best-effort fetch — continue with local refs on network failure
|
|
34
|
+
try {
|
|
35
|
+
await git().fetch(["--prune"]);
|
|
36
|
+
} catch {
|
|
37
|
+
/* offline is OK */
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const raw = await git().raw([
|
|
41
|
+
"for-each-ref",
|
|
42
|
+
"--sort=-committerdate",
|
|
43
|
+
"--format=%(refname:short)\t%(committerdate:relative)\t%(refname)",
|
|
44
|
+
"refs/heads/",
|
|
45
|
+
"refs/remotes/origin/",
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
const map = new Map<string, BranchInfo>();
|
|
49
|
+
for (const line of raw.trim().split("\n")) {
|
|
50
|
+
if (!line) continue;
|
|
51
|
+
const [shortName, date, fullRef] = line.split("\t");
|
|
52
|
+
if (!shortName || !fullRef) continue;
|
|
53
|
+
if (shortName.includes("HEAD")) continue;
|
|
54
|
+
const isRemote = fullRef.startsWith("refs/remotes/origin/");
|
|
55
|
+
const clean = shortName.replace(/^origin\//, "").trim();
|
|
56
|
+
if (!clean) continue;
|
|
57
|
+
|
|
58
|
+
const entry: BranchInfo = map.get(clean) || { name: clean, remote: false, local: false, date: date ?? "" };
|
|
59
|
+
if (isRemote) entry.remote = true;
|
|
60
|
+
else entry.local = true;
|
|
61
|
+
if (!entry.date) entry.date = date ?? "";
|
|
62
|
+
map.set(clean, entry);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const branches = [...map.values()];
|
|
66
|
+
|
|
67
|
+
for (const b of branches) {
|
|
68
|
+
if (["dev", "main", "master"].includes(b.name)) {
|
|
69
|
+
b.category = "default";
|
|
70
|
+
} else if (b.name.startsWith("feature/")) {
|
|
71
|
+
b.category = "feature";
|
|
72
|
+
} else if (b.name.startsWith("fix/") || b.name.startsWith("hotfix/")) {
|
|
73
|
+
b.category = "fix";
|
|
74
|
+
} else {
|
|
75
|
+
b.category = "other";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return branches;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function addWorktree(name: string, branch: string): Promise<void> {
|
|
83
|
+
const path = campPath(name);
|
|
84
|
+
const refs = [`origin/${branch}`, branch];
|
|
85
|
+
let lastErr: unknown;
|
|
86
|
+
for (const ref of refs) {
|
|
87
|
+
try {
|
|
88
|
+
await git().raw(["worktree", "add", "--detach", path, ref]);
|
|
89
|
+
return;
|
|
90
|
+
} catch (err) {
|
|
91
|
+
lastErr = err;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
throw lastErr;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function removeWorktree(name: string): Promise<void> {
|
|
98
|
+
const path = campPath(name);
|
|
99
|
+
await git().raw(["worktree", "remove", "--force", path]);
|
|
100
|
+
}
|