@thincoder/core 0.9.1
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/abort-provenance.mjs +116 -0
- package/advisor/citations.mjs +139 -0
- package/advisor/compaction.mjs +174 -0
- package/advisor/convergence.mjs +80 -0
- package/advisor/history.mjs +77 -0
- package/advisor/loop.mjs +288 -0
- package/advisor/messages.mjs +299 -0
- package/advisor/notice.mjs +141 -0
- package/advisor/project-context.mjs +197 -0
- package/advisor/repos.mjs +150 -0
- package/advisor/run.mjs +190 -0
- package/advisor/truncate.mjs +57 -0
- package/advisor.mjs +281 -0
- package/agent/child-marks.mjs +24 -0
- package/agent/completion.mjs +145 -0
- package/agent/dispatch.mjs +493 -0
- package/agent/family-tools.mjs +174 -0
- package/agent/helpers.mjs +412 -0
- package/agent/post-turn.mjs +70 -0
- package/agent/record-results.mjs +174 -0
- package/agent/relay-prefix.mjs +39 -0
- package/agent/run-stages.mjs +244 -0
- package/agent/setup-reminders.mjs +199 -0
- package/agent/setup.mjs +234 -0
- package/agent/spawn-child.mjs +258 -0
- package/agent/suspension.mjs +240 -0
- package/agent/write-gate.mjs +87 -0
- package/agent-tools/advisor-async.mjs +481 -0
- package/agent-tools/advisor-settle.mjs +240 -0
- package/agent-tools/advisor.mjs +280 -0
- package/agent-tools/async-discard.mjs +143 -0
- package/agent-tools/async-settle.mjs +299 -0
- package/agent-tools/batch-segment.mjs +265 -0
- package/agent-tools/child-permission.mjs +45 -0
- package/agent-tools/consult.mjs +471 -0
- package/agent-tools/design-token.mjs +117 -0
- package/agent-tools/digest-budget.mjs +76 -0
- package/agent-tools/eng.mjs +102 -0
- package/agent-tools/escalate-async.mjs +302 -0
- package/agent-tools/goal.mjs +119 -0
- package/agent-tools/panel-blocks.mjs +24 -0
- package/agent-tools/parent-channel.mjs +231 -0
- package/agent-tools/plan.mjs +86 -0
- package/agent-tools/read-history.mjs +309 -0
- package/agent-tools/recent-changes.mjs +24 -0
- package/agent-tools/review-facts.mjs +31 -0
- package/agent-tools/settings.mjs +268 -0
- package/agent-tools/skill.mjs +63 -0
- package/agent-tools/spawn-gates.mjs +109 -0
- package/agent-tools/subagent-actions.mjs +496 -0
- package/agent-tools/subagent-async.mjs +456 -0
- package/agent-tools/subagent-panel.mjs +160 -0
- package/agent-tools/subagent-run.mjs +208 -0
- package/agent-tools/subagent-scheduler.mjs +446 -0
- package/agent-tools/subagent-spawn.mjs +478 -0
- package/agent-tools/subagent.mjs +419 -0
- package/agent-tools/task.mjs +87 -0
- package/agent-tools/timer.mjs +46 -0
- package/agent-tools/verify.mjs +295 -0
- package/agent-tools.mjs +23 -0
- package/agent.mjs +430 -0
- package/auto-think.mjs +115 -0
- package/compress-form.mjs +24 -0
- package/config-io.mjs +277 -0
- package/config-migrate.mjs +178 -0
- package/config-presets.mjs +49 -0
- package/config.mjs +419 -0
- package/context.mjs +495 -0
- package/conventions.mjs +223 -0
- package/embedding.mjs +120 -0
- package/escape.mjs +152 -0
- package/expand-home.mjs +16 -0
- package/explore-distill.mjs +152 -0
- package/generate-title.mjs +123 -0
- package/git/checkpoint.mjs +448 -0
- package/git/gitmem.mjs +100 -0
- package/history-window.mjs +179 -0
- package/hooks.mjs +108 -0
- package/i18n.mjs +106 -0
- package/index-bin.mjs +48 -0
- package/index-discover.mjs +176 -0
- package/ledger-cmd.mjs +209 -0
- package/ledger-db.mjs +86 -0
- package/ledger-surface.mjs +76 -0
- package/ledger.mjs +202 -0
- package/log.mjs +195 -0
- package/manifest.mjs +338 -0
- package/markdown.mjs +106 -0
- package/mcp/helpers.mjs +51 -0
- package/mcp/transport-http.mjs +248 -0
- package/mcp/transport-stdio.mjs +140 -0
- package/mcp/transport-ws.mjs +122 -0
- package/mcp.mjs +295 -0
- package/memory/code-index.mjs +219 -0
- package/memory/code-sync.mjs +427 -0
- package/memory/core.mjs +318 -0
- package/memory/delete.mjs +242 -0
- package/memory/docs.mjs +431 -0
- package/memory/file-walk.mjs +109 -0
- package/memory/origin.mjs +24 -0
- package/memory/scan.mjs +177 -0
- package/memory/schema.mjs +460 -0
- package/memory.mjs +21 -0
- package/model-ref.mjs +66 -0
- package/model-specs.mjs +277 -0
- package/package.json +32 -0
- package/peer-domains.mjs +265 -0
- package/peer-instances.mjs +178 -0
- package/permission.mjs +79 -0
- package/process-probe.mjs +315 -0
- package/prompt-files.mjs +113 -0
- package/prompt-overlays.mjs +78 -0
- package/prompts/advisor-design.md +43 -0
- package/prompts/advisor-round1.md +41 -0
- package/prompts/advisor-round2.md +46 -0
- package/prompts/advisor-round3.md +42 -0
- package/prompts/common.md +158 -0
- package/prompts/consult-base.md +19 -0
- package/prompts/discipline-engineering.md +123 -0
- package/prompts/discipline-normal.md +206 -0
- package/prompts/persona-coder.md +21 -0
- package/prompts/persona-eng-coder.md +41 -0
- package/prompts/persona-eng-designer.md +80 -0
- package/prompts/persona-engineering.md +160 -0
- package/prompts/persona-explore.md +15 -0
- package/prompts/persona-normal.md +35 -0
- package/prompts/persona-plan.md +27 -0
- package/provider/anthropic.mjs +225 -0
- package/provider/core.mjs +491 -0
- package/provider/errors.mjs +101 -0
- package/provider/google.mjs +257 -0
- package/provider/index.mjs +7 -0
- package/provider/list-models.mjs +163 -0
- package/provider/normalize.mjs +81 -0
- package/provider/rate.mjs +168 -0
- package/provider/responses.mjs +495 -0
- package/provider/retry.mjs +88 -0
- package/provider/sse.mjs +264 -0
- package/provider/wait-status.mjs +59 -0
- package/proxy.mjs +274 -0
- package/rules.mjs +53 -0
- package/session-gc.mjs +248 -0
- package/session-guard.mjs +59 -0
- package/session-lifecycle.mjs +305 -0
- package/session-migrate.mjs +48 -0
- package/session-rename.mjs +38 -0
- package/session-segments.mjs +99 -0
- package/session-slot-write.mjs +168 -0
- package/session-slots-manifest.mjs +264 -0
- package/session-slots.mjs +298 -0
- package/session-store.mjs +441 -0
- package/session.mjs +244 -0
- package/skills.mjs +234 -0
- package/text-budget.mjs +79 -0
- package/token-ttl.mjs +285 -0
- package/tool-docs/apply_patch.md +15 -0
- package/tool-docs/bash.md +38 -0
- package/tool-docs/delete.md +13 -0
- package/tool-docs/edit.md +30 -0
- package/tool-docs/execute.md +21 -0
- package/tool-docs/fetch.md +12 -0
- package/tool-docs/file_ops.md +17 -0
- package/tool-docs/get_current_time.md +8 -0
- package/tool-docs/git.md +54 -0
- package/tool-docs/glob.md +11 -0
- package/tool-docs/grep.md +19 -0
- package/tool-docs/hashline_edit.md +14 -0
- package/tool-docs/insert_after.md +15 -0
- package/tool-docs/lint.md +10 -0
- package/tool-docs/ls.md +12 -0
- package/tool-docs/lsp.md +10 -0
- package/tool-docs/process.md +10 -0
- package/tool-docs/question.md +16 -0
- package/tool-docs/read.md +20 -0
- package/tool-docs/read_image.md +8 -0
- package/tool-docs/tree.md +14 -0
- package/tool-docs/wait_for.md +22 -0
- package/tool-docs/websearch.md +16 -0
- package/tool-docs/write.md +11 -0
- package/tools/bash.mjs +276 -0
- package/tools/edit-batch.mjs +204 -0
- package/tools/edit-diff.mjs +388 -0
- package/tools/exec-run.mjs +43 -0
- package/tools/execute.mjs +243 -0
- package/tools/file.mjs +464 -0
- package/tools/git-checkpoint.mjs +143 -0
- package/tools/git-ext.mjs +173 -0
- package/tools/git.mjs +415 -0
- package/tools/glob-dialect.mjs +130 -0
- package/tools/index.mjs +76 -0
- package/tools/linter.mjs +120 -0
- package/tools/lsp.mjs +335 -0
- package/tools/ops.mjs +293 -0
- package/tools/patch.mjs +290 -0
- package/tools/question.mjs +26 -0
- package/tools/repomap.mjs +314 -0
- package/tools/search.mjs +248 -0
- package/tools/shared.mjs +467 -0
- package/tools/tree.mjs +81 -0
- package/tools/web.mjs +224 -0
- package/tools/write-path.mjs +191 -0
- package/traces/trace-store.mjs +303 -0
- package/undo-stack.mjs +47 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* generate-title.mjs — LLM-generated session titles
|
|
3
|
+
* Called after the first user message to auto-title the session.
|
|
4
|
+
*
|
|
5
|
+
* §2.5 #163 并入:按 `provider.format` 三格式分派(VSC 侧能力归位)——openai 兼容(缺省/
|
|
6
|
+
* 未知)/ anthropic / google;各格式的 thinking 禁用形态与响应提取各自适配(IK9UZ8:
|
|
7
|
+
* thinking 模型会把输出预算烧在 reasoning 上、content 为空——标题静默失败)。
|
|
8
|
+
* 定制头(`provider.headers`)与代理路径(`proxyUri`)为 CLI 侧口径,三格式共用。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { proxyFetch } from "./proxy.mjs"
|
|
12
|
+
|
|
13
|
+
// Test seam (_-prefix, mirrors run.mjs seams): lets the proxy-branch regression
|
|
14
|
+
// test swap the proxy fetch. The branch it exercises used to carry a dynamic
|
|
15
|
+
// import("../proxy.mjs") that silently resolved to the REPO ROOT from src/ —
|
|
16
|
+
// the thrown ERR_MODULE_NOT_FOUND vanished into the catch, and proxy users
|
|
17
|
+
// lost session titles with zero test coverage (2026-08-30 review).
|
|
18
|
+
export const _deps = { proxyFetchImpl: proxyFetch }
|
|
19
|
+
|
|
20
|
+
const MAX_TITLE_TOKENS = 100
|
|
21
|
+
|
|
22
|
+
/** Generate a session title from the first user message using an LLM. Returns title string or null. */
|
|
23
|
+
export async function generateTitle(userContent, provider) {
|
|
24
|
+
// Extract text even from multimodal content (array of parts)
|
|
25
|
+
const userText = Array.isArray(userContent)
|
|
26
|
+
? userContent.find((p) => p.type === "text")?.text || ""
|
|
27
|
+
: userContent
|
|
28
|
+
if (typeof userText !== "string" || userText.length < 10) return null
|
|
29
|
+
if (!provider?.apiKey || !provider?.baseURL || !provider?.model) return null
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const system = "Generate a concise title (max 40 chars, no quotes) for this conversation. Reply ONLY with the title."
|
|
33
|
+
const text = userText.slice(0, 200)
|
|
34
|
+
let url, body, extract
|
|
35
|
+
if (provider.format === "anthropic") {
|
|
36
|
+
url = `${provider.baseURL.replace(/\/+$/, "")}/messages`
|
|
37
|
+
body = JSON.stringify({
|
|
38
|
+
model: provider.model,
|
|
39
|
+
system,
|
|
40
|
+
messages: [{ role: "user", content: text }],
|
|
41
|
+
max_tokens: MAX_TITLE_TOKENS,
|
|
42
|
+
stream: false,
|
|
43
|
+
thinking: { type: "disabled" }, // IK9UZ8:不让 reasoning 吃掉输出预算
|
|
44
|
+
})
|
|
45
|
+
extract = (data) => data.content?.map((b) => b.text || "").join("")
|
|
46
|
+
} else if (provider.format === "google") {
|
|
47
|
+
url = `${provider.baseURL.replace(/\/+$/, "")}/models/${encodeURIComponent(provider.model)}:generateContent?key=${encodeURIComponent(provider.apiKey)}`
|
|
48
|
+
body = JSON.stringify({
|
|
49
|
+
systemInstruction: { parts: [{ text: system }] },
|
|
50
|
+
contents: [{ role: "user", parts: [{ text }] }],
|
|
51
|
+
generationConfig: { maxOutputTokens: MAX_TITLE_TOKENS, thinkingConfig: { thinkingLevel: "none" } }, // IK9UZ8
|
|
52
|
+
})
|
|
53
|
+
extract = (data) => data.candidates?.[0]?.content?.parts?.map((p) => p.text || "").join("")
|
|
54
|
+
} else {
|
|
55
|
+
const bodyObj = {
|
|
56
|
+
model: provider.model,
|
|
57
|
+
messages: [
|
|
58
|
+
{ role: "system", content: system },
|
|
59
|
+
{ role: "user", content: text },
|
|
60
|
+
],
|
|
61
|
+
// Disable thinking so reasoning_content doesn't consume the whole output budget and
|
|
62
|
+
// leave content empty (IK9UZ8). Providers that don't accept the field ignore it
|
|
63
|
+
// (OpenAI-compatible convention). A 40-char title wants ~60–80 tokens, so 100 is
|
|
64
|
+
// ~2.5x headroom (design decision — docs/core/design/SESSION.md §6.7).
|
|
65
|
+
thinking: { type: "disabled" },
|
|
66
|
+
max_tokens: MAX_TITLE_TOKENS,
|
|
67
|
+
stream: false,
|
|
68
|
+
}
|
|
69
|
+
const chatPath = provider.chatPath ?? "/chat/completions"
|
|
70
|
+
url = `${provider.baseURL.replace(/\/+$/, "")}${chatPath}`
|
|
71
|
+
body = JSON.stringify(bodyObj)
|
|
72
|
+
extract = (data) => data.choices?.[0]?.message?.content
|
|
73
|
+
}
|
|
74
|
+
// 定制头展开(PROVIDER.md §6.17)——定制头在前、内置头在后:内置头胜出;三格式各自内置头。
|
|
75
|
+
const headers = { ...(provider.headers ?? {}), "Content-Type": "application/json" }
|
|
76
|
+
if (provider.format === "anthropic") {
|
|
77
|
+
headers["x-api-key"] = provider.apiKey
|
|
78
|
+
headers["anthropic-version"] = "2023-06-01"
|
|
79
|
+
} else if (provider.format !== "google") {
|
|
80
|
+
headers.Authorization = `Bearer ${provider.apiKey}` // google 走 URL key
|
|
81
|
+
}
|
|
82
|
+
const opts = {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers,
|
|
85
|
+
body,
|
|
86
|
+
signal: AbortSignal.timeout(10000),
|
|
87
|
+
}
|
|
88
|
+
const res = provider.proxyUri
|
|
89
|
+
? await _deps.proxyFetchImpl(url, opts, provider.proxyUri)
|
|
90
|
+
: await fetch(url, opts)
|
|
91
|
+
if (!res.ok) return null
|
|
92
|
+
const data = await res.json()
|
|
93
|
+
const title = extract(data)?.trim().slice(0, 40)
|
|
94
|
+
return title || null
|
|
95
|
+
} catch {
|
|
96
|
+
return null
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Derive + assign the session title from the first user message (once per session).
|
|
101
|
+
* Extracted from agent-turn.mjs's finally block (2026-08-30): the lookup + call +
|
|
102
|
+
* assign belongs beside generateTitle, not in the turn driver. Non-fatal on
|
|
103
|
+
* failure — title generation must never break the turn. Returns the title (or null). */
|
|
104
|
+
export async function ensureSessionTitle(agent) {
|
|
105
|
+
if (agent.title) return agent.title
|
|
106
|
+
try {
|
|
107
|
+
// TUI-OOM-ROOTCAUSE 批(SESSION.md §6.14):绑定态首条 user 消息在记录存储(段 1)——
|
|
108
|
+
// 内存窗口可能已滑过它(store.firstUserMessage 首扫一次并缓存);未绑定回退内存查找。
|
|
109
|
+
let firstUser = agent._recordStore?.firstUserMessage?.() ?? null
|
|
110
|
+
if (!firstUser) {
|
|
111
|
+
firstUser = (agent._fullHistory ?? agent.history).find(
|
|
112
|
+
(m) => m.role === "user" && typeof m.content === "string" && !m.content.startsWith("[System reminder:"),
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
if (firstUser) {
|
|
116
|
+
const title = await generateTitle(firstUser.content, agent.provider)
|
|
117
|
+
if (title) agent.title = title
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
// Title generation failure is non-fatal
|
|
121
|
+
}
|
|
122
|
+
return agent.title ?? null
|
|
123
|
+
}
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* checkpoint.mjs — workspace snapshot and rollback (v2: full-file copies)
|
|
3
|
+
* Snapshot = full copies of changed tracked files + untracked file copies (respects .gitignore).
|
|
4
|
+
* v1 stored only a git diff patch — rewind depended on HEAD being unchanged (a commit after the
|
|
5
|
+
* snapshot made `git apply` fail and the failure recovery chain collapse). v2 copies files, so
|
|
6
|
+
* rewind works regardless of later commits.
|
|
7
|
+
* Only available inside git repos. Rewind creates a new snapshot first (rewind is reversible).
|
|
8
|
+
* rewind supports a path parameter for per-file restore.
|
|
9
|
+
*/
|
|
10
|
+
import { execFileSync } from "node:child_process"
|
|
11
|
+
import { createHash } from "node:crypto"
|
|
12
|
+
import { existsSync, readFileSync, statSync } from "node:fs"
|
|
13
|
+
import { cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"
|
|
14
|
+
import { dirname, join } from "node:path"
|
|
15
|
+
import { configDir } from "../config.mjs"
|
|
16
|
+
|
|
17
|
+
const CWD_HASH_LEN = 12
|
|
18
|
+
|
|
19
|
+
const MAX_CHECKPOINTS = 100
|
|
20
|
+
|
|
21
|
+
/** Files larger than this are NOT copied (sqlite db, bundles…) — they are recorded as skipped. */
|
|
22
|
+
const MAX_FILE_BYTES = 5 * 1024 * 1024
|
|
23
|
+
|
|
24
|
+
/** meta.version 2 = full-copy snapshots; 1 = legacy patch-only snapshots */
|
|
25
|
+
const META_VERSION = 2
|
|
26
|
+
|
|
27
|
+
function git(cwd, args, { allowFail = false } = {}) {
|
|
28
|
+
try {
|
|
29
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim()
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (allowFail) return null
|
|
32
|
+
throw new Error(`git ${args.join(" ")} failed: ${error.stderr?.toString().trim() || error.message}`, { cause: error })
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Normalize cwd for hashing: uppercase the Windows drive letter so the VS Code
|
|
37
|
+
* extension's uri.fsPath (lowercased) produces the SAME cwdHash12 as the CLI's
|
|
38
|
+
* process.cwd() — cross-end snapshot sharing (CHECKPOINT.md F5/T7) depends on this
|
|
39
|
+
* contract. Same normalization as session storage (session-slots.mjs normalizeCwd). */
|
|
40
|
+
function normalizeCwd(cwd) {
|
|
41
|
+
return cwd.replace(/^([a-z]):/, (_, d) => d.toUpperCase() + ":")
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function checkpointRoot(cwd) {
|
|
45
|
+
const hash = createHash("sha1").update(normalizeCwd(cwd)).digest("hex").slice(0, CWD_HASH_LEN)
|
|
46
|
+
return join(configDir, "checkpoints", hash)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Extract the list of tracked files changed from a unified diff */
|
|
50
|
+
function trackedFilesFromPatch(patch) {
|
|
51
|
+
if (!patch.trim()) return []
|
|
52
|
+
const files = new Set()
|
|
53
|
+
for (const m of patch.matchAll(/^--- a\/(.+)$/gm)) files.add(m[1])
|
|
54
|
+
return [...files].sort()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Extract a single file's patch hunks from a unified diff */
|
|
58
|
+
function extractFileHunks(patch, filePath) {
|
|
59
|
+
// Split by file: each file block starts with "diff --git"
|
|
60
|
+
const sections = patch.split(/(?=^diff --git )/m)
|
|
61
|
+
for (const sec of sections) {
|
|
62
|
+
if (!sec.trim()) continue
|
|
63
|
+
const m = sec.match(/^diff --git a\/(.+) b\/(.+)/m)
|
|
64
|
+
if (!m) continue
|
|
65
|
+
if (m[1] === filePath || m[2] === filePath) return sec.trim()
|
|
66
|
+
}
|
|
67
|
+
return ""
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Whether the current directory is a git repo */
|
|
71
|
+
export function isGitRepo(cwd) {
|
|
72
|
+
return git(cwd, ["rev-parse", "--is-inside-work-tree"], { allowFail: true }) === "true"
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Copy a file into a snapshot directory, skipping oversized files.
|
|
77
|
+
* Returns { size, sha } (sha = SHA256 hex, 12 chars) or null when skipped/failed.
|
|
78
|
+
*/
|
|
79
|
+
async function copyInto(dir, rel, src, skipped) {
|
|
80
|
+
let size
|
|
81
|
+
try { size = statSync(src).size } catch { return null }
|
|
82
|
+
if (size > MAX_FILE_BYTES) {
|
|
83
|
+
skipped.push(rel)
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
const buf = await readFile(src).catch((e) => { console.error(`[checkpoint] skipping ${rel}: ${e.message}`); return null })
|
|
87
|
+
if (!buf) return null
|
|
88
|
+
const dst = join(dir, rel)
|
|
89
|
+
await mkdir(dirname(dst), { recursive: true })
|
|
90
|
+
await writeFile(dst, buf)
|
|
91
|
+
return { size: buf.length, sha: createHash("sha256").update(buf).digest("hex").slice(0, 12) }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Create a snapshot. Returns { id, time, files, tracked, untracked, skipped } or null (non-git repo).
|
|
96
|
+
* Per-file metadata ({ size, sha } per copied file) is stored in meta.json — this powers
|
|
97
|
+
* listFileVersions (per-file history across snapshots) without rescanning copies.
|
|
98
|
+
*/
|
|
99
|
+
/** Full-directory snapshot for NON-git cwds (desktop proposal ②: createCheckpoint used to
|
|
100
|
+
* return null here, silently disabling checkpoints for non-git projects). Every file is
|
|
101
|
+
* copied under files/ (v2 layout, same rewind code path); meta carries nongit:true so
|
|
102
|
+
* rewind knows to skip git plumbing. Respects .gitignore-like skips via SKIP set only. */
|
|
103
|
+
async function createNonGitCheckpoint(cwd) {
|
|
104
|
+
const id = Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 6)
|
|
105
|
+
const dir = join(checkpointRoot(cwd), id)
|
|
106
|
+
await mkdir(join(dir, "files"), { recursive: true })
|
|
107
|
+
await mkdir(join(dir, "untracked"), { recursive: true })
|
|
108
|
+
|
|
109
|
+
const skipped = []
|
|
110
|
+
const fileMeta = {}
|
|
111
|
+
const all = await collectFiles(cwd)
|
|
112
|
+
for (const rel of all) {
|
|
113
|
+
const m = await copyInto(join(dir, "files"), rel, join(cwd, rel), skipped)
|
|
114
|
+
if (m) fileMeta[rel] = m
|
|
115
|
+
}
|
|
116
|
+
await writeFile(join(dir, "patch.diff"), "", "utf8") // no patch in nongit mode (layout compat)
|
|
117
|
+
await writeFile(join(dir, "meta.json"), JSON.stringify({
|
|
118
|
+
version: META_VERSION, id, time: Date.now(), nongit: true,
|
|
119
|
+
untracked: [], tracked: all, trackedAll: all, skipped, head: "", fileMeta, untrackedMeta: {},
|
|
120
|
+
}, null, 2), "utf8")
|
|
121
|
+
await pruneCheckpoints(cwd)
|
|
122
|
+
return { id, time: Date.now(), files: all.length, tracked: all, untracked: [], skipped }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Walk a non-git cwd collecting every file (relative paths), skipping heavy/irrelevant dirs. */
|
|
126
|
+
async function collectFiles(cwd) {
|
|
127
|
+
const SKIP = new Set(["node_modules", ".git", "dist", "build", ".turbo", "coverage", ".next", "target", "__pycache__", ".venv"])
|
|
128
|
+
const out = []
|
|
129
|
+
async function walk(rel) {
|
|
130
|
+
const abs = rel ? join(cwd, rel) : cwd
|
|
131
|
+
let entries
|
|
132
|
+
try { entries = await readdir(abs, { withFileTypes: true }) } catch { return }
|
|
133
|
+
for (const e of entries) {
|
|
134
|
+
if (e.name.startsWith(".thincoder")) continue
|
|
135
|
+
const child = rel ? `${rel}/${e.name}` : e.name
|
|
136
|
+
if (e.isDirectory()) { if (!SKIP.has(e.name)) await walk(child) }
|
|
137
|
+
else out.push(child)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
await walk("")
|
|
141
|
+
return out
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function createCheckpoint(cwd) {
|
|
145
|
+
if (!isGitRepo(cwd)) return createNonGitCheckpoint(cwd)
|
|
146
|
+
|
|
147
|
+
// Random suffix: prevents id collisions for two snapshots in the same millisecond (sorting stays ordered by timestamp prefix)
|
|
148
|
+
const id = Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 6)
|
|
149
|
+
const dir = join(checkpointRoot(cwd), id)
|
|
150
|
+
await mkdir(join(dir, "files"), { recursive: true })
|
|
151
|
+
await mkdir(join(dir, "untracked"), { recursive: true })
|
|
152
|
+
|
|
153
|
+
// v2: full copies of changed tracked files (git apply of a stale patch is the v1 failure mode).
|
|
154
|
+
// The patch is still written for legacy tooling/debugging, but rewind uses the copies.
|
|
155
|
+
const head = git(cwd, ["rev-parse", "HEAD"], { allowFail: true }) ?? ""
|
|
156
|
+
const changedRaw = git(cwd, ["diff", "HEAD", "--name-only", "-z"], { allowFail: true }) ?? ""
|
|
157
|
+
const changed = changedRaw ? changedRaw.split("\0").filter(Boolean) : []
|
|
158
|
+
const trackedAllRaw = git(cwd, ["ls-files", "-z"], { allowFail: true }) ?? ""
|
|
159
|
+
const trackedAll = trackedAllRaw ? trackedAllRaw.split("\0").filter(Boolean) : []
|
|
160
|
+
const patch = git(cwd, ["diff", "HEAD", "--binary"], { allowFail: true }) ?? ""
|
|
161
|
+
await writeFile(join(dir, "patch.diff"), patch, "utf8")
|
|
162
|
+
|
|
163
|
+
const skipped = []
|
|
164
|
+
const fileMeta = {}
|
|
165
|
+
for (const rel of changed) {
|
|
166
|
+
const m = await copyInto(join(dir, "files"), rel, join(cwd, rel), skipped)
|
|
167
|
+
if (m) fileMeta[rel] = m
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Untracked files (respects .gitignore) → copy as-is
|
|
171
|
+
const untrackedRaw = git(cwd, ["ls-files", "--others", "--exclude-standard"], { allowFail: true }) ?? ""
|
|
172
|
+
const untracked = untrackedRaw ? untrackedRaw.split("\n").filter(Boolean) : []
|
|
173
|
+
const untrackedMeta = {}
|
|
174
|
+
for (const rel of untracked) {
|
|
175
|
+
const m = await copyInto(join(dir, "untracked"), rel, join(cwd, rel), skipped)
|
|
176
|
+
if (m) untrackedMeta[rel] = m
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
await writeFile(join(dir, "meta.json"), JSON.stringify({
|
|
180
|
+
version: META_VERSION, id, time: Date.now(), untracked, tracked: changed, skipped,
|
|
181
|
+
head, trackedAll, fileMeta, untrackedMeta,
|
|
182
|
+
}, null, 2), "utf8")
|
|
183
|
+
|
|
184
|
+
await pruneCheckpoints(cwd)
|
|
185
|
+
return { id, time: Date.now(), files: changed.length + untracked.length, tracked: changed, untracked, skipped }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** List checkpoints (newest→oldest), with file change summary */
|
|
189
|
+
export async function listCheckpoints(cwd) {
|
|
190
|
+
const root = checkpointRoot(cwd)
|
|
191
|
+
if (!existsSync(root)) return []
|
|
192
|
+
const ids = (await readdir(root)).sort().reverse()
|
|
193
|
+
const out = []
|
|
194
|
+
for (const id of ids) {
|
|
195
|
+
try {
|
|
196
|
+
const meta = JSON.parse(await readFile(join(root, id, "meta.json"), "utf8"))
|
|
197
|
+
// Compat with old checkpoints (no tracked field): extract from patch file
|
|
198
|
+
const tracked = meta.tracked ?? (() => {
|
|
199
|
+
try {
|
|
200
|
+
return trackedFilesFromPatch(readFileSync(join(root, id, "patch.diff"), "utf8"))
|
|
201
|
+
} catch { return [] }
|
|
202
|
+
})()
|
|
203
|
+
out.push({
|
|
204
|
+
id, time: meta.time,
|
|
205
|
+
untracked: meta.untracked ?? [],
|
|
206
|
+
skipped: meta.skipped ?? [],
|
|
207
|
+
tracked,
|
|
208
|
+
})
|
|
209
|
+
} catch {
|
|
210
|
+
// Corrupted checkpoint — skip
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return out
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---- restore core ----
|
|
217
|
+
|
|
218
|
+
/** v1 legacy fallback: reset file to HEAD then apply its patch hunks. */
|
|
219
|
+
async function partialRestoreTrackedViaPatch(cwd, root, patchContent, filePath) {
|
|
220
|
+
git(cwd, ["checkout", "HEAD", "--", filePath], { allowFail: true })
|
|
221
|
+
const hunks = extractFileHunks(patchContent, filePath)
|
|
222
|
+
if (!hunks) return false
|
|
223
|
+
const tmpFile = join(root, ".tmp_partial.patch")
|
|
224
|
+
await writeFile(tmpFile, hunks, "utf8")
|
|
225
|
+
try {
|
|
226
|
+
git(cwd, ["apply", "--whitespace=nowarn", tmpFile])
|
|
227
|
+
return true
|
|
228
|
+
} finally {
|
|
229
|
+
await rm(tmpFile, { force: true })
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Restore a single file from a snapshot copy. Returns true when the copy existed. */
|
|
234
|
+
async function restoreFromCopy(snapshotDir, rel, cwd) {
|
|
235
|
+
const src = join(snapshotDir, rel)
|
|
236
|
+
if (!existsSync(src)) return false
|
|
237
|
+
await mkdir(dirname(join(cwd, rel)), { recursive: true })
|
|
238
|
+
await cp(src, join(cwd, rel), { force: true })
|
|
239
|
+
return true
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---- restore (single-file only) ----
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Restore ONE file from a specific snapshot (saves current state as a new snapshot first,
|
|
246
|
+
* making the restore reversible).
|
|
247
|
+
*
|
|
248
|
+
* FULL restore is deliberately DISABLED: rolling the whole working tree back to a snapshot
|
|
249
|
+
* is exactly as dangerous as `git checkout -- .` — it silently discards every change made
|
|
250
|
+
* after the snapshot. Restore files individually (rewind with path / restoreFile).
|
|
251
|
+
*
|
|
252
|
+
* Returns { path, type, restored }.
|
|
253
|
+
*/
|
|
254
|
+
export async function rewind(cwd, id, { path } = {}) {
|
|
255
|
+
if (!path) {
|
|
256
|
+
throw new Error(
|
|
257
|
+
"Full rewind is disabled — it is as dangerous as `git checkout -- .` (silently discards all work after the snapshot). " +
|
|
258
|
+
"Restore files individually: rewind(cwd, id, { path }) or restoreFile(cwd, path, id)."
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const root = checkpointRoot(cwd)
|
|
263
|
+
const dir = join(root, id)
|
|
264
|
+
if (!existsSync(join(dir, "meta.json"))) throw new Error(`checkpoint ${id} not found`)
|
|
265
|
+
const meta = JSON.parse(await readFile(join(dir, "meta.json"), "utf8"))
|
|
266
|
+
const isV2 = meta.version === META_VERSION
|
|
267
|
+
const patchContent = await readFile(join(dir, "patch.diff"), "utf8")
|
|
268
|
+
|
|
269
|
+
// Restore is reversible: snapshot current state first
|
|
270
|
+
await createCheckpoint(cwd)
|
|
271
|
+
|
|
272
|
+
const tracked = meta.tracked ?? []
|
|
273
|
+
const trackedAll = meta.trackedAll ?? []
|
|
274
|
+
const isTracked = tracked.includes(path) || trackedAll.includes(path) || extractFileHunks(patchContent, path) !== ""
|
|
275
|
+
const inUntracked = (meta.untracked ?? []).includes(path)
|
|
276
|
+
|
|
277
|
+
// Oversized files were never copied — nothing to restore, say so explicitly.
|
|
278
|
+
if ((meta.skipped ?? []).includes(path)) {
|
|
279
|
+
throw new Error(`file "${path}" was NOT snapshotted in checkpoint ${id} (oversized, >5MB) — no copy exists to restore`)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (!isTracked && !inUntracked) {
|
|
283
|
+
throw new Error(`file "${path}" not found in checkpoint ${id} (tracked: ${tracked.join(", ") || "none"}, untracked: ${(meta.untracked ?? []).join(", ") || "none"})`)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
let ok = false
|
|
287
|
+
if (inUntracked) {
|
|
288
|
+
ok = await restoreFromCopy(join(dir, "untracked"), path, cwd) || ok
|
|
289
|
+
}
|
|
290
|
+
if (isTracked) {
|
|
291
|
+
if (isV2 && existsSync(join(dir, "files", path))) {
|
|
292
|
+
ok = await restoreFromCopy(join(dir, "files"), path, cwd) || ok
|
|
293
|
+
} else if (isV2 && meta.head) {
|
|
294
|
+
// Untouched at snapshot time (content = snapshot HEAD) → checkout that commit's version.
|
|
295
|
+
// Works even if HEAD moved since — the commit object is immutable.
|
|
296
|
+
git(cwd, ["checkout", meta.head, "--", path], { allowFail: true })
|
|
297
|
+
ok = true
|
|
298
|
+
} else {
|
|
299
|
+
// v1 snapshot → patch fallback
|
|
300
|
+
ok = await partialRestoreTrackedViaPatch(cwd, root, patchContent, path) || ok
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return { path, type: isTracked ? "tracked" : "untracked", restored: ok }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ---- per-file history ----
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* List every historical version of a file across all snapshots (newest first).
|
|
311
|
+
* Distinguishing copies: each entry carries its snapshot id, time, byte size and
|
|
312
|
+
* content sha — the same file in different snapshots is a different version.
|
|
313
|
+
* @param {string} cwd
|
|
314
|
+
* @param {string} filePath — relative path as stored in snapshots
|
|
315
|
+
* @returns {Promise<Array<{snapshotId: string, time: number, size: number, sha: string, source: "tracked"|"untracked"}>>}
|
|
316
|
+
*/
|
|
317
|
+
export async function listFileVersions(cwd, filePath) {
|
|
318
|
+
const root = checkpointRoot(cwd)
|
|
319
|
+
if (!existsSync(root)) return []
|
|
320
|
+
const ids = (await readdir(root)).sort().reverse() // newest → oldest
|
|
321
|
+
const out = []
|
|
322
|
+
for (const id of ids) {
|
|
323
|
+
let meta
|
|
324
|
+
try { meta = JSON.parse(await readFile(join(root, id, "meta.json"), "utf8")) } catch { continue }
|
|
325
|
+
const trackedMeta = meta.fileMeta ?? {}
|
|
326
|
+
const untrackedMeta = meta.untrackedMeta ?? {}
|
|
327
|
+
let entry = null
|
|
328
|
+
if (trackedMeta[filePath]) {
|
|
329
|
+
entry = { snapshotId: id, time: meta.time, ...trackedMeta[filePath], source: "tracked" }
|
|
330
|
+
} else if (untrackedMeta[filePath]) {
|
|
331
|
+
entry = { snapshotId: id, time: meta.time, ...untrackedMeta[filePath], source: "untracked" }
|
|
332
|
+
} else if ((meta.tracked ?? []).includes(filePath) || (meta.untracked ?? []).includes(filePath)) {
|
|
333
|
+
// Legacy snapshot (no per-file meta): fall back to stat-ing the copy
|
|
334
|
+
const src = join((meta.tracked ?? []).includes(filePath) ? join(root, id, "files") : join(root, id, "untracked"), filePath)
|
|
335
|
+
let size, sha
|
|
336
|
+
try {
|
|
337
|
+
const buf = await readFile(src)
|
|
338
|
+
size = buf.length
|
|
339
|
+
sha = createHash("sha256").update(buf).digest("hex").slice(0, 12)
|
|
340
|
+
} catch { continue }
|
|
341
|
+
entry = { snapshotId: id, time: meta.time, size, sha, source: (meta.tracked ?? []).includes(filePath) ? "tracked" : "untracked" }
|
|
342
|
+
}
|
|
343
|
+
if (entry) out.push(entry)
|
|
344
|
+
}
|
|
345
|
+
return out
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Restore a single file's content from a specific snapshot (per-file restore —
|
|
350
|
+
* other files are left untouched). Thin wrapper over rewind path mode with clearer intent.
|
|
351
|
+
* @returns {Promise<{path: string, type: "tracked"|"untracked", restored: boolean}>}
|
|
352
|
+
*/
|
|
353
|
+
export async function restoreFile(cwd, filePath, snapshotId) {
|
|
354
|
+
return rewind(cwd, snapshotId, { path: filePath })
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
// ---- view ----
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* View a file's content from a checkpoint (does not modify the working tree).
|
|
362
|
+
* v2: read the snapshot copy directly. Legacy snapshots fall back to the temporary-restore path.
|
|
363
|
+
* Returns the file content string.
|
|
364
|
+
*/
|
|
365
|
+
export async function catFile(cwd, id, filePath) {
|
|
366
|
+
const root = checkpointRoot(cwd)
|
|
367
|
+
const dir = join(root, id)
|
|
368
|
+
if (!existsSync(join(dir, "meta.json"))) throw new Error(`checkpoint ${id} not found`)
|
|
369
|
+
const meta = JSON.parse(await readFile(join(dir, "meta.json"), "utf8"))
|
|
370
|
+
const isV2 = meta.version === META_VERSION
|
|
371
|
+
const patchContent = await readFile(join(dir, "patch.diff"), "utf8")
|
|
372
|
+
|
|
373
|
+
const tracked = meta.tracked ?? []
|
|
374
|
+
const isTracked = tracked.includes(filePath) || extractFileHunks(patchContent, filePath) !== ""
|
|
375
|
+
const inUntracked = (meta.untracked ?? []).includes(filePath)
|
|
376
|
+
|
|
377
|
+
if (!isTracked && !inUntracked) {
|
|
378
|
+
throw new Error(`file "${filePath}" not in checkpoint ${id}`)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// v2: read the snapshot copy directly
|
|
382
|
+
const copy = join(inUntracked ? join(dir, "untracked") : join(dir, "files"), filePath)
|
|
383
|
+
if (isV2 && existsSync(copy)) {
|
|
384
|
+
return await readFile(copy, "utf8")
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Untracked legacy: read the copy if present
|
|
388
|
+
if (inUntracked && !isTracked) {
|
|
389
|
+
const src = join(dir, "untracked", filePath)
|
|
390
|
+
if (!existsSync(src)) throw new Error(`untracked file "${filePath}" copy missing in checkpoint`)
|
|
391
|
+
return await readFile(src, "utf8")
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Tracked legacy: temporarily restore → read → restore working tree
|
|
395
|
+
const abs = join(cwd, filePath)
|
|
396
|
+
const existed = existsSync(abs)
|
|
397
|
+
let saved = null
|
|
398
|
+
if (existed) saved = await readFile(abs, "utf8")
|
|
399
|
+
|
|
400
|
+
try {
|
|
401
|
+
git(cwd, ["checkout", "HEAD", "--", filePath])
|
|
402
|
+
const hunks = extractFileHunks(patchContent, filePath)
|
|
403
|
+
if (hunks) {
|
|
404
|
+
const tmpPatch = join(root, ".tmp_cat.patch")
|
|
405
|
+
await writeFile(tmpPatch, hunks, "utf8")
|
|
406
|
+
try {
|
|
407
|
+
git(cwd, ["apply", "--whitespace=nowarn", tmpPatch])
|
|
408
|
+
} finally {
|
|
409
|
+
await rm(tmpPatch, { force: true })
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return await readFile(abs, "utf8")
|
|
413
|
+
} finally {
|
|
414
|
+
// Restore original working tree state
|
|
415
|
+
if (existed) {
|
|
416
|
+
await writeFile(abs, saved, "utf8")
|
|
417
|
+
} else {
|
|
418
|
+
await rm(abs, { force: true })
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** F6: delete ALL checkpoints for a cwd — commit = new safety baseline. Best-effort:
|
|
424
|
+
* a missing dir is a no-op; deletion failures propagate so the git tool's commit case
|
|
425
|
+
* can report "(checkpoint cleanup skipped: …)" without blocking the commit result. */
|
|
426
|
+
export async function deleteCheckpointsForCwd(cwd) {
|
|
427
|
+
await rm(checkpointRoot(cwd), { recursive: true, force: true })
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** NF6: keep only the most recent `count` snapshots — oldest removed first (id sort =
|
|
431
|
+
* timestamp prefix, so ascending order IS oldest-first). Returns how many were deleted
|
|
432
|
+
* (0 when the cwd has no checkpoint dir). */
|
|
433
|
+
export async function deleteCheckpointsOlderThan(cwd, count) {
|
|
434
|
+
const root = checkpointRoot(cwd)
|
|
435
|
+
let ids
|
|
436
|
+
try { ids = (await readdir(root)).sort() } catch { return 0 }
|
|
437
|
+
let removed = 0
|
|
438
|
+
while (ids.length > count) {
|
|
439
|
+
await rm(join(root, ids.shift()), { recursive: true, force: true })
|
|
440
|
+
removed++
|
|
441
|
+
}
|
|
442
|
+
return removed
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Keep only the most recent MAX_CHECKPOINTS (NF6 cap — runs at the end of every create) */
|
|
446
|
+
async function pruneCheckpoints(cwd) {
|
|
447
|
+
await deleteCheckpointsOlderThan(cwd, MAX_CHECKPOINTS)
|
|
448
|
+
}
|
package/git/gitmem.mjs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gitmem.mjs — Team-layer memory git sync
|
|
3
|
+
* All through child_process calling system git, zero dependencies.
|
|
4
|
+
* Conflict strategy (decided): different entries are naturally conflict-free;
|
|
5
|
+
* on real conflicts abort rebase to keep the repo clean,
|
|
6
|
+
* report an error with manual resolution guidance — no auto-merge.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execFile } from "node:child_process"
|
|
10
|
+
import { existsSync } from "node:fs"
|
|
11
|
+
import { mkdir } from "node:fs/promises"
|
|
12
|
+
import { dirname, join } from "node:path"
|
|
13
|
+
import { promisify } from "node:util"
|
|
14
|
+
|
|
15
|
+
const execFileAsync = promisify(execFile)
|
|
16
|
+
|
|
17
|
+
/** Run git in dir, throw with stderr on failure */
|
|
18
|
+
async function git(dir, args) {
|
|
19
|
+
try {
|
|
20
|
+
const { stdout } = await execFileAsync("git", args, { cwd: dir, encoding: "utf8" })
|
|
21
|
+
return stdout.trim()
|
|
22
|
+
} catch (error) {
|
|
23
|
+
const detail = error.stderr?.trim() || error.message
|
|
24
|
+
const err = new Error(`git ${args.join(" ")} failed: ${detail}`)
|
|
25
|
+
err.gitError = true
|
|
26
|
+
err.stderr = detail
|
|
27
|
+
throw err
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Clone team repo if it doesn't exist. Returns whether a clone happened */
|
|
32
|
+
export async function ensureClone({ repo, dir }) {
|
|
33
|
+
if (existsSync(join(dir, ".git"))) return false
|
|
34
|
+
await mkdir(dirname(dir), { recursive: true })
|
|
35
|
+
await git(dirname(dir), ["clone", repo, dir])
|
|
36
|
+
return true
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Sync: pull --rebase. Skip if remote is still empty (first-time use).
|
|
41
|
+
* On conflict, abort rebase (keep repo clean) and throw a guided error.
|
|
42
|
+
* Returns true = pull succeeded (caller should then syncDir to rebuild index)
|
|
43
|
+
*/
|
|
44
|
+
export async function pullTeam(dir) {
|
|
45
|
+
// Empty remote repo: no branch to pull (ls-remote has no output)
|
|
46
|
+
const refs = await git(dir, ["ls-remote", "--heads", "origin"])
|
|
47
|
+
if (!refs) return false
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
await git(dir, ["pull", "--rebase"])
|
|
51
|
+
return true
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (await hasConflict(dir)) {
|
|
54
|
+
let abortFailed = false
|
|
55
|
+
try { await git(dir, ["rebase", "--abort"]) } catch {
|
|
56
|
+
abortFailed = true
|
|
57
|
+
}
|
|
58
|
+
const conflictError = new Error(
|
|
59
|
+
`Team memory sync conflict: local and remote modified the same entry.\n` +
|
|
60
|
+
`Please resolve manually in ${dir} with \`git pull\`, then re-run \`thincoder sync\`.\n` +
|
|
61
|
+
(abortFailed
|
|
62
|
+
? `(WARNING: git rebase --abort also failed — the repo may be in a conflicted state. ` +
|
|
63
|
+
`Run \`cd ${dir} && git rebase --abort\` manually to clean up.)`
|
|
64
|
+
: `(The local repo has been restored to its pre-sync state — nothing was lost.)`),
|
|
65
|
+
{ cause: error },
|
|
66
|
+
)
|
|
67
|
+
throw conflictError
|
|
68
|
+
}
|
|
69
|
+
throw error
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Commit and push an entry file. On push rejection (remote has new commits),
|
|
75
|
+
* pull --rebase then retry once; rebase conflicts also abort and error out.
|
|
76
|
+
*/
|
|
77
|
+
export async function commitAndPush(dir, filename, message) {
|
|
78
|
+
await git(dir, ["add", filename])
|
|
79
|
+
// Nothing to commit (content unchanged) — this is normal idempotent behavior, not an error
|
|
80
|
+
const dirty = await git(dir, ["status", "--porcelain", "--", filename])
|
|
81
|
+
if (dirty) await git(dir, ["commit", "-m", message])
|
|
82
|
+
try {
|
|
83
|
+
await git(dir, ["push"])
|
|
84
|
+
} catch {
|
|
85
|
+
await pullTeam(dir) // Conflicts will throw a guided error here
|
|
86
|
+
await git(dir, ["push"])
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Whether currently in a rebase conflict state (unmerged paths exist) */
|
|
91
|
+
async function hasConflict(dir) {
|
|
92
|
+
try {
|
|
93
|
+
const out = await git(dir, ["status", "--porcelain"])
|
|
94
|
+
// There are 7 unmerged states: DD AU UD UA DU AA UU — only checking UU/AA/DD misses 4 states with U,
|
|
95
|
+
// which means the rebase is left mid-conflict (violating the "keep repo clean" promise)
|
|
96
|
+
return out.split("\n").some((l) => l[0] === "U" || l[1] === "U" || l.startsWith("AA") || l.startsWith("DD"))
|
|
97
|
+
} catch {
|
|
98
|
+
return false
|
|
99
|
+
}
|
|
100
|
+
}
|