acdev 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/.acdev/.env.example +13 -0
- package/README.md +231 -0
- package/bin/acdev.js +138 -0
- package/package.json +56 -0
- package/public/acdev_wordmark_logo.svg +10 -0
- package/public/app.js +3291 -0
- package/public/index.html +449 -0
- package/public/styles.css +1870 -0
- package/src/afterPrRules.js +116 -0
- package/src/agent.js +669 -0
- package/src/claude-auth.js +81 -0
- package/src/config.js +426 -0
- package/src/env.js +98 -0
- package/src/gh-auth.js +41 -0
- package/src/git.js +867 -0
- package/src/github.js +179 -0
- package/src/jira.js +418 -0
- package/src/paths.js +128 -0
- package/src/server.js +988 -0
- package/src/store.js +135 -0
- package/src/urls.js +16 -0
- package/src/usage.js +122 -0
package/src/git.js
ADDED
|
@@ -0,0 +1,867 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import simpleGit from 'simple-git';
|
|
7
|
+
import { worktreesRoot } from './paths.js';
|
|
8
|
+
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
|
|
11
|
+
/** @type {(cwd: string) => import('simple-git').SimpleGit} */
|
|
12
|
+
let gitFactory = (cwd) => simpleGit(cwd);
|
|
13
|
+
|
|
14
|
+
/** @param {(cwd: string) => import('simple-git').SimpleGit} fn */
|
|
15
|
+
export function _setGitFactory(fn) {
|
|
16
|
+
gitFactory = fn;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function _resetGitFactory() {
|
|
20
|
+
gitFactory = (cwd) => simpleGit(cwd);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const SLUG_MAX_LEN = 50;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Lowercase slug from a ticket title: spaces→hyphens, strip non-alnum except
|
|
27
|
+
* hyphens, collapse repeats, truncate to ~50 chars.
|
|
28
|
+
* @param {string} title
|
|
29
|
+
* @param {number} [maxLen]
|
|
30
|
+
*/
|
|
31
|
+
export function slugifyTitle(title, maxLen = SLUG_MAX_LEN) {
|
|
32
|
+
const slug = String(title || '')
|
|
33
|
+
.toLowerCase()
|
|
34
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
35
|
+
.replace(/-+/g, '-')
|
|
36
|
+
.replace(/^-|-$/g, '')
|
|
37
|
+
.slice(0, maxLen)
|
|
38
|
+
.replace(/-$/g, '');
|
|
39
|
+
return slug || 'issue';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const FIX_LABELS = new Set(['bug', 'bugfix', 'defect', 'fix']);
|
|
43
|
+
const FEAT_LABELS = new Set(['feature', 'enhancement', 'feat']);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* True when the title clearly refers to a bug (word "bug", [Bug], Bug:, Bug151, etc.).
|
|
47
|
+
* Whole-word / structured matches only — avoids false positives like "debug".
|
|
48
|
+
* @param {string} titleLower
|
|
49
|
+
*/
|
|
50
|
+
function titleLooksLikeBug(titleLower) {
|
|
51
|
+
if (/\[bug\]/.test(titleLower)) return true;
|
|
52
|
+
if (/^bug\s*:/.test(titleLower)) return true;
|
|
53
|
+
if (/\bbug\s*:/.test(titleLower)) return true;
|
|
54
|
+
if (/\bbug\s*\d/.test(titleLower)) return true; // Bug 151 / Bug151
|
|
55
|
+
if (/\bbug\b/.test(titleLower)) return true;
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Classify issue as feat vs fix from labels, then title/body keywords.
|
|
61
|
+
*
|
|
62
|
+
* Priority:
|
|
63
|
+
* 1. Labels (`bug`/`bugfix`/`defect`/`fix` → fix; `feature`/`enhancement`/`feat` → feat)
|
|
64
|
+
* 2. Title contains "bug" / [Bug] / Bug: / Bug N → fix (wins over feat keywords)
|
|
65
|
+
* 3. Title/body keywords (bug/fix/regression/… vs feature/enhance/implement/…)
|
|
66
|
+
* 4. Mixed or unclear → `fix` (safer: bugs must not become `feat/…`)
|
|
67
|
+
*
|
|
68
|
+
* @param {{ title?: string, body?: string, labels?: Array<string | { name?: string }> }} issue
|
|
69
|
+
* @returns {'feat' | 'fix'}
|
|
70
|
+
*/
|
|
71
|
+
export function detectIssueType(issue) {
|
|
72
|
+
const labels = (issue?.labels ?? []).map((l) =>
|
|
73
|
+
(typeof l === 'string' ? l : l?.name || '').toLowerCase()
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
if (labels.some((n) => FIX_LABELS.has(n))) return 'fix';
|
|
77
|
+
if (labels.some((n) => FEAT_LABELS.has(n))) return 'feat';
|
|
78
|
+
|
|
79
|
+
const titleLower = String(issue?.title ?? '').toLowerCase();
|
|
80
|
+
const bodyLower = String(issue?.body ?? '').toLowerCase();
|
|
81
|
+
const text = `${titleLower}\n${bodyLower}`;
|
|
82
|
+
|
|
83
|
+
// Titles with "bug" must never become feat/…, even if feat keywords also appear.
|
|
84
|
+
if (titleLooksLikeBug(titleLower)) return 'fix';
|
|
85
|
+
|
|
86
|
+
const bugHit =
|
|
87
|
+
/\b(bug|fix|regression|crash|error|broken|hotfix|defect)\b/.test(text);
|
|
88
|
+
const featHit =
|
|
89
|
+
/\b(feature|enhancement|enhance|implement)\b/.test(text) ||
|
|
90
|
+
/\badd\s+support\b/.test(text) ||
|
|
91
|
+
/\bnew\s+feature\b/.test(text);
|
|
92
|
+
|
|
93
|
+
if (bugHit) return 'fix';
|
|
94
|
+
if (featHit) return 'feat';
|
|
95
|
+
return 'fix';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @param {'feat' | 'fix'} type
|
|
100
|
+
* @param {string} title
|
|
101
|
+
*/
|
|
102
|
+
export function buildBranchName(type, title) {
|
|
103
|
+
return `${type}/${slugifyTitle(title)}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @param {string} repoRoot
|
|
108
|
+
* @param {string | number} worktreeId GitHub issue number or Jira key
|
|
109
|
+
*/
|
|
110
|
+
function worktreePaths(repoRoot, worktreeId) {
|
|
111
|
+
const worktreeRoot = worktreesRoot(repoRoot);
|
|
112
|
+
const worktreePath = path.join(worktreeRoot, `issue-${worktreeId}`);
|
|
113
|
+
return { worktreeRoot, worktreePath };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @param {import('simple-git').SimpleGit} git
|
|
118
|
+
* @param {string} branchName
|
|
119
|
+
*/
|
|
120
|
+
async function branchExistsLocalOrRemote(git, branchName) {
|
|
121
|
+
try {
|
|
122
|
+
const local = await git.raw(['branch', '--list', branchName]);
|
|
123
|
+
if (local.trim()) return true;
|
|
124
|
+
} catch {
|
|
125
|
+
// fall through
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
const remote = await git.raw(['branch', '-r', '--list', `origin/${branchName}`]);
|
|
129
|
+
if (remote.trim()) return true;
|
|
130
|
+
} catch {
|
|
131
|
+
// fall through
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Paths under acdev's own state dirs must not fail the clean check —
|
|
138
|
+
* the tool creates `.acdev/` inside the target repo (config, state, .env).
|
|
139
|
+
* Also recognizes legacy `.codepilot/` and `.agent-mcp/` paths.
|
|
140
|
+
* @param {string} filePath
|
|
141
|
+
*/
|
|
142
|
+
export function isAcdevStatePath(filePath) {
|
|
143
|
+
const normalized = String(filePath || '').replace(/\\/g, '/');
|
|
144
|
+
return (
|
|
145
|
+
normalized === '.acdev' ||
|
|
146
|
+
normalized.startsWith('.acdev/') ||
|
|
147
|
+
normalized === '.acdev-worktrees' ||
|
|
148
|
+
normalized.startsWith('.acdev-worktrees/') ||
|
|
149
|
+
normalized === '.codepilot' ||
|
|
150
|
+
normalized.startsWith('.codepilot/') ||
|
|
151
|
+
normalized === '.codepilot-worktrees' ||
|
|
152
|
+
normalized.startsWith('.codepilot-worktrees/') ||
|
|
153
|
+
normalized === '.agent-mcp' ||
|
|
154
|
+
normalized.startsWith('.agent-mcp/') ||
|
|
155
|
+
normalized === '.agent-mcp-worktrees' ||
|
|
156
|
+
normalized.startsWith('.agent-mcp-worktrees/')
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Collect dirty paths from a simple-git StatusResult, excluding acdev state.
|
|
162
|
+
* @param {import('simple-git').StatusResult} status
|
|
163
|
+
* @returns {string[]}
|
|
164
|
+
*/
|
|
165
|
+
export function relevantDirtyPaths(status) {
|
|
166
|
+
/** @type {string[]} */
|
|
167
|
+
const paths = [];
|
|
168
|
+
|
|
169
|
+
if (Array.isArray(status.files) && status.files.length > 0) {
|
|
170
|
+
for (const file of status.files) {
|
|
171
|
+
if (file?.path) paths.push(file.path);
|
|
172
|
+
}
|
|
173
|
+
} else {
|
|
174
|
+
for (const key of ['not_added', 'conflicted', 'created', 'deleted', 'modified', 'staged']) {
|
|
175
|
+
const list = status[key];
|
|
176
|
+
if (Array.isArray(list)) paths.push(...list);
|
|
177
|
+
}
|
|
178
|
+
for (const renamed of status.renamed ?? []) {
|
|
179
|
+
if (renamed?.from) paths.push(renamed.from);
|
|
180
|
+
if (renamed?.to) paths.push(renamed.to);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return [...new Set(paths)].filter((p) => !isAcdevStatePath(p));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* @param {string} repoRoot
|
|
189
|
+
* @param {string} baseBranch
|
|
190
|
+
*/
|
|
191
|
+
export async function syncBaseBranch(repoRoot, baseBranch) {
|
|
192
|
+
const git = gitFactory(repoRoot);
|
|
193
|
+
const status = await git.status();
|
|
194
|
+
const dirtyPaths = relevantDirtyPaths(status);
|
|
195
|
+
if (dirtyPaths.length > 0) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`Working copy at ${repoRoot} is not clean. Commit or stash changes before running acdev.\n` +
|
|
198
|
+
`Dirty paths:\n${dirtyPaths.map((p) => ` ${p}`).join('\n')}`
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
await git.fetch('origin');
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
await git.checkout(baseBranch);
|
|
206
|
+
} catch {
|
|
207
|
+
await git.checkout(['-b', baseBranch, `origin/${baseBranch}`]);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
await git.pull('origin', baseBranch);
|
|
211
|
+
const log = await git.log({ maxCount: 1 });
|
|
212
|
+
const headSha = log.latest?.hash ?? (await git.revparse(['HEAD']));
|
|
213
|
+
return { branch: baseBranch, headSha };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Create an isolated worktree. Worktree path stays `issue-<id>` for uniqueness
|
|
218
|
+
* (GitHub issue number or Jira key); branch name is `feat/<slug>` or `fix/<slug>`
|
|
219
|
+
* (append `-<id>` if taken).
|
|
220
|
+
*
|
|
221
|
+
* @param {string} repoRoot
|
|
222
|
+
* @param {string | number} worktreeId
|
|
223
|
+
* @param {string} baseBranch
|
|
224
|
+
* @param {string} desiredBranchName e.g. feat/add-login
|
|
225
|
+
*/
|
|
226
|
+
export async function createWorktree(repoRoot, worktreeId, baseBranch, desiredBranchName) {
|
|
227
|
+
if (!desiredBranchName) {
|
|
228
|
+
throw new Error('desiredBranchName is required (feat/<slug> or fix/<slug>)');
|
|
229
|
+
}
|
|
230
|
+
if (worktreeId == null || worktreeId === '') {
|
|
231
|
+
throw new Error('worktreeId is required');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const { worktreeRoot, worktreePath } = worktreePaths(repoRoot, worktreeId);
|
|
235
|
+
const git = gitFactory(repoRoot);
|
|
236
|
+
|
|
237
|
+
fs.mkdirSync(worktreeRoot, { recursive: true });
|
|
238
|
+
|
|
239
|
+
// Clean previous worktree for this issue; also drop candidate branch names.
|
|
240
|
+
const uniquified = `${desiredBranchName}-${worktreeId}`;
|
|
241
|
+
await removeWorktree(repoRoot, worktreeId, [desiredBranchName, uniquified]);
|
|
242
|
+
|
|
243
|
+
let branchName = desiredBranchName;
|
|
244
|
+
if (await branchExistsLocalOrRemote(git, branchName)) {
|
|
245
|
+
branchName = uniquified;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const addWorktree = async () => {
|
|
249
|
+
await git.raw([
|
|
250
|
+
'worktree',
|
|
251
|
+
'add',
|
|
252
|
+
worktreePath,
|
|
253
|
+
'-b',
|
|
254
|
+
branchName,
|
|
255
|
+
`origin/${baseBranch}`,
|
|
256
|
+
]);
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
await addWorktree();
|
|
261
|
+
} catch (err) {
|
|
262
|
+
if (fs.existsSync(worktreePath)) {
|
|
263
|
+
fs.rmSync(worktreePath, { recursive: true, force: true });
|
|
264
|
+
await addWorktree();
|
|
265
|
+
} else {
|
|
266
|
+
throw err;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return { branchName, worktreePath };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* True when a cleanup git error means the target is already gone (safe no-op).
|
|
275
|
+
* @param {unknown} err
|
|
276
|
+
* @param {'worktree' | 'branch'} kind
|
|
277
|
+
*/
|
|
278
|
+
export function isAbsentCleanupError(err, kind) {
|
|
279
|
+
const msg = String(
|
|
280
|
+
err && typeof err === 'object' && 'message' in err ? err.message : err
|
|
281
|
+
).toLowerCase();
|
|
282
|
+
if (kind === 'branch') {
|
|
283
|
+
return /\bnot found\b|\bdoesn't exist\b|\bdoes not exist\b/.test(msg);
|
|
284
|
+
}
|
|
285
|
+
// worktree remove: path already gone / never registered
|
|
286
|
+
return (
|
|
287
|
+
/\bnot a working tree\b/.test(msg) ||
|
|
288
|
+
/\bnot found\b/.test(msg) ||
|
|
289
|
+
/\bdoesn't exist\b/.test(msg) ||
|
|
290
|
+
/\bdoes not exist\b/.test(msg) ||
|
|
291
|
+
/\bno such file\b/.test(msg)
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* @param {string} repoRoot
|
|
297
|
+
* @param {string | number} worktreeId
|
|
298
|
+
* @param {string | string[] | undefined} branchName
|
|
299
|
+
*/
|
|
300
|
+
export async function removeWorktree(repoRoot, worktreeId, branchName) {
|
|
301
|
+
const { worktreePath } = worktreePaths(repoRoot, worktreeId);
|
|
302
|
+
const git = gitFactory(repoRoot);
|
|
303
|
+
|
|
304
|
+
try {
|
|
305
|
+
await git.raw(['worktree', 'remove', '--force', worktreePath]);
|
|
306
|
+
} catch (err) {
|
|
307
|
+
if (!isAbsentCleanupError(err, 'worktree')) {
|
|
308
|
+
console.warn(`[acdev] worktree remove failed for issue-${worktreeId}:`, err.message);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const names = Array.isArray(branchName)
|
|
313
|
+
? branchName
|
|
314
|
+
: branchName
|
|
315
|
+
? [branchName]
|
|
316
|
+
: [];
|
|
317
|
+
|
|
318
|
+
for (const name of [...new Set(names.filter(Boolean))]) {
|
|
319
|
+
try {
|
|
320
|
+
await git.branch(['-D', name]);
|
|
321
|
+
} catch (err) {
|
|
322
|
+
if (!isAbsentCleanupError(err, 'branch')) {
|
|
323
|
+
console.warn(`[acdev] branch delete failed for ${name}:`, err.message);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* @param {string} worktreePath
|
|
331
|
+
* @param {string} baseBranch
|
|
332
|
+
*/
|
|
333
|
+
export async function getDiff(worktreePath, baseBranch) {
|
|
334
|
+
const git = gitFactory(worktreePath);
|
|
335
|
+
return git.diff([`origin/${baseBranch}...HEAD`]);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Parse `git diff --name-only` stdout into unique relative paths.
|
|
340
|
+
* @param {string} stdout
|
|
341
|
+
* @returns {string[]}
|
|
342
|
+
*/
|
|
343
|
+
export function parseNameOnlyOutput(stdout) {
|
|
344
|
+
const seen = new Set();
|
|
345
|
+
/** @type {string[]} */
|
|
346
|
+
const paths = [];
|
|
347
|
+
for (const line of String(stdout || '').split('\n')) {
|
|
348
|
+
const p = line.trim();
|
|
349
|
+
if (!p || seen.has(p)) continue;
|
|
350
|
+
seen.add(p);
|
|
351
|
+
paths.push(p);
|
|
352
|
+
}
|
|
353
|
+
return paths;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Extract changed file paths from a unified diff (`diff --git a/… b/…`).
|
|
358
|
+
* Prefers the `b/` (new) path; falls back to `a/` when the file was deleted.
|
|
359
|
+
* @param {string} diff
|
|
360
|
+
* @returns {string[]}
|
|
361
|
+
*/
|
|
362
|
+
export function parseChangedFilesFromDiff(diff) {
|
|
363
|
+
const seen = new Set();
|
|
364
|
+
/** @type {string[]} */
|
|
365
|
+
const paths = [];
|
|
366
|
+
for (const line of String(diff || '').split('\n')) {
|
|
367
|
+
if (!line.startsWith('diff --git ')) continue;
|
|
368
|
+
const m = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
|
|
369
|
+
if (!m) continue;
|
|
370
|
+
const aPath = m[1];
|
|
371
|
+
const bPath = m[2];
|
|
372
|
+
const chosen =
|
|
373
|
+
bPath && bPath !== '/dev/null' ? bPath : aPath && aPath !== '/dev/null' ? aPath : null;
|
|
374
|
+
if (!chosen || seen.has(chosen)) continue;
|
|
375
|
+
seen.add(chosen);
|
|
376
|
+
paths.push(chosen);
|
|
377
|
+
}
|
|
378
|
+
return paths;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* List paths changed on HEAD vs origin/<baseBranch> (committed range).
|
|
383
|
+
* @param {string} worktreePath
|
|
384
|
+
* @param {string} baseBranch
|
|
385
|
+
* @returns {Promise<string[]>}
|
|
386
|
+
*/
|
|
387
|
+
export async function listChangedFiles(worktreePath, baseBranch) {
|
|
388
|
+
const git = gitFactory(worktreePath);
|
|
389
|
+
const out = await git.diff([`--name-only`, `origin/${baseBranch}...HEAD`]);
|
|
390
|
+
return parseNameOnlyOutput(out);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Normalize excludedPaths from an API body: unique non-empty strings.
|
|
395
|
+
* @param {unknown} raw
|
|
396
|
+
* @returns {{ ok: true, paths: string[] } | { ok: false, error: string }}
|
|
397
|
+
*/
|
|
398
|
+
export function normalizeExcludedPaths(raw) {
|
|
399
|
+
if (raw == null) return { ok: true, paths: [] };
|
|
400
|
+
if (!Array.isArray(raw)) {
|
|
401
|
+
return { ok: false, error: 'excludedPaths must be an array of strings' };
|
|
402
|
+
}
|
|
403
|
+
const seen = new Set();
|
|
404
|
+
/** @type {string[]} */
|
|
405
|
+
const paths = [];
|
|
406
|
+
for (const item of raw) {
|
|
407
|
+
if (typeof item !== 'string') {
|
|
408
|
+
return { ok: false, error: 'excludedPaths must be an array of strings' };
|
|
409
|
+
}
|
|
410
|
+
const p = item.trim();
|
|
411
|
+
if (!p || seen.has(p)) continue;
|
|
412
|
+
seen.add(p);
|
|
413
|
+
paths.push(p);
|
|
414
|
+
}
|
|
415
|
+
return { ok: true, paths };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Validate that at least one changed file remains included.
|
|
420
|
+
* Unknown excluded paths (not in changedFiles) are ignored.
|
|
421
|
+
* @param {string[]} changedFiles
|
|
422
|
+
* @param {string[]} excludedPaths
|
|
423
|
+
* @returns {{
|
|
424
|
+
* ok: true,
|
|
425
|
+
* excluded: string[],
|
|
426
|
+
* included: string[],
|
|
427
|
+
* } | { ok: false, error: string }}
|
|
428
|
+
*/
|
|
429
|
+
export function validateFileSelection(changedFiles, excludedPaths) {
|
|
430
|
+
const changed = [...new Set((changedFiles || []).map((p) => String(p).trim()).filter(Boolean))];
|
|
431
|
+
const changedSet = new Set(changed);
|
|
432
|
+
const excluded = [...new Set((excludedPaths || []).map((p) => String(p).trim()).filter(Boolean))]
|
|
433
|
+
.filter((p) => changedSet.has(p));
|
|
434
|
+
const included = changed.filter((p) => !excluded.includes(p));
|
|
435
|
+
|
|
436
|
+
if (changed.length === 0) {
|
|
437
|
+
return { ok: false, error: 'No changed files to include in the PR' };
|
|
438
|
+
}
|
|
439
|
+
if (included.length === 0) {
|
|
440
|
+
return { ok: false, error: 'Keep at least one file included' };
|
|
441
|
+
}
|
|
442
|
+
return { ok: true, excluded, included };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Restore excluded paths to match origin/<baseBranch>, then commit.
|
|
447
|
+
* New files (absent on base) are removed from the index/worktree.
|
|
448
|
+
*
|
|
449
|
+
* @param {string} worktreePath
|
|
450
|
+
* @param {string} baseBranch
|
|
451
|
+
* @param {string[]} excludedPaths
|
|
452
|
+
* @returns {Promise<{ committed: boolean, restored: string[] }>}
|
|
453
|
+
*/
|
|
454
|
+
export async function applyFileExclusions(worktreePath, baseBranch, excludedPaths) {
|
|
455
|
+
const paths = [...new Set((excludedPaths || []).map((p) => String(p).trim()).filter(Boolean))];
|
|
456
|
+
if (paths.length === 0) {
|
|
457
|
+
return { committed: false, restored: [] };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const source = `origin/${baseBranch}`;
|
|
461
|
+
/** @type {string[]} */
|
|
462
|
+
const restored = [];
|
|
463
|
+
|
|
464
|
+
for (const filePath of paths) {
|
|
465
|
+
try {
|
|
466
|
+
await execFileAsync(
|
|
467
|
+
'git',
|
|
468
|
+
['restore', `--source=${source}`, '--staged', '--worktree', '--', filePath],
|
|
469
|
+
{ cwd: worktreePath }
|
|
470
|
+
);
|
|
471
|
+
restored.push(filePath);
|
|
472
|
+
} catch {
|
|
473
|
+
// New file on the branch (or restore otherwise failed): drop it.
|
|
474
|
+
try {
|
|
475
|
+
await execFileAsync('git', ['rm', '-f', '--ignore-unmatch', '--', filePath], {
|
|
476
|
+
cwd: worktreePath,
|
|
477
|
+
});
|
|
478
|
+
restored.push(filePath);
|
|
479
|
+
} catch (rmErr) {
|
|
480
|
+
const message = rmErr instanceof Error ? rmErr.message : String(rmErr);
|
|
481
|
+
throw new Error(`Failed to exclude ${filePath}: ${message}`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const status = await execFileAsync('git', ['status', '--porcelain'], { cwd: worktreePath });
|
|
487
|
+
if (!String(status.stdout || '').trim()) {
|
|
488
|
+
return { committed: false, restored };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
await execFileAsync(
|
|
492
|
+
'git',
|
|
493
|
+
['commit', '-m', 'chore: exclude files from review selection', '--no-verify'],
|
|
494
|
+
{ cwd: worktreePath }
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
return { committed: true, restored };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* @param {string} worktreePath
|
|
502
|
+
* @param {string} branchName
|
|
503
|
+
*/
|
|
504
|
+
export async function pushBranch(worktreePath, branchName) {
|
|
505
|
+
const git = gitFactory(worktreePath);
|
|
506
|
+
await git.push(['-u', 'origin', branchName]);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* @param {string} worktreePath
|
|
511
|
+
* @param {string} issueUrl
|
|
512
|
+
*/
|
|
513
|
+
export async function getIssueTitle(worktreePath, issueUrl) {
|
|
514
|
+
const { stdout } = await execFileAsync(
|
|
515
|
+
'gh',
|
|
516
|
+
['issue', 'view', issueUrl, '--json', 'title'],
|
|
517
|
+
{ cwd: worktreePath }
|
|
518
|
+
);
|
|
519
|
+
const data = JSON.parse(stdout);
|
|
520
|
+
return data.title;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* True when a commit message line is Claude/Anthropic AI attribution.
|
|
525
|
+
* @param {string} line
|
|
526
|
+
*/
|
|
527
|
+
export function isAiCommitAttributionLine(line) {
|
|
528
|
+
const trimmed = String(line ?? '').trim();
|
|
529
|
+
if (!trimmed) return false;
|
|
530
|
+
if (/^co-authored-by:\s*/i.test(trimmed)) {
|
|
531
|
+
return /(claude|anthropic|noreply@anthropic\.com)/i.test(trimmed);
|
|
532
|
+
}
|
|
533
|
+
if (/^claude-generated-by:/i.test(trimmed)) return true;
|
|
534
|
+
if (/generated with\s*\[?claude code\]?/i.test(trimmed)) return true;
|
|
535
|
+
if (/generated by\s+claude/i.test(trimmed)) return true;
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Strip AI co-author / "Generated with Claude" trailers from a commit message.
|
|
541
|
+
* @param {string} message
|
|
542
|
+
* @returns {string}
|
|
543
|
+
*/
|
|
544
|
+
export function stripAiCommitMessage(message) {
|
|
545
|
+
if (!message || typeof message !== 'string') return message ?? '';
|
|
546
|
+
|
|
547
|
+
const normalized = message.replace(/\r\n/g, '\n');
|
|
548
|
+
const lines = normalized.split('\n');
|
|
549
|
+
const kept = lines.filter((line) => !isAiCommitAttributionLine(line));
|
|
550
|
+
|
|
551
|
+
// Drop trailing blank lines left behind after removing trailers.
|
|
552
|
+
while (kept.length > 0 && kept[kept.length - 1].trim() === '') {
|
|
553
|
+
kept.pop();
|
|
554
|
+
}
|
|
555
|
+
// Avoid leaving a double blank before where trailers were.
|
|
556
|
+
while (kept.length > 1 && kept[kept.length - 1].trim() === '' && kept[kept.length - 2].trim() === '') {
|
|
557
|
+
kept.pop();
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const result = kept.join('\n');
|
|
561
|
+
if (!result.trim()) return result;
|
|
562
|
+
return result.endsWith('\n') ? result : `${result}\n`;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* @param {string} message
|
|
567
|
+
*/
|
|
568
|
+
export function commitMessageHasAiAttribution(message) {
|
|
569
|
+
return String(message ?? '')
|
|
570
|
+
.split(/\r?\n/)
|
|
571
|
+
.some((line) => isAiCommitAttributionLine(line));
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* @param {string} name
|
|
576
|
+
* @param {string} email
|
|
577
|
+
*/
|
|
578
|
+
export function isAiGitIdentity(name, email) {
|
|
579
|
+
const n = String(name ?? '');
|
|
580
|
+
const e = String(email ?? '').toLowerCase();
|
|
581
|
+
if (/@anthropic\.com$/i.test(e) || e === 'noreply@anthropic.com') return true;
|
|
582
|
+
if (/\bclaude\b/i.test(n) || /\banthropic\b/i.test(n)) return true;
|
|
583
|
+
return false;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Write worktree-local Claude settings that disable Co-Authored-By trailers.
|
|
588
|
+
* The Agent SDK injects those trailers by default unless attribution is cleared.
|
|
589
|
+
* @param {string} worktreePath
|
|
590
|
+
*/
|
|
591
|
+
export function ensureNoAiAttributionSettings(worktreePath) {
|
|
592
|
+
const dir = path.join(worktreePath, '.claude');
|
|
593
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
594
|
+
const settingsPath = path.join(dir, 'settings.local.json');
|
|
595
|
+
|
|
596
|
+
/** @type {Record<string, unknown>} */
|
|
597
|
+
let existing = {};
|
|
598
|
+
if (fs.existsSync(settingsPath)) {
|
|
599
|
+
try {
|
|
600
|
+
existing = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
601
|
+
} catch {
|
|
602
|
+
existing = {};
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const merged = {
|
|
607
|
+
...existing,
|
|
608
|
+
includeCoAuthoredBy: false,
|
|
609
|
+
attribution: {
|
|
610
|
+
...(typeof existing.attribution === 'object' && existing.attribution
|
|
611
|
+
? existing.attribution
|
|
612
|
+
: {}),
|
|
613
|
+
commit: '',
|
|
614
|
+
pr: '',
|
|
615
|
+
},
|
|
616
|
+
};
|
|
617
|
+
fs.writeFileSync(settingsPath, `${JSON.stringify(merged, null, 2)}\n`);
|
|
618
|
+
|
|
619
|
+
// Best-effort: keep the local settings file out of agent commits.
|
|
620
|
+
try {
|
|
621
|
+
const gitMarker = path.join(worktreePath, '.git');
|
|
622
|
+
let gitDir = path.join(worktreePath, '.git');
|
|
623
|
+
if (fs.existsSync(gitMarker) && fs.statSync(gitMarker).isFile()) {
|
|
624
|
+
const text = fs.readFileSync(gitMarker, 'utf8');
|
|
625
|
+
const m = text.match(/gitdir:\s*(.+)/i);
|
|
626
|
+
if (m) gitDir = path.resolve(worktreePath, m[1].trim());
|
|
627
|
+
}
|
|
628
|
+
const excludeFile = path.join(gitDir, 'info', 'exclude');
|
|
629
|
+
const line = '.claude/settings.local.json';
|
|
630
|
+
if (fs.existsSync(path.dirname(excludeFile))) {
|
|
631
|
+
const current = fs.existsSync(excludeFile)
|
|
632
|
+
? fs.readFileSync(excludeFile, 'utf8')
|
|
633
|
+
: '';
|
|
634
|
+
if (!current.split(/\r?\n/).includes(line)) {
|
|
635
|
+
const prefix = current === '' || current.endsWith('\n') ? '' : '\n';
|
|
636
|
+
fs.appendFileSync(excludeFile, `${prefix}${line}\n`);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
} catch {
|
|
640
|
+
// ignore exclude failures
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
return settingsPath;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Resolve git user.name / user.email from a repo (falls back to global).
|
|
648
|
+
* @param {string} cwd
|
|
649
|
+
* @returns {Promise<{ name: string, email: string } | null>}
|
|
650
|
+
*/
|
|
651
|
+
export async function getGitUser(cwd) {
|
|
652
|
+
try {
|
|
653
|
+
const [{ stdout: nameOut }, { stdout: emailOut }] = await Promise.all([
|
|
654
|
+
execFileAsync('git', ['config', 'user.name'], { cwd }),
|
|
655
|
+
execFileAsync('git', ['config', 'user.email'], { cwd }),
|
|
656
|
+
]);
|
|
657
|
+
const name = nameOut.trim();
|
|
658
|
+
const email = emailOut.trim();
|
|
659
|
+
if (!name || !email) return null;
|
|
660
|
+
return { name, email };
|
|
661
|
+
} catch {
|
|
662
|
+
return null;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* @param {string} worktreePath
|
|
668
|
+
* @param {string} baseBranch
|
|
669
|
+
* @returns {Promise<Array<{ sha: string, message: string, authorName: string, authorEmail: string }>>}
|
|
670
|
+
*/
|
|
671
|
+
async function listBranchCommits(worktreePath, baseBranch) {
|
|
672
|
+
const range = `origin/${baseBranch}..HEAD`;
|
|
673
|
+
let stdout;
|
|
674
|
+
try {
|
|
675
|
+
({ stdout } = await execFileAsync(
|
|
676
|
+
'git',
|
|
677
|
+
['rev-list', '--reverse', range],
|
|
678
|
+
{ cwd: worktreePath }
|
|
679
|
+
));
|
|
680
|
+
} catch {
|
|
681
|
+
return [];
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const shas = stdout
|
|
685
|
+
.trim()
|
|
686
|
+
.split('\n')
|
|
687
|
+
.map((s) => s.trim())
|
|
688
|
+
.filter(Boolean);
|
|
689
|
+
if (shas.length === 0) return [];
|
|
690
|
+
|
|
691
|
+
/** @type {Array<{ sha: string, message: string, authorName: string, authorEmail: string }>} */
|
|
692
|
+
const commits = [];
|
|
693
|
+
for (const sha of shas) {
|
|
694
|
+
const { stdout: meta } = await execFileAsync(
|
|
695
|
+
'git',
|
|
696
|
+
['log', '-1', '--format=%an%n%ae%n%B', sha],
|
|
697
|
+
{ cwd: worktreePath }
|
|
698
|
+
);
|
|
699
|
+
const nl1 = meta.indexOf('\n');
|
|
700
|
+
const nl2 = meta.indexOf('\n', nl1 + 1);
|
|
701
|
+
const authorName = nl1 === -1 ? '' : meta.slice(0, nl1);
|
|
702
|
+
const authorEmail = nl2 === -1 ? '' : meta.slice(nl1 + 1, nl2);
|
|
703
|
+
const message = nl2 === -1 ? '' : meta.slice(nl2 + 1);
|
|
704
|
+
commits.push({ sha, message, authorName, authorEmail });
|
|
705
|
+
}
|
|
706
|
+
return commits;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Rewrite branch-only commits to strip AI attribution trailers and fix AI authors.
|
|
711
|
+
* Runs before push (awaiting_review). No-ops when there are no commits or nothing to fix.
|
|
712
|
+
*
|
|
713
|
+
* @param {string} worktreePath
|
|
714
|
+
* @param {string} baseBranch
|
|
715
|
+
* @param {string} [authorSourceCwd] repo used to read user.name / user.email
|
|
716
|
+
* @returns {Promise<{ rewritten: number, commits: number }>}
|
|
717
|
+
*/
|
|
718
|
+
export async function sanitizeBranchCommits(worktreePath, baseBranch, authorSourceCwd) {
|
|
719
|
+
const commits = await listBranchCommits(worktreePath, baseBranch);
|
|
720
|
+
if (commits.length === 0) {
|
|
721
|
+
return { rewritten: 0, commits: 0 };
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const gitUser = await getGitUser(authorSourceCwd || worktreePath);
|
|
725
|
+
const toFix = commits.filter((c) => {
|
|
726
|
+
const stripped = stripAiCommitMessage(c.message);
|
|
727
|
+
const msgNeeds =
|
|
728
|
+
commitMessageHasAiAttribution(c.message) ||
|
|
729
|
+
stripped.replace(/\n+$/, '') !== c.message.replace(/\r\n/g, '\n').replace(/\n+$/, '');
|
|
730
|
+
const authorNeeds = Boolean(gitUser && isAiGitIdentity(c.authorName, c.authorEmail));
|
|
731
|
+
return msgNeeds || authorNeeds;
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
if (toFix.length === 0) {
|
|
735
|
+
return { rewritten: 0, commits: commits.length };
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
if (commits.length === 1) {
|
|
739
|
+
await amendHeadCommit(worktreePath, commits[0], gitUser);
|
|
740
|
+
return { rewritten: 1, commits: 1 };
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
await rewriteBranchCommitsWithFilter(worktreePath, baseBranch, gitUser);
|
|
744
|
+
return { rewritten: toFix.length, commits: commits.length };
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* @param {string} worktreePath
|
|
749
|
+
* @param {{ message: string, authorName: string, authorEmail: string }} commit
|
|
750
|
+
* @param {{ name: string, email: string } | null} gitUser
|
|
751
|
+
*/
|
|
752
|
+
async function amendHeadCommit(worktreePath, commit, gitUser) {
|
|
753
|
+
const cleaned = stripAiCommitMessage(commit.message).replace(/\n$/, '');
|
|
754
|
+
const forceAuthor = Boolean(gitUser && isAiGitIdentity(commit.authorName, commit.authorEmail));
|
|
755
|
+
|
|
756
|
+
/** @type {NodeJS.ProcessEnv} */
|
|
757
|
+
const env = { ...process.env };
|
|
758
|
+
const args = ['commit', '--amend', '-m', cleaned, '--no-verify'];
|
|
759
|
+
|
|
760
|
+
if (gitUser) {
|
|
761
|
+
env.GIT_COMMITTER_NAME = gitUser.name;
|
|
762
|
+
env.GIT_COMMITTER_EMAIL = gitUser.email;
|
|
763
|
+
if (forceAuthor) {
|
|
764
|
+
args.push('--author', `${gitUser.name} <${gitUser.email}>`);
|
|
765
|
+
env.GIT_AUTHOR_NAME = gitUser.name;
|
|
766
|
+
env.GIT_AUTHOR_EMAIL = gitUser.email;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
await execFileAsync('git', args, { cwd: worktreePath, env });
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Rewrite commits in origin/base..HEAD: strip AI trailers; fix AI author identities.
|
|
775
|
+
* @param {string} worktreePath
|
|
776
|
+
* @param {string} baseBranch
|
|
777
|
+
* @param {{ name: string, email: string } | null} gitUser
|
|
778
|
+
*/
|
|
779
|
+
async function rewriteBranchCommitsWithFilter(worktreePath, baseBranch, gitUser) {
|
|
780
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'acdev-rebase-'));
|
|
781
|
+
const msgFilterPath = path.join(tmpDir, 'msg-filter.mjs');
|
|
782
|
+
const envFilterPath = path.join(tmpDir, 'env-filter.sh');
|
|
783
|
+
|
|
784
|
+
fs.writeFileSync(
|
|
785
|
+
msgFilterPath,
|
|
786
|
+
`import fs from 'node:fs';
|
|
787
|
+
const message = fs.readFileSync(0, 'utf8');
|
|
788
|
+
const lines = message.replace(/\\r\\n/g, '\\n').split('\\n');
|
|
789
|
+
const kept = lines.filter((line) => {
|
|
790
|
+
const t = line.trim();
|
|
791
|
+
if (!t) return true;
|
|
792
|
+
if (/^co-authored-by:\\s*/i.test(t)) {
|
|
793
|
+
return !/(claude|anthropic|noreply@anthropic\\.com)/i.test(t);
|
|
794
|
+
}
|
|
795
|
+
if (/^claude-generated-by:/i.test(t)) return false;
|
|
796
|
+
if (/generated with\\s*\\[?claude code\\]?/i.test(t)) return false;
|
|
797
|
+
if (/generated by\\s+claude/i.test(t)) return false;
|
|
798
|
+
return true;
|
|
799
|
+
});
|
|
800
|
+
while (kept.length > 0 && kept[kept.length - 1].trim() === '') kept.pop();
|
|
801
|
+
let result = kept.join('\\n');
|
|
802
|
+
if (result.trim() && !result.endsWith('\\n')) result += '\\n';
|
|
803
|
+
process.stdout.write(result);
|
|
804
|
+
`
|
|
805
|
+
);
|
|
806
|
+
|
|
807
|
+
const name = gitUser?.name?.replace(/'/g, `'\\''`) ?? '';
|
|
808
|
+
const email = gitUser?.email?.replace(/'/g, `'\\''`) ?? '';
|
|
809
|
+
fs.writeFileSync(
|
|
810
|
+
envFilterPath,
|
|
811
|
+
`#!/bin/sh
|
|
812
|
+
is_ai=0
|
|
813
|
+
case "$GIT_AUTHOR_EMAIL" in
|
|
814
|
+
*anthropic.com) is_ai=1 ;;
|
|
815
|
+
esac
|
|
816
|
+
case "$GIT_AUTHOR_NAME" in
|
|
817
|
+
*[Cc]laude*|*[Aa]nthropic*) is_ai=1 ;;
|
|
818
|
+
esac
|
|
819
|
+
if [ "$is_ai" = "1" ] && [ -n '${name}' ] && [ -n '${email}' ]; then
|
|
820
|
+
export GIT_AUTHOR_NAME='${name}'
|
|
821
|
+
export GIT_AUTHOR_EMAIL='${email}'
|
|
822
|
+
export GIT_COMMITTER_NAME='${name}'
|
|
823
|
+
export GIT_COMMITTER_EMAIL='${email}'
|
|
824
|
+
fi
|
|
825
|
+
`,
|
|
826
|
+
{ mode: 0o755 }
|
|
827
|
+
);
|
|
828
|
+
|
|
829
|
+
const quotedMsg = JSON.stringify(msgFilterPath);
|
|
830
|
+
const quotedEnv = JSON.stringify(envFilterPath);
|
|
831
|
+
|
|
832
|
+
try {
|
|
833
|
+
await execFileAsync(
|
|
834
|
+
'git',
|
|
835
|
+
[
|
|
836
|
+
'filter-branch',
|
|
837
|
+
'-f',
|
|
838
|
+
'--msg-filter',
|
|
839
|
+
`${process.execPath} ${quotedMsg}`,
|
|
840
|
+
'--env-filter',
|
|
841
|
+
`. ${quotedEnv}`,
|
|
842
|
+
`origin/${baseBranch}..HEAD`,
|
|
843
|
+
],
|
|
844
|
+
{
|
|
845
|
+
cwd: worktreePath,
|
|
846
|
+
env: { ...process.env, FILTER_BRANCH_SQUELCH_WARNING: '1' },
|
|
847
|
+
}
|
|
848
|
+
);
|
|
849
|
+
} catch (err) {
|
|
850
|
+
const detail = err.stderr?.toString?.() || err.message || String(err);
|
|
851
|
+
throw new Error(`Failed to strip AI attribution from commits: ${detail}`);
|
|
852
|
+
} finally {
|
|
853
|
+
try {
|
|
854
|
+
const gitDir = (
|
|
855
|
+
await execFileAsync('git', ['rev-parse', '--git-dir'], { cwd: worktreePath })
|
|
856
|
+
).stdout.trim();
|
|
857
|
+
const originalRefs = path.resolve(worktreePath, gitDir, 'refs', 'original');
|
|
858
|
+
if (fs.existsSync(originalRefs)) {
|
|
859
|
+
fs.rmSync(originalRefs, { recursive: true, force: true });
|
|
860
|
+
}
|
|
861
|
+
} catch {
|
|
862
|
+
// Best-effort cleanup of filter-branch backup refs.
|
|
863
|
+
}
|
|
864
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|