@link-assistant/hive-mind 1.73.9 → 1.74.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 +27 -0
- package/README.hi.md +36 -0
- package/README.md +37 -0
- package/README.ru.md +37 -0
- package/README.zh.md +35 -0
- package/package.json +2 -1
- package/src/cleanup.lib.mjs +359 -0
- package/src/cleanup.mjs +288 -0
- package/src/cleanup.os.lib.mjs +404 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 1.74.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- b00a51c: feat(cleanup): add a task-aware `cleanup` command to free disk space safely (#1848)
|
|
8
|
+
|
|
9
|
+
Adds a new `cleanup` bin that removes stale hive-mind temporary
|
|
10
|
+
directories/files under the system temp dir while preserving folders that belong
|
|
11
|
+
to currently-running tasks, protected system paths, and any clone with
|
|
12
|
+
uncommitted or unpushed work.
|
|
13
|
+
|
|
14
|
+
Highlights:
|
|
15
|
+
- `--dry-run` / `-n` prints the full list of kept folders and folders that would
|
|
16
|
+
be deleted (with sizes and reasons), deleting nothing.
|
|
17
|
+
- `--keep-active-tasks-folders` (default on) detects active tasks from running
|
|
18
|
+
processes (`/proc`) and live isolation sessions (`screen`/`tmux` +
|
|
19
|
+
`$ --status`), and matches clones to tasks by branch name using the same logic
|
|
20
|
+
as `solve` (issue → `issue-{n}-{hex}` scoped to the repo; PR → its resolved
|
|
21
|
+
head branch). Disable with `--no-keep-active-tasks-folders`.
|
|
22
|
+
- Keeps `/tmp/start-command/` and system-owned temp entries by default;
|
|
23
|
+
`--force-start-command` allows deleting `/tmp/start-command` when needed.
|
|
24
|
+
- Optional Ubuntu/system cleanup behind explicit flags: `--apt`, `--journal`,
|
|
25
|
+
`--docker`, `--npm` (and `--system` shorthand), with `--sudo`.
|
|
26
|
+
- Safe by default: keeps unrecognised entries unless `--all`, never deletes
|
|
27
|
+
paths held open by a running process or used by the cleanup process itself,
|
|
28
|
+
and requires confirmation unless `--force`.
|
|
29
|
+
|
|
3
30
|
## 1.73.9
|
|
4
31
|
|
|
5
32
|
### Patch Changes
|
package/README.hi.md
CHANGED
|
@@ -717,6 +717,42 @@ solve https://github.com/owner/repo/issues/123 --resume 657e6db1-6eb3-4a8d
|
|
|
717
717
|
(cd /tmp/gh-issue-solver-123456789 && claude --resume session-id)
|
|
718
718
|
```
|
|
719
719
|
|
|
720
|
+
### डिस्क क्लीनअप
|
|
721
|
+
|
|
722
|
+
`cleanup` पुरानी hive-mind अस्थायी डायरेक्टरी/फ़ाइलों (जैसे प्रति-कार्य क्लोन
|
|
723
|
+
`/tmp/gh-issue-solver-*`, MCP कॉन्फ़िग फ़ाइलें, लॉग डाउनलोड डायरेक्टरी आदि) को हटाकर
|
|
724
|
+
डिस्क स्थान खाली करता है, जबकि **वर्तमान में चल रहे कार्यों से संबंधित फ़ोल्डर**,
|
|
725
|
+
सुरक्षित सिस्टम पथ, और बिना कमिट या बिना पुश किए बदलावों वाले किसी भी क्लोन को बनाए
|
|
726
|
+
रखता है। यह चल रही प्रक्रियाओं और लाइव आइसोलेशन सेशन से सक्रिय कार्यों का पता लगाता है
|
|
727
|
+
और `solve` के समान लॉजिक का उपयोग करते हुए शाखा नाम द्वारा क्लोन को कार्यों से मिलाता
|
|
728
|
+
है (issue → `issue-{n}-{hex}`; PR → इसकी हल की गई head शाखा)।
|
|
729
|
+
|
|
730
|
+
```bash
|
|
731
|
+
# पूर्वावलोकन: रखे जाने वाले और हटाए जाने वाले फ़ोल्डरों की सूची (कुछ भी नहीं हटाता)
|
|
732
|
+
cleanup --dry-run
|
|
733
|
+
|
|
734
|
+
# पुरानी अस्थायी फ़ाइलें वास्तव में हटाएँ (पहले पुष्टि माँगता है)
|
|
735
|
+
cleanup
|
|
736
|
+
|
|
737
|
+
# पुष्टि प्रॉम्प्ट के बिना हटाएँ
|
|
738
|
+
cleanup --force
|
|
739
|
+
|
|
740
|
+
# गैर-hive-mind अस्थायी प्रविष्टियों पर भी विचार करें (अधिक आक्रामक)
|
|
741
|
+
cleanup --all --dry-run
|
|
742
|
+
|
|
743
|
+
# /tmp/start-command को हटाने की अनुमति दें (डिफ़ॉल्ट रूप से रखा जाता है; इसमें आइसोलेशन लॉग होते हैं)
|
|
744
|
+
cleanup --force-start-command
|
|
745
|
+
|
|
746
|
+
# Ubuntu / सिस्टम क्लीनअप (apt कैश, journald लॉग, npm कैश)
|
|
747
|
+
cleanup --system --sudo
|
|
748
|
+
|
|
749
|
+
# सक्रिय-कार्य पहचान अक्षम करें (केवल सुरक्षित पथ रखे जाते हैं)
|
|
750
|
+
cleanup --no-keep-active-tasks-folders --dry-run
|
|
751
|
+
```
|
|
752
|
+
|
|
753
|
+
विकल्पों की पूरी सूची के लिए `cleanup --help` चलाएँ। यह कमांड dry-run के अनुकूल है और
|
|
754
|
+
हर रन के लिए टाइमस्टैम्प वाला `cleanup-*.log` लिखता है।
|
|
755
|
+
|
|
720
756
|
## 🔍 निगरानी और लॉगिंग
|
|
721
757
|
|
|
722
758
|
लॉग में resume कमांड खोजें:
|
package/README.md
CHANGED
|
@@ -737,6 +737,43 @@ solve https://github.com/owner/repo/issues/123 --resume 657e6db1-6eb3-4a8d
|
|
|
737
737
|
(cd /tmp/gh-issue-solver-123456789 && claude --resume session-id)
|
|
738
738
|
```
|
|
739
739
|
|
|
740
|
+
### Disk Cleanup
|
|
741
|
+
|
|
742
|
+
`cleanup` frees disk space by removing stale hive-mind temporary
|
|
743
|
+
directories/files (per-task clones like `/tmp/gh-issue-solver-*`, MCP config
|
|
744
|
+
files, log download dirs, …) while **keeping folders that belong to
|
|
745
|
+
currently-running tasks**, protected system paths, and any clone with
|
|
746
|
+
uncommitted or unpushed work. It detects active tasks from running processes and
|
|
747
|
+
live isolation sessions and matches clones to tasks by branch name using the
|
|
748
|
+
same logic as `solve` (issue → `issue-{n}-{hex}`; PR → its resolved head
|
|
749
|
+
branch).
|
|
750
|
+
|
|
751
|
+
```bash
|
|
752
|
+
# Preview: list kept folders and folders that would be deleted (deletes nothing)
|
|
753
|
+
cleanup --dry-run
|
|
754
|
+
|
|
755
|
+
# Actually delete stale temp artifacts (asks for confirmation first)
|
|
756
|
+
cleanup
|
|
757
|
+
|
|
758
|
+
# Delete without the confirmation prompt
|
|
759
|
+
cleanup --force
|
|
760
|
+
|
|
761
|
+
# Also consider non-hive-mind temp entries (more aggressive)
|
|
762
|
+
cleanup --all --dry-run
|
|
763
|
+
|
|
764
|
+
# Allow deleting /tmp/start-command (kept by default; holds isolation logs)
|
|
765
|
+
cleanup --force-start-command
|
|
766
|
+
|
|
767
|
+
# Ubuntu / system cleanup (apt caches, journald logs, npm cache)
|
|
768
|
+
cleanup --system --sudo
|
|
769
|
+
|
|
770
|
+
# Disable active-task detection (only protected paths are kept)
|
|
771
|
+
cleanup --no-keep-active-tasks-folders --dry-run
|
|
772
|
+
```
|
|
773
|
+
|
|
774
|
+
Run `cleanup --help` for the full list of options. The command is dry-run
|
|
775
|
+
friendly and writes a timestamped `cleanup-*.log` for every run.
|
|
776
|
+
|
|
740
777
|
## 🔍 Monitoring & Logging
|
|
741
778
|
|
|
742
779
|
Find resume commands in logs:
|
package/README.ru.md
CHANGED
|
@@ -719,6 +719,43 @@ solve https://github.com/owner/repo/issues/123 --resume 657e6db1-6eb3-4a8d
|
|
|
719
719
|
(cd /tmp/gh-issue-solver-123456789 && claude --resume session-id)
|
|
720
720
|
```
|
|
721
721
|
|
|
722
|
+
### Очистка диска
|
|
723
|
+
|
|
724
|
+
`cleanup` освобождает место на диске, удаляя устаревшие временные каталоги/файлы
|
|
725
|
+
hive-mind (клоны для каждой задачи вида `/tmp/gh-issue-solver-*`, файлы
|
|
726
|
+
конфигурации MCP, каталоги загрузки логов и т. д.), при этом **сохраняя папки,
|
|
727
|
+
относящиеся к выполняющимся в данный момент задачам**, защищённые системные пути и
|
|
728
|
+
любой клон с незакоммиченными или неотправленными изменениями. Он обнаруживает
|
|
729
|
+
активные задачи по запущенным процессам и активным сессиям изоляции и сопоставляет
|
|
730
|
+
клоны с задачами по имени ветки, используя ту же логику, что и `solve`
|
|
731
|
+
(issue → `issue-{n}-{hex}`; PR → его разрешённая head-ветка).
|
|
732
|
+
|
|
733
|
+
```bash
|
|
734
|
+
# Предпросмотр: список сохраняемых и удаляемых папок (ничего не удаляет)
|
|
735
|
+
cleanup --dry-run
|
|
736
|
+
|
|
737
|
+
# Реально удалить устаревшие временные файлы (сначала запросит подтверждение)
|
|
738
|
+
cleanup
|
|
739
|
+
|
|
740
|
+
# Удалить без запроса подтверждения
|
|
741
|
+
cleanup --force
|
|
742
|
+
|
|
743
|
+
# Учитывать также не-hive-mind временные записи (более агрессивно)
|
|
744
|
+
cleanup --all --dry-run
|
|
745
|
+
|
|
746
|
+
# Разрешить удаление /tmp/start-command (по умолчанию сохраняется; хранит логи изоляции)
|
|
747
|
+
cleanup --force-start-command
|
|
748
|
+
|
|
749
|
+
# Очистка Ubuntu / системы (кэши apt, логи journald, кэш npm)
|
|
750
|
+
cleanup --system --sudo
|
|
751
|
+
|
|
752
|
+
# Отключить обнаружение активных задач (сохраняются только защищённые пути)
|
|
753
|
+
cleanup --no-keep-active-tasks-folders --dry-run
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
Запустите `cleanup --help`, чтобы увидеть полный список опций. Команда удобна для
|
|
757
|
+
режима dry-run и записывает лог `cleanup-*.log` с меткой времени при каждом запуске.
|
|
758
|
+
|
|
722
759
|
## 🔍 Мониторинг и логирование
|
|
723
760
|
|
|
724
761
|
Найдите команды возобновления в логах:
|
package/README.zh.md
CHANGED
|
@@ -713,6 +713,41 @@ solve https://github.com/owner/repo/issues/123 --resume 657e6db1-6eb3-4a8d
|
|
|
713
713
|
(cd /tmp/gh-issue-solver-123456789 && claude --resume session-id)
|
|
714
714
|
```
|
|
715
715
|
|
|
716
|
+
### 磁盘清理
|
|
717
|
+
|
|
718
|
+
`cleanup` 通过删除过时的 hive-mind 临时目录/文件(如每个任务的克隆
|
|
719
|
+
`/tmp/gh-issue-solver-*`、MCP 配置文件、日志下载目录等)来释放磁盘空间,同时
|
|
720
|
+
**保留属于当前正在运行任务的文件夹**、受保护的系统路径,以及任何包含未提交或未推送
|
|
721
|
+
更改的克隆。它通过运行中的进程和实时隔离会话检测活动任务,并使用与 `solve` 相同的
|
|
722
|
+
逻辑按分支名称将克隆与任务匹配(issue → `issue-{n}-{hex}`;PR → 其解析出的 head
|
|
723
|
+
分支)。
|
|
724
|
+
|
|
725
|
+
```bash
|
|
726
|
+
# 预览:列出保留的文件夹和将被删除的文件夹(不删除任何内容)
|
|
727
|
+
cleanup --dry-run
|
|
728
|
+
|
|
729
|
+
# 实际删除过时的临时文件(会先要求确认)
|
|
730
|
+
cleanup
|
|
731
|
+
|
|
732
|
+
# 删除时不显示确认提示
|
|
733
|
+
cleanup --force
|
|
734
|
+
|
|
735
|
+
# 同时考虑非 hive-mind 临时项(更激进)
|
|
736
|
+
cleanup --all --dry-run
|
|
737
|
+
|
|
738
|
+
# 允许删除 /tmp/start-command(默认保留;其中存放隔离日志)
|
|
739
|
+
cleanup --force-start-command
|
|
740
|
+
|
|
741
|
+
# Ubuntu / 系统清理(apt 缓存、journald 日志、npm 缓存)
|
|
742
|
+
cleanup --system --sudo
|
|
743
|
+
|
|
744
|
+
# 禁用活动任务检测(仅保留受保护的路径)
|
|
745
|
+
cleanup --no-keep-active-tasks-folders --dry-run
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
运行 `cleanup --help` 查看完整的选项列表。该命令对 dry-run 友好,并为每次运行写入
|
|
749
|
+
带时间戳的 `cleanup-*.log` 日志。
|
|
750
|
+
|
|
716
751
|
## 🔍 监控与日志
|
|
717
752
|
|
|
718
753
|
在日志中查找恢复命令:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@link-assistant/hive-mind",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.74.0",
|
|
4
4
|
"description": "AI-powered issue solver and hive mind for collaborative problem solving",
|
|
5
5
|
"main": "src/hive.mjs",
|
|
6
6
|
"type": "module",
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"hive": "./src/hive.mjs",
|
|
9
9
|
"solve": "./src/solve.mjs",
|
|
10
10
|
"task": "./src/task.mjs",
|
|
11
|
+
"cleanup": "./src/cleanup.mjs",
|
|
11
12
|
"review": "./src/review.mjs",
|
|
12
13
|
"configure-claude": "./src/configure-claude.mjs",
|
|
13
14
|
"start-screen": "./src/start-screen.mjs",
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core, offline-testable logic for the `cleanup` command (issue #1848).
|
|
3
|
+
*
|
|
4
|
+
* This module deliberately avoids any top-level network access (no `use-m`
|
|
5
|
+
* fetch) and any side effects so it can be unit-tested without a network
|
|
6
|
+
* connection or a real filesystem. All OS interaction (reading /tmp, querying
|
|
7
|
+
* `$ --status`, scanning /proc, deleting paths, apt cleanup) lives in
|
|
8
|
+
* `cleanup.os.lib.mjs` / `cleanup.mjs`; this module only contains pure
|
|
9
|
+
* classification, parsing and formatting helpers.
|
|
10
|
+
*
|
|
11
|
+
* The classification mirrors the manual workflow described in the issue: list
|
|
12
|
+
* the temporary directories under the tmp root, figure out which ones belong to
|
|
13
|
+
* currently-running solve tasks (by branch name, the same way solve.mjs derives
|
|
14
|
+
* branches), and keep those while removing the rest. Protected system paths such
|
|
15
|
+
* as `/tmp/start-command/` are always preserved unless explicitly forced.
|
|
16
|
+
*
|
|
17
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1848
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { isValidIssueBranchName } from './solve.branch.lib.mjs';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Directory names directly under the tmp root that must never be removed by
|
|
24
|
+
* default because deleting them would interfere with the system's ability to
|
|
25
|
+
* run or be debugged. `start-command` holds the isolation session logs that
|
|
26
|
+
* `$ --status`, /log and /terminal_watch rely on.
|
|
27
|
+
*/
|
|
28
|
+
export const DEFAULT_PROTECTED_NAMES = ['start-command'];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* System-owned temp entries that we never touch even in `--all` mode unless the
|
|
32
|
+
* user explicitly opts in. These are created by the OS / desktop / language
|
|
33
|
+
* runtimes and removing them mid-flight can break unrelated processes.
|
|
34
|
+
*/
|
|
35
|
+
export const SYSTEM_PROTECTED_PATTERNS = [/^\.X11-unix$/, /^\.XIM-unix$/, /^\.ICE-unix$/, /^\.font-unix$/, /^\.Test-unix$/, /^systemd-private-/, /^snap-private-tmp$/, /^snap\./, /^\.snap/, /^dbus-/, /^ssh-/, /^hsperfdata_/, /^\.org\.chromium\./, /^\.com\.google\.Chrome\./];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Patterns for temporary entries that are unambiguously created by hive-mind
|
|
39
|
+
* (solve.mjs, github.lib.mjs, claude.lib.mjs, telegram-*, etc.). These are safe
|
|
40
|
+
* to delete when they are not tied to an active task. Each entry has a `name`
|
|
41
|
+
* (for reporting) and a `regex` matched against the basename under the tmp root.
|
|
42
|
+
*
|
|
43
|
+
* Sources are referenced inline so future maintainers can keep this list in
|
|
44
|
+
* sync with the code that produces the files.
|
|
45
|
+
*/
|
|
46
|
+
export const HIVE_MIND_TEMP_PATTERNS = [
|
|
47
|
+
// solve.repository.lib.mjs / solve.execution.lib.mjs workspace clones
|
|
48
|
+
{ name: 'solve workspace clone', regex: /^gh-issue-solver-\d+$/ },
|
|
49
|
+
{ name: 'solve resume workspace', regex: /^gh-issue-solver-resume-.+$/ },
|
|
50
|
+
// solve.repository.lib.mjs buildWorkspacePath parent dir
|
|
51
|
+
{ name: 'solve workspace root', regex: /^hive-mind-solve-gh-/ },
|
|
52
|
+
// github.lib.mjs log download working dirs
|
|
53
|
+
{ name: 'solution draft log dir', regex: /^log-tmp-solution-draft-log-/ },
|
|
54
|
+
// claude.lib.mjs MCP config temp files
|
|
55
|
+
{ name: 'claude MCP config', regex: /^claude-mcp-no-useless-.+\.json$/ },
|
|
56
|
+
{ name: 'claude MCP config', regex: /^claude-mcp-.+\.json$/ },
|
|
57
|
+
// github.lib.mjs comment / body temp files
|
|
58
|
+
{ name: 'solution draft log', regex: /^solution-draft-log-.+\.txt$/ },
|
|
59
|
+
{ name: 'log upload comment', regex: /^log-upload-comment-.+\.md$/ },
|
|
60
|
+
{ name: 'log comment', regex: /^log-comment-.+\.md$/ },
|
|
61
|
+
// github-error-reporter.lib.mjs
|
|
62
|
+
{ name: 'issue body temp', regex: /^hive-mind-issue-body-.+\.md$/ },
|
|
63
|
+
// solve.auto-pr.lib.mjs / solve.results.lib.mjs
|
|
64
|
+
{ name: 'PR body temp', regex: /^pr-body-.+$/ },
|
|
65
|
+
{ name: 'PR title temp', regex: /^pr-title-.+\.txt$/ },
|
|
66
|
+
// solve.progress-monitoring.lib.mjs
|
|
67
|
+
{ name: 'PR progress temp', regex: /^pr-progress-.+$/ },
|
|
68
|
+
// telegram-top-command.lib.mjs
|
|
69
|
+
{ name: 'telegram top output', regex: /^top-output-.+\.txt$/ },
|
|
70
|
+
// start-screen.mjs
|
|
71
|
+
{ name: 'screen ready marker', regex: /^screen-ready-.+\.marker$/ },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Parse a GitHub issue/PR URL out of an arbitrary string (e.g. a solve command
|
|
76
|
+
* line). Self-contained so this module stays offline-safe (github.lib.mjs is
|
|
77
|
+
* not import-safe because of its top-level use-m fetch).
|
|
78
|
+
*
|
|
79
|
+
* @param {string} url
|
|
80
|
+
* @returns {{owner: string, repo: string, type: 'issue'|'pull', number: number}|null}
|
|
81
|
+
*/
|
|
82
|
+
export function parseTaskUrl(url) {
|
|
83
|
+
if (!url || typeof url !== 'string') return null;
|
|
84
|
+
const match = url.match(/github\.com[/:]([^/\s]+)\/([^/\s#]+?)(?:\.git)?\/(issues|issue|pull|pulls)\/(\d+)/i);
|
|
85
|
+
if (!match) return null;
|
|
86
|
+
const type = /^pull/i.test(match[3]) ? 'pull' : 'issue';
|
|
87
|
+
return {
|
|
88
|
+
owner: match[1],
|
|
89
|
+
repo: match[2],
|
|
90
|
+
type,
|
|
91
|
+
number: Number(match[4]),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Extract all GitHub issue/PR references from a command line string. A solve
|
|
97
|
+
* command typically takes the URL as its first positional argument, but we scan
|
|
98
|
+
* the whole string to be tolerant of flag ordering.
|
|
99
|
+
*
|
|
100
|
+
* @param {string} command
|
|
101
|
+
* @returns {Array<{owner: string, repo: string, type: 'issue'|'pull', number: number}>}
|
|
102
|
+
*/
|
|
103
|
+
export function extractTaskRefsFromCommand(command) {
|
|
104
|
+
if (!command || typeof command !== 'string') return [];
|
|
105
|
+
const refs = [];
|
|
106
|
+
const seen = new Set();
|
|
107
|
+
const re = /github\.com[/:]([^/\s]+)\/([^/\s#]+?)(?:\.git)?\/(issues|issue|pull|pulls)\/(\d+)/gi;
|
|
108
|
+
let m;
|
|
109
|
+
while ((m = re.exec(command)) !== null) {
|
|
110
|
+
const ref = parseTaskUrl(m[0]);
|
|
111
|
+
if (!ref) continue;
|
|
112
|
+
const key = `${ref.owner}/${ref.repo}#${ref.number}:${ref.type}`;
|
|
113
|
+
if (seen.has(key)) continue;
|
|
114
|
+
seen.add(key);
|
|
115
|
+
refs.push(ref);
|
|
116
|
+
}
|
|
117
|
+
return refs;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Normalise an owner/repo pair extracted from a git remote URL.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} remoteUrl
|
|
124
|
+
* @returns {{owner: string, repo: string}|null}
|
|
125
|
+
*/
|
|
126
|
+
export function parseRemoteUrl(remoteUrl) {
|
|
127
|
+
if (!remoteUrl || typeof remoteUrl !== 'string') return null;
|
|
128
|
+
// git@github.com:owner/repo.git OR https://github.com/owner/repo(.git)
|
|
129
|
+
const sshMatch = remoteUrl.match(/^[^@]+@[^:]+:([^/]+)\/(.+?)(?:\.git)?$/);
|
|
130
|
+
if (sshMatch) return { owner: sshMatch[1], repo: sshMatch[2] };
|
|
131
|
+
const httpMatch = remoteUrl.match(/^[a-z]+:\/\/[^/]+\/([^/]+)\/(.+?)(?:\.git)?$/i);
|
|
132
|
+
if (httpMatch) return { owner: httpMatch[1], repo: httpMatch[2] };
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function sameRepo(a, b) {
|
|
137
|
+
if (!a || !b) return false;
|
|
138
|
+
return a.owner.toLowerCase() === b.owner.toLowerCase() && a.repo.toLowerCase() === b.repo.toLowerCase();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Build the set of "active task matchers" from the running session task list.
|
|
143
|
+
* For PR tasks the resolved head branch is matched exactly; for issue tasks we
|
|
144
|
+
* fall back to the `issue-{number}-{hex}` prefix (the random hex is unknown from
|
|
145
|
+
* the URL alone) combined with a repo match.
|
|
146
|
+
*
|
|
147
|
+
* @param {Array<{owner, repo, type, number, branch?: string|null}>} activeTasks
|
|
148
|
+
* @returns {Array<{owner, repo, type, issueNumber: number|null, branch: string|null}>}
|
|
149
|
+
*/
|
|
150
|
+
export function buildActiveMatchers(activeTasks) {
|
|
151
|
+
const matchers = [];
|
|
152
|
+
for (const task of activeTasks || []) {
|
|
153
|
+
if (!task) continue;
|
|
154
|
+
matchers.push({
|
|
155
|
+
owner: task.owner,
|
|
156
|
+
repo: task.repo,
|
|
157
|
+
type: task.type,
|
|
158
|
+
issueNumber: task.type === 'issue' ? task.number : (task.issueNumber ?? null),
|
|
159
|
+
branch: task.branch || null,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return matchers;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Decide whether a folder's git info matches one of the active task matchers.
|
|
167
|
+
*
|
|
168
|
+
* @param {{branch: string|null, remotes: Array<{owner, repo}>}|null} gitInfo
|
|
169
|
+
* @param {Array} matchers - from buildActiveMatchers
|
|
170
|
+
* @returns {Object|null} the matched matcher, or null
|
|
171
|
+
*/
|
|
172
|
+
export function folderMatchesActiveTask(gitInfo, matchers) {
|
|
173
|
+
if (!gitInfo || !Array.isArray(matchers)) return null;
|
|
174
|
+
const remotes = gitInfo.remotes || [];
|
|
175
|
+
for (const m of matchers) {
|
|
176
|
+
// 1. Exact branch match (covers PR continue-mode and any known branch).
|
|
177
|
+
if (m.branch && gitInfo.branch && gitInfo.branch === m.branch) {
|
|
178
|
+
return m;
|
|
179
|
+
}
|
|
180
|
+
// 2. issue-{number}-{hex} prefix match scoped to the same repository.
|
|
181
|
+
if (m.issueNumber != null && gitInfo.branch && isValidIssueBranchName(gitInfo.branch, m.issueNumber)) {
|
|
182
|
+
const repoMatches = remotes.length === 0 || remotes.some(r => sameRepo(r, m));
|
|
183
|
+
if (repoMatches) return m;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function matchesAny(name, patterns) {
|
|
190
|
+
return patterns.some(p => (p.regex || p).test(name));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Identify the hive-mind temp pattern a name matches, if any.
|
|
195
|
+
*
|
|
196
|
+
* @param {string} name
|
|
197
|
+
* @returns {{name: string}|null}
|
|
198
|
+
*/
|
|
199
|
+
export function matchHiveMindPattern(name) {
|
|
200
|
+
return HIVE_MIND_TEMP_PATTERNS.find(p => p.regex.test(name)) || null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Pure classification of a single temp entry into keep/remove with a reason.
|
|
205
|
+
*
|
|
206
|
+
* Reason precedence (highest first): protected > self > active-process >
|
|
207
|
+
* active-task > dirty-worktree > hive-mind-temp (remove) > all-mode (remove) >
|
|
208
|
+
* unrecognized (keep).
|
|
209
|
+
*
|
|
210
|
+
* @param {{name: string, path: string, isDirectory: boolean}} entry
|
|
211
|
+
* @param {Object} ctx
|
|
212
|
+
* @param {string[]} ctx.protectedNames
|
|
213
|
+
* @param {boolean} ctx.forceStartCommand
|
|
214
|
+
* @param {boolean} ctx.includeSystem - allow classifying system entries in --all
|
|
215
|
+
* @param {boolean} ctx.includeAll - consider non-hive-mind entries for removal
|
|
216
|
+
* @param {boolean} ctx.keepDirty
|
|
217
|
+
* @param {Set<string>} ctx.selfPaths - absolute paths the cleanup process itself uses
|
|
218
|
+
* @param {Set<string>} ctx.heldPaths - absolute paths held by running processes
|
|
219
|
+
* @param {Array} ctx.matchers - active task matchers
|
|
220
|
+
* @param {Map<string,{branch, remotes, dirty}>} ctx.gitInfoByPath
|
|
221
|
+
* @returns {{action: 'keep'|'remove', reason: string}}
|
|
222
|
+
*/
|
|
223
|
+
export function classifyEntry(entry, ctx) {
|
|
224
|
+
const { protectedNames = DEFAULT_PROTECTED_NAMES, forceStartCommand = false, includeSystem = false, includeAll = false, keepDirty = true, selfPaths = new Set(), heldPaths = new Set(), matchers = [], gitInfoByPath = new Map() } = ctx || {};
|
|
225
|
+
|
|
226
|
+
const name = entry.name;
|
|
227
|
+
|
|
228
|
+
// 1. Protected names (start-command can be forced).
|
|
229
|
+
const isStartCommand = name === 'start-command';
|
|
230
|
+
if (protectedNames.includes(name)) {
|
|
231
|
+
if (isStartCommand && forceStartCommand) {
|
|
232
|
+
// fall through to deletion logic below
|
|
233
|
+
} else {
|
|
234
|
+
return { action: 'keep', reason: 'protected' };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 1b. System-owned entries are protected unless explicitly included.
|
|
239
|
+
if (!includeSystem && matchesAny(name, SYSTEM_PROTECTED_PATTERNS)) {
|
|
240
|
+
return { action: 'keep', reason: 'system-protected' };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 2. Paths the cleanup process itself depends on (its own clone / cwd).
|
|
244
|
+
if (selfPaths.has(entry.path)) {
|
|
245
|
+
return { action: 'keep', reason: 'self' };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 3. Held open / used as cwd by a running process.
|
|
249
|
+
if (heldPaths.has(entry.path)) {
|
|
250
|
+
return { action: 'keep', reason: 'active-process' };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// 4. Matches an active solve task by branch / repo.
|
|
254
|
+
const gitInfo = gitInfoByPath.get(entry.path);
|
|
255
|
+
const matched = folderMatchesActiveTask(gitInfo, matchers);
|
|
256
|
+
if (matched) {
|
|
257
|
+
return { action: 'keep', reason: 'active-task' };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 5. Dirty / unpushed worktree: keep by default to avoid losing work.
|
|
261
|
+
if (keepDirty && gitInfo && gitInfo.dirty) {
|
|
262
|
+
return { action: 'keep', reason: 'dirty-worktree' };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// 6. Recognised hive-mind temp artifact -> safe to remove.
|
|
266
|
+
if (matchHiveMindPattern(name)) {
|
|
267
|
+
return { action: 'remove', reason: isStartCommand ? 'forced-start-command' : 'hive-mind-temp' };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// start-command forced but not a hive-mind pattern: still remove when forced.
|
|
271
|
+
if (isStartCommand && forceStartCommand) {
|
|
272
|
+
return { action: 'remove', reason: 'forced-start-command' };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 7. --all mode removes anything not otherwise kept.
|
|
276
|
+
if (includeAll) {
|
|
277
|
+
return { action: 'remove', reason: 'all-mode' };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// 8. Default: leave unrecognised entries alone.
|
|
281
|
+
return { action: 'keep', reason: 'unrecognized' };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Classify a list of temp entries.
|
|
286
|
+
*
|
|
287
|
+
* @param {Array<{name, path, isDirectory, size?: number}>} entries
|
|
288
|
+
* @param {Object} ctx - see classifyEntry
|
|
289
|
+
* @returns {{keep: Array, remove: Array}} each item: {name, path, size, reason}
|
|
290
|
+
*/
|
|
291
|
+
export function classifyEntries(entries, ctx) {
|
|
292
|
+
const keep = [];
|
|
293
|
+
const remove = [];
|
|
294
|
+
for (const entry of entries || []) {
|
|
295
|
+
const { action, reason } = classifyEntry(entry, ctx);
|
|
296
|
+
const record = { name: entry.name, path: entry.path, size: entry.size ?? null, reason };
|
|
297
|
+
if (action === 'remove') remove.push(record);
|
|
298
|
+
else keep.push(record);
|
|
299
|
+
}
|
|
300
|
+
return { keep, remove };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Human-readable, base-1024 byte formatting (matches `du -h` style closely
|
|
305
|
+
* enough for reporting).
|
|
306
|
+
*
|
|
307
|
+
* @param {number|null|undefined} bytes
|
|
308
|
+
* @returns {string}
|
|
309
|
+
*/
|
|
310
|
+
export function formatBytes(bytes) {
|
|
311
|
+
if (bytes == null || Number.isNaN(bytes)) return '?';
|
|
312
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
313
|
+
const units = ['K', 'M', 'G', 'T', 'P'];
|
|
314
|
+
let value = bytes / 1024;
|
|
315
|
+
let unit = 0;
|
|
316
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
317
|
+
value /= 1024;
|
|
318
|
+
unit++;
|
|
319
|
+
}
|
|
320
|
+
const rounded = value >= 10 || Number.isInteger(value) ? Math.round(value) : Math.round(value * 10) / 10;
|
|
321
|
+
return `${rounded}${units[unit]}`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Aggregate totals for a classification result.
|
|
326
|
+
*
|
|
327
|
+
* @param {{keep: Array, remove: Array}} classified
|
|
328
|
+
* @returns {{keepCount, removeCount, keepBytes, removeBytes}}
|
|
329
|
+
*/
|
|
330
|
+
export function summarize(classified) {
|
|
331
|
+
const sum = list => list.reduce((acc, item) => acc + (item.size || 0), 0);
|
|
332
|
+
return {
|
|
333
|
+
keepCount: classified.keep.length,
|
|
334
|
+
removeCount: classified.remove.length,
|
|
335
|
+
keepBytes: sum(classified.keep),
|
|
336
|
+
removeBytes: sum(classified.remove),
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Human-readable label for a keep/remove reason code.
|
|
342
|
+
* @param {string} reason
|
|
343
|
+
* @returns {string}
|
|
344
|
+
*/
|
|
345
|
+
export function describeReason(reason) {
|
|
346
|
+
const map = {
|
|
347
|
+
protected: 'protected path',
|
|
348
|
+
'system-protected': 'system-owned temp',
|
|
349
|
+
self: 'used by this cleanup process',
|
|
350
|
+
'active-process': 'in use by a running process',
|
|
351
|
+
'active-task': 'belongs to an active task',
|
|
352
|
+
'dirty-worktree': 'has uncommitted/unpushed changes',
|
|
353
|
+
'hive-mind-temp': 'hive-mind temporary artifact',
|
|
354
|
+
'forced-start-command': 'start-command (forced)',
|
|
355
|
+
'all-mode': 'removed by --all',
|
|
356
|
+
unrecognized: 'not a recognised hive-mind artifact',
|
|
357
|
+
};
|
|
358
|
+
return map[reason] || reason;
|
|
359
|
+
}
|
package/src/cleanup.mjs
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `cleanup` — free disk space by removing stale hive-mind temporary
|
|
4
|
+
* directories/files while preserving folders that belong to currently-running
|
|
5
|
+
* (active) tasks, protected system paths and any work that is not yet pushed.
|
|
6
|
+
*
|
|
7
|
+
* This is the standalone command requested in issue #1848. It reproduces, in a
|
|
8
|
+
* safe and automated way, the manual workflow the maintainer used to reclaim
|
|
9
|
+
* space without restarting the server:
|
|
10
|
+
* - list temp entries (like `du -sh /tmp/*`),
|
|
11
|
+
* - figure out which clones belong to active solve tasks (by branch name, the
|
|
12
|
+
* same way solve.mjs derives branches), keeping those,
|
|
13
|
+
* - keep protected paths such as `/tmp/start-command/`,
|
|
14
|
+
* - delete the rest.
|
|
15
|
+
*
|
|
16
|
+
* Modes:
|
|
17
|
+
* --dry-run show kept + deleted lists, delete nothing
|
|
18
|
+
* --keep-active-tasks-folders keep folders of running tasks (default: on)
|
|
19
|
+
* --force / -f skip the confirmation prompt
|
|
20
|
+
* --all also consider non-hive-mind temp entries
|
|
21
|
+
* --force-start-command allow deleting /tmp/start-command
|
|
22
|
+
* --include-system also consider system-owned temp entries
|
|
23
|
+
* --no-keep-dirty allow deleting clones with unpushed changes
|
|
24
|
+
* --apt --journal --docker --npm Ubuntu/system cleanup (opt-in)
|
|
25
|
+
* --system shorthand for --apt --journal --npm
|
|
26
|
+
* --sudo prefix package-manager commands with sudo
|
|
27
|
+
* --verbose / -v
|
|
28
|
+
*
|
|
29
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1848
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
import { promises as fsp } from 'node:fs';
|
|
34
|
+
import { execSync } from 'node:child_process';
|
|
35
|
+
|
|
36
|
+
import { classifyEntries, summarize, formatBytes, describeReason, buildActiveMatchers, DEFAULT_PROTECTED_NAMES } from './cleanup.lib.mjs';
|
|
37
|
+
import { getTempRoot, listTempEntries, getPathSize, readFolderGitInfo, listProcessHeldPaths, getActiveTasks, removePath, runSystemCleanup } from './cleanup.os.lib.mjs';
|
|
38
|
+
|
|
39
|
+
const args = process.argv.slice(2);
|
|
40
|
+
|
|
41
|
+
function hasFlag(...names) {
|
|
42
|
+
return names.some(n => args.includes(n));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Early --version / --help handling (no heavy imports).
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
if (hasFlag('--version')) {
|
|
49
|
+
const { getVersion } = await import('./version.lib.mjs');
|
|
50
|
+
try {
|
|
51
|
+
console.log(await getVersion());
|
|
52
|
+
} catch {
|
|
53
|
+
console.error('Error: Unable to determine version');
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
process.exit(0);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (hasFlag('--help', '-h')) {
|
|
60
|
+
console.log(`Usage: cleanup [options]
|
|
61
|
+
|
|
62
|
+
Free disk space by removing stale hive-mind temporary directories/files while
|
|
63
|
+
keeping folders that belong to active tasks and protected system paths.
|
|
64
|
+
|
|
65
|
+
Options:
|
|
66
|
+
--dry-run, -n Show what would be kept and deleted, delete nothing
|
|
67
|
+
--keep-active-tasks-folders Keep folders of currently-running tasks [default: on]
|
|
68
|
+
--no-keep-active-tasks-folders
|
|
69
|
+
Disable active-task detection (only protected paths kept)
|
|
70
|
+
--force, -f Delete without the interactive confirmation prompt
|
|
71
|
+
--all Also consider non-hive-mind temp entries for deletion
|
|
72
|
+
--include-system Also consider system-owned temp entries (.X11-unix, …)
|
|
73
|
+
--force-start-command Allow deleting /tmp/start-command (kept by default)
|
|
74
|
+
--no-keep-dirty Allow deleting clones with uncommitted/unpushed changes
|
|
75
|
+
--no-sessions Do not query '$ --status' for active sessions
|
|
76
|
+
--no-resolve-branches Do not resolve PR head branches via gh
|
|
77
|
+
|
|
78
|
+
System / Ubuntu cleanup (opt-in):
|
|
79
|
+
--apt apt-get clean / autoclean / autoremove
|
|
80
|
+
--journal journalctl --vacuum-time=2weeks
|
|
81
|
+
--docker docker system prune -f
|
|
82
|
+
--npm npm cache clean --force
|
|
83
|
+
--system Shorthand for --apt --journal --npm
|
|
84
|
+
--sudo Prefix package-manager commands with sudo
|
|
85
|
+
|
|
86
|
+
--verbose, -v Verbose logging
|
|
87
|
+
--version Show version number
|
|
88
|
+
--help, -h Show this help
|
|
89
|
+
`);
|
|
90
|
+
process.exit(0);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Options.
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
const options = {
|
|
97
|
+
dryRun: hasFlag('--dry-run', '-n'),
|
|
98
|
+
keepActiveTasks: !hasFlag('--no-keep-active-tasks-folders'),
|
|
99
|
+
force: hasFlag('--force', '-f'),
|
|
100
|
+
includeAll: hasFlag('--all'),
|
|
101
|
+
includeSystem: hasFlag('--include-system'),
|
|
102
|
+
forceStartCommand: hasFlag('--force-start-command'),
|
|
103
|
+
keepDirty: !hasFlag('--no-keep-dirty'),
|
|
104
|
+
useSessions: !hasFlag('--no-sessions'),
|
|
105
|
+
resolveBranches: !hasFlag('--no-resolve-branches'),
|
|
106
|
+
verbose: hasFlag('--verbose', '-v'),
|
|
107
|
+
apt: hasFlag('--apt', '--system'),
|
|
108
|
+
journal: hasFlag('--journal', '--system'),
|
|
109
|
+
docker: hasFlag('--docker'),
|
|
110
|
+
npm: hasFlag('--npm', '--system'),
|
|
111
|
+
sudo: hasFlag('--sudo'),
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
115
|
+
const scriptDir = path.dirname(process.argv[1]);
|
|
116
|
+
const logFile = path.join(scriptDir, `cleanup-${timestamp}.log`);
|
|
117
|
+
|
|
118
|
+
async function log(message, { level = 'info' } = {}) {
|
|
119
|
+
await fsp.appendFile(logFile, `[${new Date().toISOString()}] [${level.toUpperCase()}] ${message}\n`).catch(() => {});
|
|
120
|
+
if (level === 'error') console.error(message);
|
|
121
|
+
else if (level === 'warn' || level === 'warning') console.warn(message);
|
|
122
|
+
else console.log(message);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function vlog(message) {
|
|
126
|
+
if (options.verbose) return log(message);
|
|
127
|
+
return fsp.appendFile(logFile, `[${new Date().toISOString()}] [DEBUG] ${message}\n`).catch(() => {});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Compute the set of absolute top-level tmp entries that the cleanup process
|
|
132
|
+
* itself depends on, so we never delete our own running clone.
|
|
133
|
+
*/
|
|
134
|
+
function computeSelfPaths(tempRoot) {
|
|
135
|
+
const selfPaths = new Set();
|
|
136
|
+
const normalizedRoot = tempRoot.endsWith(path.sep) ? tempRoot : tempRoot + path.sep;
|
|
137
|
+
const add = candidate => {
|
|
138
|
+
if (candidate && (candidate === tempRoot || candidate.startsWith(normalizedRoot))) {
|
|
139
|
+
const first = candidate.slice(normalizedRoot.length).split(path.sep)[0];
|
|
140
|
+
if (first) selfPaths.add(path.join(tempRoot, first));
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
add(process.cwd());
|
|
144
|
+
add(path.resolve(scriptDir));
|
|
145
|
+
add(path.resolve(process.argv[1] || ''));
|
|
146
|
+
return selfPaths;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function main() {
|
|
150
|
+
await fsp.writeFile(logFile, `# Cleanup Log - ${new Date().toISOString()}\n\n`).catch(() => {});
|
|
151
|
+
|
|
152
|
+
const tempRoot = getTempRoot();
|
|
153
|
+
await log('🧹 hive-mind cleanup');
|
|
154
|
+
await log('====================\n');
|
|
155
|
+
await log(`📂 Temp root: ${tempRoot}`);
|
|
156
|
+
if (options.dryRun) await log('📝 DRY RUN — nothing will be deleted\n');
|
|
157
|
+
else if (options.force) await log('⚠️ FORCE — deleting without confirmation\n');
|
|
158
|
+
|
|
159
|
+
// 1. Enumerate candidate entries.
|
|
160
|
+
const entries = listTempEntries(tempRoot);
|
|
161
|
+
await log(`🔍 Found ${entries.length} entries under ${tempRoot}`);
|
|
162
|
+
|
|
163
|
+
// 2. Gather signals for active-task detection.
|
|
164
|
+
const heldPaths = listProcessHeldPaths(tempRoot);
|
|
165
|
+
await vlog(`Process-held paths: ${[...heldPaths].join(', ') || '(none)'}`);
|
|
166
|
+
|
|
167
|
+
let matchers = [];
|
|
168
|
+
if (options.keepActiveTasks) {
|
|
169
|
+
const activeTasks = await getActiveTasks({ useSessions: options.useSessions, resolveBranches: options.resolveBranches });
|
|
170
|
+
matchers = buildActiveMatchers(activeTasks);
|
|
171
|
+
if (activeTasks.length > 0) {
|
|
172
|
+
await log(`🏃 Active tasks detected: ${activeTasks.length}`);
|
|
173
|
+
for (const t of activeTasks) {
|
|
174
|
+
await log(` • ${t.owner}/${t.repo} ${t.type} #${t.number}${t.branch ? ` (branch ${t.branch})` : ''}`);
|
|
175
|
+
}
|
|
176
|
+
} else {
|
|
177
|
+
await log('🏃 No active tasks detected from running processes/sessions');
|
|
178
|
+
}
|
|
179
|
+
} else {
|
|
180
|
+
await log('⚠️ Active-task detection disabled (--no-keep-active-tasks-folders)');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 3. Read git info for directory entries (used by branch / dirty matching).
|
|
184
|
+
const gitInfoByPath = new Map();
|
|
185
|
+
for (const entry of entries) {
|
|
186
|
+
if (!entry.isDirectory) continue;
|
|
187
|
+
const info = readFolderGitInfo(entry.path);
|
|
188
|
+
if (info) gitInfoByPath.set(entry.path, info);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const selfPaths = computeSelfPaths(tempRoot);
|
|
192
|
+
await vlog(`Self paths: ${[...selfPaths].join(', ') || '(none)'}`);
|
|
193
|
+
|
|
194
|
+
// 4. Classify.
|
|
195
|
+
const ctx = {
|
|
196
|
+
protectedNames: DEFAULT_PROTECTED_NAMES,
|
|
197
|
+
forceStartCommand: options.forceStartCommand,
|
|
198
|
+
includeSystem: options.includeSystem,
|
|
199
|
+
includeAll: options.includeAll,
|
|
200
|
+
keepDirty: options.keepDirty,
|
|
201
|
+
selfPaths,
|
|
202
|
+
heldPaths,
|
|
203
|
+
matchers,
|
|
204
|
+
gitInfoByPath,
|
|
205
|
+
};
|
|
206
|
+
const classified = classifyEntries(entries, ctx);
|
|
207
|
+
|
|
208
|
+
// 5. Compute sizes (only for what we report, to keep it reasonably fast).
|
|
209
|
+
for (const item of [...classified.keep, ...classified.remove]) {
|
|
210
|
+
item.size = getPathSize(item.path);
|
|
211
|
+
}
|
|
212
|
+
const totals = summarize(classified);
|
|
213
|
+
|
|
214
|
+
// 6. Report.
|
|
215
|
+
await log('\n🟢 KEPT folders/files:');
|
|
216
|
+
if (classified.keep.length === 0) await log(' (none)');
|
|
217
|
+
for (const item of classified.keep.sort((a, b) => (b.size || 0) - (a.size || 0))) {
|
|
218
|
+
await log(` ${formatBytes(item.size).padStart(7)} ${item.path} — ${describeReason(item.reason)}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
await log(`\n🗑️ ${options.dryRun ? 'WOULD DELETE' : 'TO DELETE'} folders/files:`);
|
|
222
|
+
if (classified.remove.length === 0) await log(' (none)');
|
|
223
|
+
for (const item of classified.remove.sort((a, b) => (b.size || 0) - (a.size || 0))) {
|
|
224
|
+
await log(` ${formatBytes(item.size).padStart(7)} ${item.path} — ${describeReason(item.reason)}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
await log(`\n📊 Summary: keep ${totals.keepCount} (${formatBytes(totals.keepBytes)}), remove ${totals.removeCount} (${formatBytes(totals.removeBytes)})`);
|
|
228
|
+
|
|
229
|
+
// 7. Execute deletion (unless dry-run).
|
|
230
|
+
if (options.dryRun) {
|
|
231
|
+
await log('\n✅ Dry run complete. Re-run without --dry-run to delete.');
|
|
232
|
+
} else if (classified.remove.length === 0) {
|
|
233
|
+
await log('\n✅ Nothing to delete.');
|
|
234
|
+
} else {
|
|
235
|
+
if (!options.force) {
|
|
236
|
+
console.log(`\n⚠️ This will permanently delete ${classified.remove.length} entries (${formatBytes(totals.removeBytes)}).`);
|
|
237
|
+
console.log('Type "yes" to confirm, or Ctrl+C to cancel:');
|
|
238
|
+
process.stdout.write('> ');
|
|
239
|
+
let answer = '';
|
|
240
|
+
try {
|
|
241
|
+
answer = execSync('read answer && echo $answer', { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'], shell: '/bin/bash' }).trim();
|
|
242
|
+
} catch {
|
|
243
|
+
await log('\n❌ Cancelled');
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (answer.toLowerCase() !== 'yes') {
|
|
247
|
+
await log('\n❌ Cancelled');
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
await log('\n🗑️ Deleting...');
|
|
253
|
+
let deleted = 0;
|
|
254
|
+
let failed = 0;
|
|
255
|
+
for (const item of classified.remove) {
|
|
256
|
+
const ok = removePath(item.path);
|
|
257
|
+
if (ok) {
|
|
258
|
+
deleted++;
|
|
259
|
+
await vlog(` removed ${item.path}`);
|
|
260
|
+
} else {
|
|
261
|
+
failed++;
|
|
262
|
+
await log(` ⚠️ failed to remove ${item.path}`, { level: 'warn' });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
await log(`\n✅ Deleted ${deleted} entries${failed ? `, ${failed} failed` : ''}.`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// 8. System / Ubuntu cleanup (opt-in).
|
|
269
|
+
if (options.apt || options.journal || options.docker || options.npm) {
|
|
270
|
+
await log('\n🧴 System cleanup:');
|
|
271
|
+
runSystemCleanup({
|
|
272
|
+
apt: options.apt,
|
|
273
|
+
journal: options.journal,
|
|
274
|
+
docker: options.docker,
|
|
275
|
+
npm: options.npm,
|
|
276
|
+
dryRun: options.dryRun,
|
|
277
|
+
useSudo: options.sudo,
|
|
278
|
+
logFn: msg => log(msg),
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
await log(`\n📁 Log file: ${logFile}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
main().catch(async error => {
|
|
286
|
+
await log(`❌ Error: ${error.message}`, { level: 'error' });
|
|
287
|
+
process.exit(1);
|
|
288
|
+
});
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OS-interaction layer for the `cleanup` command (issue #1848).
|
|
3
|
+
*
|
|
4
|
+
* Everything that touches the real filesystem, the process table (/proc),
|
|
5
|
+
* isolation session state (`$ --status` from start-command), git metadata of a
|
|
6
|
+
* clone, GitHub (`gh`) and system package caches lives here. The pure
|
|
7
|
+
* classification logic lives in `cleanup.lib.mjs` and is unit-tested without any
|
|
8
|
+
* of this.
|
|
9
|
+
*
|
|
10
|
+
* Implemented with `node:` built-ins + `node:child_process` so it does not
|
|
11
|
+
* depend on `use-m` / `command-stream` being reachable, except for the optional
|
|
12
|
+
* `$ --status` session query which reuses isolation-runner.lib.mjs.
|
|
13
|
+
*
|
|
14
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1848
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import { execFileSync } from 'node:child_process';
|
|
21
|
+
|
|
22
|
+
import { extractTaskRefsFromCommand, parseRemoteUrl } from './cleanup.lib.mjs';
|
|
23
|
+
|
|
24
|
+
/** Run a command, returning trimmed stdout or null on any failure. */
|
|
25
|
+
function tryExec(cmd, args, options = {}) {
|
|
26
|
+
try {
|
|
27
|
+
return execFileSync(cmd, args, {
|
|
28
|
+
encoding: 'utf8',
|
|
29
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
30
|
+
timeout: options.timeout ?? 20000,
|
|
31
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
32
|
+
...options,
|
|
33
|
+
}).trim();
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The tmp root cleanup operates on (honours TMPDIR via os.tmpdir()). */
|
|
40
|
+
export function getTempRoot() {
|
|
41
|
+
return os.tmpdir();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* List immediate children of the tmp root as candidate entries.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} tempRoot
|
|
48
|
+
* @returns {Array<{name: string, path: string, isDirectory: boolean}>}
|
|
49
|
+
*/
|
|
50
|
+
export function listTempEntries(tempRoot) {
|
|
51
|
+
let dirents;
|
|
52
|
+
try {
|
|
53
|
+
dirents = fs.readdirSync(tempRoot, { withFileTypes: true });
|
|
54
|
+
} catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
return dirents.map(d => {
|
|
58
|
+
const full = path.join(tempRoot, d.name);
|
|
59
|
+
let isDirectory = d.isDirectory();
|
|
60
|
+
// Resolve symlinks defensively (don't follow into them for deletion though).
|
|
61
|
+
if (d.isSymbolicLink()) {
|
|
62
|
+
try {
|
|
63
|
+
isDirectory = fs.statSync(full).isDirectory();
|
|
64
|
+
} catch {
|
|
65
|
+
isDirectory = false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { name: d.name, path: full, isDirectory };
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Size of a path in bytes. Uses `du -sk` (fast, handles dirs) with a small
|
|
74
|
+
* fs.statSync fallback for plain files.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} targetPath
|
|
77
|
+
* @returns {number|null}
|
|
78
|
+
*/
|
|
79
|
+
export function getPathSize(targetPath) {
|
|
80
|
+
const out = tryExec('du', ['-sk', targetPath]);
|
|
81
|
+
if (out) {
|
|
82
|
+
const kb = parseInt(out.split(/\s+/)[0], 10);
|
|
83
|
+
if (!Number.isNaN(kb)) return kb * 1024;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
return fs.statSync(targetPath).size;
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Read the git branch, remotes and dirty state of a clone directory.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} dir
|
|
96
|
+
* @returns {{branch: string|null, remotes: Array<{owner, repo, url}>, dirty: boolean}|null}
|
|
97
|
+
*/
|
|
98
|
+
export function readFolderGitInfo(dir) {
|
|
99
|
+
// Cheap check: is it a git work tree?
|
|
100
|
+
const isRepo = tryExec('git', ['-C', dir, 'rev-parse', '--is-inside-work-tree']);
|
|
101
|
+
if (isRepo !== 'true') return null;
|
|
102
|
+
|
|
103
|
+
const branch = tryExec('git', ['-C', dir, 'branch', '--show-current']) || null;
|
|
104
|
+
|
|
105
|
+
const remotesRaw = tryExec('git', ['-C', dir, 'remote', '-v']) || '';
|
|
106
|
+
const remotes = [];
|
|
107
|
+
const seen = new Set();
|
|
108
|
+
for (const line of remotesRaw.split('\n')) {
|
|
109
|
+
const m = line.match(/^\S+\s+(\S+)\s+\((?:fetch|push)\)/);
|
|
110
|
+
if (!m) continue;
|
|
111
|
+
const parsed = parseRemoteUrl(m[1]);
|
|
112
|
+
if (parsed) {
|
|
113
|
+
const key = `${parsed.owner}/${parsed.repo}`.toLowerCase();
|
|
114
|
+
if (!seen.has(key)) {
|
|
115
|
+
seen.add(key);
|
|
116
|
+
remotes.push({ ...parsed, url: m[1] });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Dirty if there are uncommitted changes OR commits not present on any remote.
|
|
122
|
+
const status = tryExec('git', ['-C', dir, 'status', '--porcelain']);
|
|
123
|
+
let dirty = Boolean(status && status.length > 0);
|
|
124
|
+
if (!dirty && branch) {
|
|
125
|
+
// Unpushed local commits: branch exists but has no upstream, or is ahead.
|
|
126
|
+
const upstream = tryExec('git', ['-C', dir, 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}']);
|
|
127
|
+
if (!upstream) {
|
|
128
|
+
// No upstream tracking: check whether the branch commit exists on a remote.
|
|
129
|
+
const head = tryExec('git', ['-C', dir, 'rev-parse', 'HEAD']);
|
|
130
|
+
const onRemote = head ? tryExec('git', ['-C', dir, 'branch', '-r', '--contains', head]) : null;
|
|
131
|
+
dirty = !onRemote;
|
|
132
|
+
} else {
|
|
133
|
+
const counts = tryExec('git', ['-C', dir, 'rev-list', '--left-right', '--count', `${upstream}...HEAD`]);
|
|
134
|
+
if (counts) {
|
|
135
|
+
const ahead = parseInt(counts.split(/\s+/)[1] || '0', 10);
|
|
136
|
+
dirty = ahead > 0;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { branch, remotes, dirty };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Scan /proc to find paths under tempRoot that are the cwd of, or an open fd /
|
|
146
|
+
* mapped file of, a running process. Linux-only; returns an empty set elsewhere.
|
|
147
|
+
*
|
|
148
|
+
* @param {string} tempRoot
|
|
149
|
+
* @returns {Set<string>} absolute top-level entry paths under tempRoot
|
|
150
|
+
*/
|
|
151
|
+
export function listProcessHeldPaths(tempRoot) {
|
|
152
|
+
const held = new Set();
|
|
153
|
+
let pids;
|
|
154
|
+
try {
|
|
155
|
+
pids = fs.readdirSync('/proc').filter(name => /^\d+$/.test(name));
|
|
156
|
+
} catch {
|
|
157
|
+
return held; // Not Linux / no procfs.
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const normalizedRoot = tempRoot.endsWith(path.sep) ? tempRoot : tempRoot + path.sep;
|
|
161
|
+
const recordIfUnderRoot = target => {
|
|
162
|
+
if (!target) return;
|
|
163
|
+
if (target === tempRoot || target.startsWith(normalizedRoot)) {
|
|
164
|
+
// Reduce to the top-level entry directly under tempRoot.
|
|
165
|
+
const rest = target.slice(normalizedRoot.length);
|
|
166
|
+
const first = rest.split(path.sep)[0];
|
|
167
|
+
if (first) held.add(path.join(tempRoot, first));
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
for (const pid of pids) {
|
|
172
|
+
// cwd of the process (covers git/claude children that chdir into the clone).
|
|
173
|
+
try {
|
|
174
|
+
recordIfUnderRoot(fs.readlinkSync(`/proc/${pid}/cwd`));
|
|
175
|
+
} catch {
|
|
176
|
+
/* process gone or permission denied */
|
|
177
|
+
}
|
|
178
|
+
// open file descriptors.
|
|
179
|
+
try {
|
|
180
|
+
for (const fd of fs.readdirSync(`/proc/${pid}/fd`)) {
|
|
181
|
+
try {
|
|
182
|
+
recordIfUnderRoot(fs.readlinkSync(`/proc/${pid}/fd/${fd}`));
|
|
183
|
+
} catch {
|
|
184
|
+
/* fd vanished */
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
} catch {
|
|
188
|
+
/* no fd dir / permission */
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return held;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Collect task references (owner/repo/number/type) from running solve/hive
|
|
196
|
+
* processes by scanning /proc/<pid>/cmdline.
|
|
197
|
+
*
|
|
198
|
+
* @returns {Array<{owner, repo, type, number}>}
|
|
199
|
+
*/
|
|
200
|
+
export function listActiveTaskRefsFromProc() {
|
|
201
|
+
const refs = [];
|
|
202
|
+
const seen = new Set();
|
|
203
|
+
let pids;
|
|
204
|
+
try {
|
|
205
|
+
pids = fs.readdirSync('/proc').filter(name => /^\d+$/.test(name));
|
|
206
|
+
} catch {
|
|
207
|
+
return refs;
|
|
208
|
+
}
|
|
209
|
+
for (const pid of pids) {
|
|
210
|
+
let cmdline;
|
|
211
|
+
try {
|
|
212
|
+
cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ').trim();
|
|
213
|
+
} catch {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (!cmdline || !/github\.com/.test(cmdline)) continue;
|
|
217
|
+
for (const ref of extractTaskRefsFromCommand(cmdline)) {
|
|
218
|
+
const key = `${ref.owner}/${ref.repo}#${ref.number}:${ref.type}`;
|
|
219
|
+
if (!seen.has(key)) {
|
|
220
|
+
seen.add(key);
|
|
221
|
+
refs.push(ref);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return refs;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Discover currently-running isolation session UUIDs from start-command's live
|
|
230
|
+
* session managers (screen / tmux). These names are the session UUIDs.
|
|
231
|
+
*
|
|
232
|
+
* @returns {string[]}
|
|
233
|
+
*/
|
|
234
|
+
export function listLiveSessionIds() {
|
|
235
|
+
const ids = new Set();
|
|
236
|
+
|
|
237
|
+
const screenOut = tryExec('screen', ['-ls']);
|
|
238
|
+
if (screenOut) {
|
|
239
|
+
for (const m of screenOut.matchAll(/^\s*\d+\.([0-9a-f-]{8,})\s/gim)) {
|
|
240
|
+
ids.add(m[1]);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const tmuxOut = tryExec('tmux', ['ls', '-F', '#{session_name}']);
|
|
245
|
+
if (tmuxOut) {
|
|
246
|
+
for (const line of tmuxOut.split('\n')) {
|
|
247
|
+
const name = line.trim();
|
|
248
|
+
if (/^[0-9a-f-]{8,}$/i.test(name)) ids.add(name);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return [...ids];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Query `$ --status <uuid>` for each live session and extract task references
|
|
257
|
+
* from executing sessions' command lines. Optional; reuses isolation-runner.
|
|
258
|
+
*
|
|
259
|
+
* @param {string[]} sessionIds
|
|
260
|
+
* @returns {Promise<Array<{owner, repo, type, number}>>}
|
|
261
|
+
*/
|
|
262
|
+
export async function listActiveTaskRefsFromSessions(sessionIds) {
|
|
263
|
+
if (!sessionIds || sessionIds.length === 0) return [];
|
|
264
|
+
let querySessionStatus;
|
|
265
|
+
let isTerminalSessionStatus;
|
|
266
|
+
try {
|
|
267
|
+
({ querySessionStatus, isTerminalSessionStatus } = await import('./isolation-runner.lib.mjs'));
|
|
268
|
+
} catch {
|
|
269
|
+
return [];
|
|
270
|
+
}
|
|
271
|
+
const refs = [];
|
|
272
|
+
const seen = new Set();
|
|
273
|
+
for (const id of sessionIds) {
|
|
274
|
+
let status;
|
|
275
|
+
try {
|
|
276
|
+
status = await querySessionStatus(id);
|
|
277
|
+
} catch {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (!status || !status.exists) continue;
|
|
281
|
+
if (status.status && isTerminalSessionStatus(status.status)) continue;
|
|
282
|
+
if (!status.command) continue;
|
|
283
|
+
for (const ref of extractTaskRefsFromCommand(status.command)) {
|
|
284
|
+
const key = `${ref.owner}/${ref.repo}#${ref.number}:${ref.type}`;
|
|
285
|
+
if (!seen.has(key)) {
|
|
286
|
+
seen.add(key);
|
|
287
|
+
refs.push(ref);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return refs;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Resolve the head branch of a PR via `gh pr view`. Returns null on failure
|
|
296
|
+
* (offline, no gh, not found) — callers fall back to issue-prefix matching.
|
|
297
|
+
*
|
|
298
|
+
* @param {{owner, repo, number}} ref
|
|
299
|
+
* @returns {string|null}
|
|
300
|
+
*/
|
|
301
|
+
export function resolvePrHeadBranch(ref) {
|
|
302
|
+
const out = tryExec('gh', ['pr', 'view', String(ref.number), '--repo', `${ref.owner}/${ref.repo}`, '--json', 'headRefName', '--jq', '.headRefName']);
|
|
303
|
+
return out || null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Build the full active-task list, resolving PR head branches where possible.
|
|
308
|
+
*
|
|
309
|
+
* @param {Object} [options]
|
|
310
|
+
* @param {boolean} [options.useSessions=true] - also query `$ --status`
|
|
311
|
+
* @param {boolean} [options.resolveBranches=true] - resolve PR head branches via gh
|
|
312
|
+
* @returns {Promise<Array<{owner, repo, type, number, branch: string|null}>>}
|
|
313
|
+
*/
|
|
314
|
+
export async function getActiveTasks(options = {}) {
|
|
315
|
+
const { useSessions = true, resolveBranches = true } = options;
|
|
316
|
+
const refs = [...listActiveTaskRefsFromProc()];
|
|
317
|
+
const seen = new Set(refs.map(r => `${r.owner}/${r.repo}#${r.number}:${r.type}`));
|
|
318
|
+
|
|
319
|
+
if (useSessions) {
|
|
320
|
+
const sessionRefs = await listActiveTaskRefsFromSessions(listLiveSessionIds());
|
|
321
|
+
for (const ref of sessionRefs) {
|
|
322
|
+
const key = `${ref.owner}/${ref.repo}#${ref.number}:${ref.type}`;
|
|
323
|
+
if (!seen.has(key)) {
|
|
324
|
+
seen.add(key);
|
|
325
|
+
refs.push(ref);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return refs.map(ref => {
|
|
331
|
+
let branch = null;
|
|
332
|
+
if (ref.type === 'pull' && resolveBranches) {
|
|
333
|
+
branch = resolvePrHeadBranch(ref);
|
|
334
|
+
}
|
|
335
|
+
return { ...ref, branch };
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Permanently remove a path (recursive, force). Returns true on success.
|
|
341
|
+
*
|
|
342
|
+
* @param {string} targetPath
|
|
343
|
+
* @returns {boolean}
|
|
344
|
+
*/
|
|
345
|
+
export function removePath(targetPath) {
|
|
346
|
+
try {
|
|
347
|
+
fs.rmSync(targetPath, { recursive: true, force: true });
|
|
348
|
+
return true;
|
|
349
|
+
} catch {
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* System / Ubuntu cleanup actions. Each is opt-in. In dry-run mode the commands
|
|
356
|
+
* are only described, never executed.
|
|
357
|
+
*
|
|
358
|
+
* @param {Object} options
|
|
359
|
+
* @param {boolean} [options.apt] - apt-get clean / autoclean / autoremove
|
|
360
|
+
* @param {boolean} [options.journal] - journalctl --vacuum-time
|
|
361
|
+
* @param {boolean} [options.docker] - docker system prune
|
|
362
|
+
* @param {boolean} [options.npm] - npm cache clean --force
|
|
363
|
+
* @param {string} [options.journalVacuumTime='2weeks']
|
|
364
|
+
* @param {boolean} [options.dryRun]
|
|
365
|
+
* @param {boolean} [options.useSudo] - prefix package commands with sudo
|
|
366
|
+
* @param {(msg: string) => void} [options.logFn]
|
|
367
|
+
* @returns {Array<{command: string, executed: boolean, ok: boolean|null}>}
|
|
368
|
+
*/
|
|
369
|
+
export function runSystemCleanup(options = {}) {
|
|
370
|
+
const { apt = false, journal = false, docker = false, npm = false, journalVacuumTime = '2weeks', dryRun = false, useSudo = false, logFn = () => {} } = options;
|
|
371
|
+
|
|
372
|
+
const plan = [];
|
|
373
|
+
const sudo = useSudo ? ['sudo'] : [];
|
|
374
|
+
if (apt) {
|
|
375
|
+
plan.push([...sudo, 'apt-get', 'clean']);
|
|
376
|
+
plan.push([...sudo, 'apt-get', 'autoclean', '-y']);
|
|
377
|
+
plan.push([...sudo, 'apt-get', 'autoremove', '-y']);
|
|
378
|
+
}
|
|
379
|
+
if (journal) {
|
|
380
|
+
plan.push([...sudo, 'journalctl', `--vacuum-time=${journalVacuumTime}`]);
|
|
381
|
+
}
|
|
382
|
+
if (docker) {
|
|
383
|
+
plan.push(['docker', 'system', 'prune', '-f']);
|
|
384
|
+
}
|
|
385
|
+
if (npm) {
|
|
386
|
+
plan.push(['npm', 'cache', 'clean', '--force']);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const results = [];
|
|
390
|
+
for (const argv of plan) {
|
|
391
|
+
const display = argv.join(' ');
|
|
392
|
+
if (dryRun) {
|
|
393
|
+
logFn(` [dry-run] would run: ${display}`);
|
|
394
|
+
results.push({ command: display, executed: false, ok: null });
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
logFn(` running: ${display}`);
|
|
398
|
+
const out = tryExec(argv[0], argv.slice(1), { timeout: 180000, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
399
|
+
const ok = out !== null;
|
|
400
|
+
logFn(ok ? ` ✓ ${display}` : ` ✗ ${display} (failed or unavailable)`);
|
|
401
|
+
results.push({ command: display, executed: true, ok });
|
|
402
|
+
}
|
|
403
|
+
return results;
|
|
404
|
+
}
|