deel-local-cli 2.1.2 → 2.1.4
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/README.ko.md +16 -6
- package/README.md +17 -6
- package/bin/deel.js +15 -1
- package/package.json +1 -1
- package/src/acp/serve.js +4 -0
- package/src/agent/loop.js +38 -2
- package/src/agent/route.js +67 -1
- package/src/agent/session.js +13 -1
- package/src/backend/adapter.js +26 -4
- package/src/backend/loosejson.js +109 -0
- package/src/backend/mcp.js +586 -41
- package/src/backend/probe.js +17 -3
- package/src/backend/textcalls.js +196 -0
- package/src/commands.js +26 -37
- package/src/completion.js +2 -0
- package/src/exit.js +3 -1
- package/src/i18n/en.js +5 -0
- package/src/i18n/ja.js +5 -0
- package/src/i18n/ko.js +5 -0
- package/src/i18n/zh.js +5 -0
- package/src/login.js +268 -0
- package/src/lsp/diag.js +2 -1
- package/src/oneshot.js +143 -5
- package/src/preview/serve.js +2 -31
- package/src/providers/custom.js +1 -1
- package/src/providers/index.js +2 -1
- package/src/providers/openrouter.js +62 -0
- package/src/repl.js +13 -1
- package/src/runlog.js +99 -0
- package/src/safety/authcmd.js +68 -17
- package/src/safety/gitsnap.js +286 -0
- package/src/safety/undo.js +30 -1
- package/src/safety/which.js +37 -0
- package/src/setup.js +62 -6
- package/src/slash.js +103 -0
- package/src/tools/importgraph.js +107 -0
- package/src/tools/index.js +24 -1
- package/src/tools/outline.js +21 -3
- package/src/tools/verify.js +103 -45
- package/src/ui/browser.js +81 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* OpenRouter — 열쇠 하나로 여러 회사 모델 (Claude · GPT · Grok · Kimi …) (2.1.4).
|
|
3
|
+
*
|
|
4
|
+
* 규격은 OpenAI 호환이라 여기도 데이터 한 장이다. 다른 제공자와 다른 칸은 하나 —
|
|
5
|
+
* **`링크로그인`** 이다.
|
|
6
|
+
*
|
|
7
|
+
* ── 열쇠를 안 넣고 계정으로 받는다 ─────────────────────────────────────
|
|
8
|
+
*
|
|
9
|
+
* 「API 키 말고, 링크 타고 로그인하면 바로 쓰게」 가 사람들이 오래 바란 것이다. Claude·Codex 의
|
|
10
|
+
* 구독 로그인을 가져다 쓰는 길은 **안 간다** — 그 로그인은 그 회사 도구용이고, 남의 도구가 쓰면
|
|
11
|
+
* 약관에 걸리거나 어느 날 막힌다. 그러면 사람은 이유도 모르고 못 쓰게 된다.
|
|
12
|
+
*
|
|
13
|
+
* OpenRouter 는 제 계정으로 **열쇠를 내주는 로그인**(OAuth PKCE)을 공식으로 연다
|
|
14
|
+
* (https://openrouter.ai/docs/use-cases/oauth-pkce). 브라우저에서 로그인하면 그 계정에 열쇠가
|
|
15
|
+
* 하나 생기고, 우리는 그것을 받아 사람이 붙여 넣은 열쇠와 똑같이 잠가 둔다. 쓴 만큼 그 계정에서
|
|
16
|
+
* 빠진다. 받은 열쇠는 https://openrouter.ai/keys 에 「deel」 이름으로 보이고 거기서 지울 수 있다.
|
|
17
|
+
*
|
|
18
|
+
* 로그인 흐름은 src/login.js 가 한다. 여기에는 **주소만** 적는다 — 이 칸이 있는 제공자는
|
|
19
|
+
* 누구든 같은 흐름을 탄다. 코드 분기가 아니라 데이터다(providers/index.js 머리말).
|
|
20
|
+
*/
|
|
21
|
+
export const 제공자 = {
|
|
22
|
+
id: 'openrouter',
|
|
23
|
+
이름: 'OpenRouter (Claude · GPT · Grok · Kimi …)',
|
|
24
|
+
한줄: '열쇠 하나로 여러 회사 모델. 브라우저로 로그인하면 열쇠를 안 넣어도 됩니다.',
|
|
25
|
+
|
|
26
|
+
규격: 'openai',
|
|
27
|
+
인증: 'bearer',
|
|
28
|
+
주소들: () => ['https://openrouter.ai/api/v1'],
|
|
29
|
+
리전들: null,
|
|
30
|
+
|
|
31
|
+
규격됐나: true,
|
|
32
|
+
빈칸: ['열쇠 또는 로그인'],
|
|
33
|
+
열쇠받는곳: 'https://openrouter.ai/keys',
|
|
34
|
+
|
|
35
|
+
/*
|
|
36
|
+
* `sk-or-v1-…`. openai 의 `sk-` 보다 길어서 이긴다(index.js 의 어디것일까) — 짧은 쪽이 먼저
|
|
37
|
+
* 집으면 OpenRouter 열쇠가 api.openai.com 으로 간다.
|
|
38
|
+
*/
|
|
39
|
+
키앞머리: [/^sk-or-v1-/, /^sk-or-/],
|
|
40
|
+
|
|
41
|
+
// 로그인 창구. 1단계는 브라우저가 열고, 2단계(코드 → 열쇠)는 우리가 한 번 POST 한다.
|
|
42
|
+
링크로그인: {
|
|
43
|
+
여는곳: 'https://openrouter.ai/auth',
|
|
44
|
+
바꾸는곳: 'https://openrouter.ai/api/v1/auth/keys',
|
|
45
|
+
열쇠이름: 'deel',
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
// 단가는 모델마다 다르고 자주 바뀐다. 아는 척하지 않는다(openai.js 의 요금 머리말).
|
|
49
|
+
요금: {},
|
|
50
|
+
요금기준: null,
|
|
51
|
+
요금표주소: 'https://openrouter.ai/models',
|
|
52
|
+
|
|
53
|
+
오류읽기({ status, 서버말 }) {
|
|
54
|
+
if (status === 402) {
|
|
55
|
+
return '이 계정에 크레딧이 없습니다 — https://openrouter.ai/settings/credits 에서 채우거나, 이름이 :free 로 끝나는 모델을 고르세요.';
|
|
56
|
+
}
|
|
57
|
+
if (status === 404 && /no endpoints|not a valid model|model/i.test(서버말)) {
|
|
58
|
+
return '그 모델을 지금 받아 주는 곳이 없습니다 — 이름이 틀렸거나 내려간 모델입니다. https://openrouter.ai/models 에서 보세요.';
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
},
|
|
62
|
+
};
|
package/src/repl.js
CHANGED
|
@@ -22,7 +22,7 @@ import { handle, COMMANDS, 미리보기끄기, 적용하기 as 그림적용 } fr
|
|
|
22
22
|
import { next as nextWork, get as getWork, canWrite, 보일이름, 보일한줄 } from './agent/modes.js';
|
|
23
23
|
import { route } from './agent/route.js';
|
|
24
24
|
import { run } from './agent/loop.js';
|
|
25
|
-
import { Session, repairToolPairs } from './agent/session.js';
|
|
25
|
+
import { Session, repairToolPairs, 생성속도 } from './agent/session.js';
|
|
26
26
|
import { makeScope } from './safety/guard.js';
|
|
27
27
|
import { 언어서버있나 } from './tools/index.js';
|
|
28
28
|
import { 모두끄기 as 언어서버다끄기 } from './lsp/client.js';
|
|
@@ -2224,6 +2224,8 @@ export async function chatLoop(opts = {}) {
|
|
|
2224
2224
|
// 캐시가 걸린 턴일수록 실제보다 비싸게 뜬다.
|
|
2225
2225
|
cacheRead: session.usage.cacheRead ?? 0,
|
|
2226
2226
|
cacheWrite: session.usage.cacheWrite ?? 0,
|
|
2227
|
+
genMs: session.usage.genMs ?? 0,
|
|
2228
|
+
genOut: session.usage.genOut ?? 0,
|
|
2227
2229
|
};
|
|
2228
2230
|
|
|
2229
2231
|
/*
|
|
@@ -3042,6 +3044,13 @@ export async function chatLoop(opts = {}) {
|
|
|
3042
3044
|
}
|
|
3043
3045
|
break;
|
|
3044
3046
|
|
|
3047
|
+
// 글로 적은 도구 부름을 건졌다 (backend/textcalls.js) — 흘러 나온 표 글 뒤에 무엇을 했는지 한 줄.
|
|
3048
|
+
case 'textcalls':
|
|
3049
|
+
clearThinking();
|
|
3050
|
+
if (streamed) { 답비우기(); say(''); streamed = false; }
|
|
3051
|
+
say(` ${c.gray(`↳ ${옮긴말('ev.textCalls', { n: ev.count, 이름: ev.names.join(', ') })}`)}`);
|
|
3052
|
+
break;
|
|
3053
|
+
|
|
3045
3054
|
case 'nudge':
|
|
3046
3055
|
clearThinking();
|
|
3047
3056
|
if (streamed) { 답비우기(); say(''); streamed = false; }
|
|
@@ -3216,6 +3225,9 @@ export async function chatLoop(opts = {}) {
|
|
|
3216
3225
|
const d읽음 = (session.usage.cacheRead ?? 0) - before.cacheRead;
|
|
3217
3226
|
const d씀 = (session.usage.cacheWrite ?? 0) - before.cacheWrite;
|
|
3218
3227
|
if (dIn || dOut) bits.push(`↑${dIn.toLocaleString()} ↓${dOut.toLocaleString()}`);
|
|
3228
|
+
// 이번 턴의 생성 속도 — 흘려받은 부름만 잰다(session.js 의 생성속도). 못 쟀으면 안 적는다.
|
|
3229
|
+
const 이번속도 = 생성속도({ genMs: (session.usage.genMs ?? 0) - before.genMs, genOut: (session.usage.genOut ?? 0) - before.genOut });
|
|
3230
|
+
if (이번속도 !== null) bits.push(`${이번속도} tok/s`);
|
|
3219
3231
|
/*
|
|
3220
3232
|
* ── ↑ 안을 가른다 ────────────────────────────────────────────────────
|
|
3221
3233
|
*
|
package/src/runlog.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// `deel run` 이 밖으로 내는 두 가지 — 바뀐 파일 목록과 사건 흐름 (2.1.3).
|
|
2
|
+
//
|
|
3
|
+
// oneshot.js 에서 따로 둔다. 거기는 이미 천 줄이 넘고, 이 둘은 「루프가 내는 사건을 받아 밖에 적는다」
|
|
4
|
+
// 는 한 가지 일만 한다 — 루프의 속을 몰라도 되고, 그래서 따로 잴 수 있다.
|
|
5
|
+
|
|
6
|
+
import { openSync, writeSync, closeSync } from 'node:fs';
|
|
7
|
+
import { resolve, relative, isAbsolute, sep } from 'node:path';
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* ── 바뀐 파일 ─────────────────────────────────────────────────────────
|
|
11
|
+
*
|
|
12
|
+
* 루프는 턴이 끝날 때 손댄 파일을 `done` · `stuck` · `limit` 에 실어 준다(agent/loop.js 의 마무리).
|
|
13
|
+
* 그런데 끊기거나(aborted) 터진(error) 턴은 그 목록 없이 끝난다 — 스크립트가 제일 알고 싶은 것이
|
|
14
|
+
* 「반쯤 하다 멈췄는데 무엇을 건드렸나」 인데. 그래서 도구 사건마다 루프와 같은 세 칸을 보고 모은다.
|
|
15
|
+
* 끝 사건이 준 목록도 합친다 — 하위 작업이 바꾼 것은 거기에만 온다.
|
|
16
|
+
*
|
|
17
|
+
* 작업 폴더 기준 경로로, 슬래시로 적는다. 스크립트는 `jq -r '.files[]' | xargs git add` 처럼 쓴다 —
|
|
18
|
+
* 윈도우 역슬래시나 절대 경로가 섞이면 그 한 줄이 운영체제마다 달라진다. 폴더 밖은 절대 경로 그대로 둔다.
|
|
19
|
+
*/
|
|
20
|
+
export function 바뀐파일모음(root) {
|
|
21
|
+
const 모음 = new Set();
|
|
22
|
+
const 담기 = (p) => {
|
|
23
|
+
if (typeof p !== 'string' || !p) return;
|
|
24
|
+
const 온경로 = resolve(root, p);
|
|
25
|
+
const 상대 = relative(root, 온경로);
|
|
26
|
+
const 밖인가 = !상대 || 상대.startsWith('..') || isAbsolute(상대);
|
|
27
|
+
모음.add((밖인가 ? 온경로 : 상대).split(sep).join('/'));
|
|
28
|
+
};
|
|
29
|
+
return {
|
|
30
|
+
사건(ev) {
|
|
31
|
+
if (!ev || typeof ev !== 'object') return;
|
|
32
|
+
if (ev.type === 'tool' && ev.result && !ev.result.error) {
|
|
33
|
+
const r = ev.result;
|
|
34
|
+
담기(r.changed);
|
|
35
|
+
for (const f of r.여럿 ?? []) if (f?.ok) 담기(f.path);
|
|
36
|
+
for (const f of r.바뀐것들 ?? []) 담기(f);
|
|
37
|
+
}
|
|
38
|
+
for (const f of Array.isArray(ev.files) ? ev.files : []) 담기(f?.path);
|
|
39
|
+
},
|
|
40
|
+
목록: () => [...모음].sort(),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/*
|
|
45
|
+
* ── 사건 흐름 (--events <파일>) ───────────────────────────────────────
|
|
46
|
+
*
|
|
47
|
+
* 끝날 때 한 덩이만 나오면 오래 도는 잡을 지켜볼 수가 없다. 사건을 한 줄에 하나씩 JSON 으로 흘린다 —
|
|
48
|
+
* `tail -f` 로 보고, 끝나면 `jq` 로 훑는다. 마지막 줄은 `--json` 과 같은 결과 한 덩이다(type: 'result').
|
|
49
|
+
*
|
|
50
|
+
* 글자 조각(content · thinking)은 안 싣는다. 흘려받는 서버면 한 턴에 수천 줄이 되고, 답은 마지막 줄에
|
|
51
|
+
* 이미 통째로 있다. 긴 글은 잘라 싣는다 — 큰 파일을 읽은 도구 결과 하나가 한 줄에 통째로 실리면
|
|
52
|
+
* 사건 파일이 읽은 파일들의 사본이 된다.
|
|
53
|
+
*
|
|
54
|
+
* `type` 칸은 약속이다. 나머지 칸은 판마다 늘 수 있다 — 루프가 화면에 주는 것을 그대로 옮긴다.
|
|
55
|
+
*/
|
|
56
|
+
export const 사건글한도 = 4000;
|
|
57
|
+
const 안싣는것 = new Set(['content', 'thinking']);
|
|
58
|
+
|
|
59
|
+
export function 사건줄(ev, 흐른ms, { 통째 = false } = {}) {
|
|
60
|
+
const 자르기 = (_k, v) => (typeof v === 'string' && v.length > 사건글한도
|
|
61
|
+
? `${v.slice(0, 사건글한도)}…(+${v.length - 사건글한도})`
|
|
62
|
+
: v);
|
|
63
|
+
try {
|
|
64
|
+
return JSON.stringify({ ...ev, elapsed: 흐른ms }, 통째 ? undefined : 자르기);
|
|
65
|
+
} catch {
|
|
66
|
+
// 제자리를 도는 값 같은 것. 무엇이 있었는지는 남긴다 — 통째로 빼면 그 자리가 비어 보인다.
|
|
67
|
+
return JSON.stringify({ type: String(ev?.type ?? '?'), elapsed: 흐른ms, 못적음: true });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 사건 파일을 연다. 못 열면 던진다 — 부르는 쪽이 일을 시작하기 **전에** 선다.
|
|
73
|
+
*
|
|
74
|
+
* 다 돌고 나서 「사건 파일을 못 열었습니다」 면 지켜보려던 사람은 아무것도 못 봤다. 도중에 못 쓰게
|
|
75
|
+
* 되면(디스크 가득) 한 번 알리고 그만 적는다 — 기록을 남기려다 일을 죽이면 본말이 뒤집힌다.
|
|
76
|
+
*/
|
|
77
|
+
export function 사건적기열기(경로, { 시작 = Date.now(), 알림 = () => {} } = {}) {
|
|
78
|
+
const fd = openSync(경로, 'w');
|
|
79
|
+
let 막힘 = false;
|
|
80
|
+
let 닫힘 = false;
|
|
81
|
+
const 한줄 = (글) => {
|
|
82
|
+
if (막힘 || 닫힘) return;
|
|
83
|
+
try { writeSync(fd, `${글}\n`); }
|
|
84
|
+
catch (err) { 막힘 = true; 알림(err?.code ?? err?.message ?? String(err)); }
|
|
85
|
+
};
|
|
86
|
+
return {
|
|
87
|
+
적기(ev) {
|
|
88
|
+
if (!ev || 안싣는것.has(ev.type)) return;
|
|
89
|
+
한줄(사건줄(ev, Date.now() - 시작));
|
|
90
|
+
},
|
|
91
|
+
// 결과 줄은 자르지 않는다 — 답이 거기에 통째로 있다는 것이 위 약속이다(--json 과 같은 덩이).
|
|
92
|
+
결과(r) { 한줄(사건줄({ type: 'result', ...r }, Date.now() - 시작, { 통째: true })); },
|
|
93
|
+
닫기() {
|
|
94
|
+
if (닫힘) return;
|
|
95
|
+
닫힘 = true;
|
|
96
|
+
try { closeSync(fd); } catch { /* 이미 닫혔으면 그만 */ }
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
package/src/safety/authcmd.js
CHANGED
|
@@ -69,14 +69,29 @@ let 마지막명령줄 = null;
|
|
|
69
69
|
*/
|
|
70
70
|
export function 마지막명령() { return 마지막명령줄; }
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
/*
|
|
73
|
+
* ── 받아 둔 것과 승인은 **명령마다** 따로 든다 (2.1.3) ───────────────────────
|
|
74
|
+
*
|
|
75
|
+
* 둘 다 모듈에 하나뿐이었다. 한 판에 연결이 하나면 그래도 맞다 — 그런데 `/consult` 와 모델을 고른
|
|
76
|
+
* Task 는 **다른 프로필의 연결**을 연다(agent/models.js). 그 연결이 제 명령으로 열쇠를 청하면 들고
|
|
77
|
+
* 있던 앞 프로필의 토큰이 먼저 돌아와, A 게이트웨이의 토큰이 B 게이트웨이로 나갔다. B 의 명령은
|
|
78
|
+
* 묻는 자리에 닿지도 않았고, 닿았어도 A 를 허락한 답이 B 의 답으로 읽혔다.
|
|
79
|
+
*
|
|
80
|
+
* 열쇠는 명령줄로 가른다 — 같은 명령이면 같은 열쇠다(받기설정 이 정책·설정 어느 쪽이든 명령을 준다).
|
|
81
|
+
*/
|
|
82
|
+
const 받은것들 = new Map(); // 명령 → { token, headers, 만료, 언제 } — 메모리에만. 파일로 안 나간다.
|
|
83
|
+
const 물어본것들 = new Map(); // 명령 → 이번 판에 사람이 뭐라고 답했나 (true/false)
|
|
84
|
+
let 마지막받은 = null; // 화면이 연결을 안 줄 때 보여 줄 것
|
|
74
85
|
|
|
75
86
|
/** 검사와 `/model` 갈아타기가 부른다. */
|
|
76
|
-
export function 잊기() {
|
|
87
|
+
export function 잊기() { 받은것들.clear(); 물어본것들.clear(); 마지막받은 = null; 마지막명령줄 = null; }
|
|
77
88
|
|
|
78
|
-
/**
|
|
79
|
-
|
|
89
|
+
/**
|
|
90
|
+
* 지금 들고 있는 것. 없으면 null. 화면·심사서가 **토큰 없이** 상태만 볼 때.
|
|
91
|
+
* @param {object} [설정] 받기설정() 이 준 것 — 주면 그 명령의 것을, 안 주면 마지막에 받은 것을.
|
|
92
|
+
*/
|
|
93
|
+
export function 지금상태(설정 = null) {
|
|
94
|
+
const 받은것 = 설정 ? 받은것들.get(설정.명령) : 마지막받은;
|
|
80
95
|
if (!받은것) return null;
|
|
81
96
|
return { 있음: true, 만료: 받은것.만료, 남은초: Math.max(0, Math.round((받은것.만료 - Date.now()) / 1000)) };
|
|
82
97
|
}
|
|
@@ -251,16 +266,45 @@ export async function 한번받기(설정, { 기다림 = 기본기다림, signal
|
|
|
251
266
|
// 잘못된 것이고, 열어 두면 그 자리에서 영영 멈춘다.
|
|
252
267
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
253
268
|
windowsHide: true,
|
|
269
|
+
// 유닉스는 셸을 새 무리의 머리로 띄운다 — 끊을 때 무리째 거두게(거두기). 윈도우에서 detached 는 새 콘솔
|
|
270
|
+
// 창이라 안 쓴다(그쪽은 taskkill /T 가 나무를 따라간다). tools/index.js 의 Bash 와 같은 짝이다.
|
|
271
|
+
detached: process.platform !== 'win32',
|
|
254
272
|
});
|
|
255
273
|
} catch (err) {
|
|
256
274
|
return done({ ok: false, 왜: `명령을 못 띄웠습니다 (${err.message})`, 보인것: '' });
|
|
257
275
|
}
|
|
258
276
|
const 마치기 = (것) => { if (!끝났나) { 끝났나 = true; clearTimeout(시계); 끊기해제(); done(것); } };
|
|
277
|
+
/*
|
|
278
|
+
* ── 끊을 때는 **나무째** 거두고 우리 파이프를 놓는다 (2.1.3) ────────────────
|
|
279
|
+
*
|
|
280
|
+
* 명령은 셸을 거쳐 뜬다(tools/shell.js 의 셸명령). kill 은 셸만 죽이고, 윈도우에서는 그 밑의 진짜
|
|
281
|
+
* 로그인 도구(손자)가 살아 stdout 을 물고 있었다. 그러면 「못 받았습니다」 뒤에도 그 파이프가 deel 을
|
|
282
|
+
* 붙들어, 할 일을 다 마친 `deel run` 이 손자가 끝날 때까지 안 끝났다. 윈도우는 셸이 살아 있을 때
|
|
283
|
+
* taskkill /T 로 나무를 따라가야 하므로 kill 은 그 뒤에 한다(backend/mcp.js 닫기 와 같은 차례).
|
|
284
|
+
* 유닉스도 같았다 — 셸만 죽고 손자가 남았다(리눅스 CI 가 잡음). 무리(-pid)째 끊는다.
|
|
285
|
+
*/
|
|
286
|
+
const 거두기 = () => {
|
|
287
|
+
const 죽이기 = () => {
|
|
288
|
+
if (process.platform !== 'win32' && kid.pid) {
|
|
289
|
+
try { process.kill(-kid.pid, 'SIGKILL'); return; } catch { /* 무리가 없다 — 셸만 */ }
|
|
290
|
+
}
|
|
291
|
+
try { kid.kill('SIGKILL'); } catch { /* 이미 죽었으면 그만 */ }
|
|
292
|
+
};
|
|
293
|
+
if (process.platform === 'win32' && kid.pid && kid.exitCode === null) {
|
|
294
|
+
try {
|
|
295
|
+
const 나무 = spawn('taskkill', ['/pid', String(kid.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
|
|
296
|
+
나무.once('error', 죽이기);
|
|
297
|
+
나무.once('exit', 죽이기);
|
|
298
|
+
나무.unref();
|
|
299
|
+
} catch { 죽이기(); }
|
|
300
|
+
} else 죽이기();
|
|
301
|
+
try { kid.stdout?.destroy(); kid.stderr?.destroy(); } catch { /* 이미 닫힘 */ }
|
|
302
|
+
};
|
|
259
303
|
const 시계 = setTimeout(() => {
|
|
260
|
-
|
|
304
|
+
거두기();
|
|
261
305
|
마치기({ ok: false, 왜: `${Math.round(기다림 / 1000)}초를 기다렸는데 안 끝났습니다`, 보인것: '' });
|
|
262
306
|
}, 기다림);
|
|
263
|
-
const 끊기 = () => {
|
|
307
|
+
const 끊기 = () => { 거두기(); 마치기({ ok: false, 왜: '중단했습니다', 보인것: '' }); };
|
|
264
308
|
const 끊기해제 = () => signal?.removeEventListener?.('abort', 끊기);
|
|
265
309
|
if (signal?.aborted) return 끊기();
|
|
266
310
|
signal?.addEventListener?.('abort', 끊기, { once: true });
|
|
@@ -280,8 +324,8 @@ export async function 한번받기(설정, { 기다림 = 기본기다림, signal
|
|
|
280
324
|
kid.stdout.on('data', (b) => {
|
|
281
325
|
if (끝났나) return;
|
|
282
326
|
if (나온것.length + b.length > 받을상한) {
|
|
283
|
-
|
|
284
|
-
|
|
327
|
+
// 나무째 거두고 파이프를 놓는다 (위 거두기) — 셸 아래 손자가 계속 쓰고 있어도 EPIPE 를 맞고 멈춘다.
|
|
328
|
+
거두기();
|
|
285
329
|
// 먼저 자르고 가린다 — 1MB 짜리 한 줄을 통째로 가리기 에 넣으면 그 무늬들이
|
|
286
330
|
// 몇 분을 돈다(처음 고칠 때 검사가 여기서 멈춰 섰다).
|
|
287
331
|
const 첫줄 = 가리기(나온것.slice(0, 400).trim().split('\n')[0] ?? '', {}).글.slice(0, 120);
|
|
@@ -324,7 +368,8 @@ export async function 한번받기(설정, { 기다림 = 기본기다림, signal
|
|
|
324
368
|
export async function 열쇠(설정, { 다시 = false, 물어보기 = null, signal = null, 알림 = null, 기다림 = 기본기다림 } = {}) {
|
|
325
369
|
if (!설정) return { ok: false, 왜: '열쇠받기가 설정되어 있지 않습니다' };
|
|
326
370
|
|
|
327
|
-
if (다시)
|
|
371
|
+
if (다시) 받은것들.delete(설정.명령);
|
|
372
|
+
const 받은것 = 받은것들.get(설정.명령);
|
|
328
373
|
if (받은것 && 받은것.만료 - 미리 > Date.now()) {
|
|
329
374
|
return { ok: true, token: 받은것.token, headers: 받은것.headers, 만료: 받은것.만료, 그대로: true };
|
|
330
375
|
}
|
|
@@ -356,19 +401,22 @@ export async function 열쇠(설정, { 다시 = false, 물어보기 = null, sign
|
|
|
356
401
|
* 거기서까지 막으면 사람이 승인을 했는데도 열쇠가 안 붙는다.
|
|
357
402
|
*/
|
|
358
403
|
if (설정.곳 !== '정책') {
|
|
359
|
-
|
|
360
|
-
if (
|
|
404
|
+
const 답 = 물어본것들.get(설정.명령);
|
|
405
|
+
if (답 === false) return { ok: false, 왜: '이 판에서는 안 부르기로 했습니다' };
|
|
406
|
+
if (답 === undefined) {
|
|
361
407
|
if (typeof 물어보기 !== 'function') {
|
|
362
408
|
return { ok: false, 왜: '이 명령을 띄워도 되는지 물어볼 자리가 없습니다 — 승인 없이는 안 띄웁니다' };
|
|
363
409
|
}
|
|
364
|
-
|
|
365
|
-
|
|
410
|
+
const 허락 = !!(await 물어보기(설정));
|
|
411
|
+
물어본것들.set(설정.명령, 허락);
|
|
412
|
+
if (!허락) return { ok: false, 왜: '이 판에서는 안 부르기로 했습니다' };
|
|
366
413
|
}
|
|
367
414
|
}
|
|
368
415
|
|
|
369
416
|
const r = await 한번받기(설정, { signal, 알림, 기다림 });
|
|
370
417
|
if (!r.ok) return r;
|
|
371
|
-
|
|
418
|
+
마지막받은 = { token: r.token, headers: r.headers, 만료: r.만료, 언제: Date.now() };
|
|
419
|
+
받은것들.set(설정.명령, 마지막받은);
|
|
372
420
|
return { ok: true, token: r.token, headers: r.headers, 만료: r.만료, ms: r.ms, 그대로: false };
|
|
373
421
|
}
|
|
374
422
|
|
|
@@ -380,7 +428,10 @@ export async function 열쇠(설정, { 다시 = false, 물어보기 = null, sign
|
|
|
380
428
|
*/
|
|
381
429
|
export function 가림(글) {
|
|
382
430
|
const 것들 = [];
|
|
383
|
-
|
|
384
|
-
for (const
|
|
431
|
+
// 들고 있는 것 **전부**를 가린다 — 명령마다 따로 들게 된 뒤로(위 받은것들) 하나만 가리면 다른 연결의 토큰이 샌다.
|
|
432
|
+
for (const 받은것 of 받은것들.values()) {
|
|
433
|
+
if (받은것?.token) 것들.push(받은것.token);
|
|
434
|
+
for (const v of Object.values(받은것?.headers ?? {})) if (v) 것들.push(String(v));
|
|
435
|
+
}
|
|
385
436
|
return 가리기(String(글 ?? ''), { 열쇠들: 것들 }).글;
|
|
386
437
|
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 셸이 바꾼 파일을 git 으로 알아내 되돌리기에 올린다 (2.1.3).
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 이게 필요한가 ───────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* Bash 의 되돌리기는 **명령줄에 적힌 이름**을 보고 떴다(tools/index.js 의 바꾸기전스냅샷). 셸이 푸는
|
|
7
|
+
* 와일드카드(`rm *.tmp`)와 스크립트 안에서 고치는 것(`npm run format` · `node 고치기.js` ·
|
|
8
|
+
* `npx prettier --write .`)은 이름이 명령줄에 없어 떠 둔 것이 없었고, /undo 뒤에도 그대로였다.
|
|
9
|
+
* 모델이 파일을 제일 많이 바꾸는 길이 바로 그 둘이다.
|
|
10
|
+
*
|
|
11
|
+
* ── 어떻게 ────────────────────────────────────────────────────────────
|
|
12
|
+
*
|
|
13
|
+
* 명령 **앞**에 `git status` 로 지금 고치던 것 · 안 올린 것을 보고, 그 파일들만 사본을 떠 둔다(보통 몇 개다).
|
|
14
|
+
* 명령 **뒤**에 한 번 더 보고 견준다.
|
|
15
|
+
*
|
|
16
|
+
* 앞에 깨끗했던 파일이 바뀌거나 사라졌다 → 앞 모습은 명령 전 HEAD 에 있다 (git 이 들고 있다). git 이 작업 폴더에
|
|
17
|
+
* 꺼내 줄 모습으로 받는다 — `git restore` 와 같다(줄 끝 변환까지 · 헤드꺼내기)
|
|
18
|
+
* 앞에 고치던 · 안 올린 파일이 바뀌었다 → 앞 모습은 떠 둔 사본
|
|
19
|
+
* 앞에 없던 파일이 생겼다 → 「원래 없던 자리」 — 되돌리면 지운다
|
|
20
|
+
*
|
|
21
|
+
* 그렇게 안 것을 되돌리기 이력(safety/undo.js)에 **여느 기록과 같은 모양으로** 적는다. 그러면 /undo 가
|
|
22
|
+
* 밖을 가리키는 기록 · 링크 너머 · 바이너리를 다루는 규칙을 그대로 탄다 — 되돌리는 길을 새로 만들지 않는다.
|
|
23
|
+
*
|
|
24
|
+
* ── 안 하는 것 ────────────────────────────────────────────────────────
|
|
25
|
+
*
|
|
26
|
+
* · .git 에 아무것도 안 쓴다. `git add` 로 나무를 뜨는 길도 있었지만, 그러면 명령마다 남의 저장소에
|
|
27
|
+
* 객체를 쌓는다(큰 파일이면 크게). 읽기만 하는 status · cat-file 만 부르고, status 도
|
|
28
|
+
* `--no-optional-locks` 로 색인을 안 고친다.
|
|
29
|
+
* · 무시되는 파일(.gitignore)은 못 본다 — git 이 안 보는 자리다. 못 본 것을 되돌린다고 말하지 않는다.
|
|
30
|
+
* · git 의 색인(스테이징)과 커밋은 안 되돌린다. 되돌리는 것은 작업 폴더의 파일이다.
|
|
31
|
+
* · 저장소가 아니면 아무것도 안 한다(null). 없는 안전망을 있다고 하지 않는다.
|
|
32
|
+
*/
|
|
33
|
+
import { spawnSync } from 'node:child_process';
|
|
34
|
+
import { randomBytes } from 'node:crypto';
|
|
35
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
36
|
+
import { join, resolve } from 'node:path';
|
|
37
|
+
import { 경로에서찾기 } from './which.js';
|
|
38
|
+
|
|
39
|
+
// 명령 전에 사본을 떠 둘 파일의 상한. 넘친 것은 「못 떴다」 로 말한다.
|
|
40
|
+
const 사본수상한 = 400;
|
|
41
|
+
const 사본한개상한 = 4 * 1024 * 1024;
|
|
42
|
+
const 사본합상한 = 32 * 1024 * 1024;
|
|
43
|
+
// git 이 이보다 오래 걸리는 저장소면 이 판에서는 그만둔다 — 명령마다 사람이 기다린다.
|
|
44
|
+
const 느림문턱 = 3000;
|
|
45
|
+
// 앞 모습을 꺼내는 데는 더 기다린다 — 명령은 이미 돌았고, 여기서 끊기면 그 파일들을 되돌릴 수 없다.
|
|
46
|
+
const 꺼내기문턱 = 10000;
|
|
47
|
+
// 수정 시각이 이만큼 안쪽이면 시각을 못 믿는다 — FAT 은 2초 단위라, 한 틈에 두 번 쓰면 크기 · 시각이 같다.
|
|
48
|
+
const 흐릿한틈 = 3000;
|
|
49
|
+
|
|
50
|
+
/** 이 뿌리에서는 그만뒀다 (느리다). 프로세스가 사는 동안 기억한다. */
|
|
51
|
+
const 그만둔곳 = new Set();
|
|
52
|
+
let 깃자리 = undefined;
|
|
53
|
+
|
|
54
|
+
function 깃찾기() {
|
|
55
|
+
// 작업 폴더 안의 git.exe 를 집지 않게 우리가 PATH 에서 찾는다 (which.js) — 절대 경로 칸만 본다.
|
|
56
|
+
if (깃자리 === undefined) 깃자리 = 경로에서찾기(['git']);
|
|
57
|
+
return 깃자리;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function 깃(뿌리, 인자, { 바이트 = false, 입력 = undefined, 제한 = 느림문턱 } = {}) {
|
|
61
|
+
const 파일 = 깃찾기();
|
|
62
|
+
if (!파일) return null;
|
|
63
|
+
const r = spawnSync(파일, ['--no-optional-locks', '-c', 'core.quotepath=false', ...인자], {
|
|
64
|
+
cwd: 뿌리, windowsHide: true, timeout: 제한, maxBuffer: 256 * 1024 * 1024,
|
|
65
|
+
// 입력은 Buffer 로 — 글로 주면 encoding 을 입력의 인코딩으로도 써서 'buffer' 에서 던진다.
|
|
66
|
+
encoding: 바이트 ? 'buffer' : 'utf8', input: 입력 === undefined ? undefined : Buffer.from(입력, 'utf8'),
|
|
67
|
+
// 사람이 켜 둔 페이저 · 비밀번호 묻기가 끼지 않게.
|
|
68
|
+
env: { ...process.env, GIT_PAGER: 'cat', GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0' },
|
|
69
|
+
});
|
|
70
|
+
if (r.error || r.status !== 0) return { ok: false, 느림: r.error?.code === 'ETIMEDOUT' || r.signal === 'SIGTERM' };
|
|
71
|
+
return { ok: true, out: r.stdout };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* `git status --porcelain=v1 -z` 를 읽는다. 열쇠는 저장소 꼭대기 기준 경로(슬래시), 값은 두 글자 상태.
|
|
76
|
+
* 이름 바꾸기(R · C)는 옛 이름도 같이 올린다 — 옛 자리는 사라진 파일이다.
|
|
77
|
+
*/
|
|
78
|
+
function 상태읽기(뿌리) {
|
|
79
|
+
const r = 깃(뿌리, ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--ignore-submodules=all', '--', '.']);
|
|
80
|
+
if (!r?.ok) return r?.느림 ? { 느림: true } : null;
|
|
81
|
+
const 표 = new Map();
|
|
82
|
+
const 칸 = String(r.out).split('\0');
|
|
83
|
+
for (let i = 0; i < 칸.length; i++) {
|
|
84
|
+
const 줄 = 칸[i];
|
|
85
|
+
if (줄.length < 4) continue;
|
|
86
|
+
const 상태 = 줄.slice(0, 2);
|
|
87
|
+
표.set(줄.slice(3), 상태);
|
|
88
|
+
if (상태[0] === 'R' || 상태[0] === 'C') {
|
|
89
|
+
const 옛 = 칸[++i];
|
|
90
|
+
if (옛 && 상태[0] === 'R') 표.set(옛, ' D');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { 표 };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/*
|
|
97
|
+
* 저장소 꼭대기 기준 경로를 **작업 폴더 기준**으로. 밖이거나 deel 제 살림(.deel)이면 null.
|
|
98
|
+
*
|
|
99
|
+
* 절대 경로끼리 견주지 않는다 — 윈도우에서 git 이 주는 꼭대기(긴 이름 · 슬래시)와 우리 뿌리(짧은 이름 · 대소문자)가
|
|
100
|
+
* 글자로 달라, 안의 파일이 전부 밖으로 읽힐 수 있다. git 이 준 앞머리(`--show-prefix`)를 떼고 뿌리에 붙인다.
|
|
101
|
+
*/
|
|
102
|
+
const 살림꼴 = /(^|\/)\.deel(\/|$)/i;
|
|
103
|
+
function 맡을경로(앞머리, 경로) {
|
|
104
|
+
if (!경로.startsWith(앞머리)) return null;
|
|
105
|
+
const rel = 경로.slice(앞머리.length);
|
|
106
|
+
if (!rel || 살림꼴.test(rel)) return null;
|
|
107
|
+
return rel;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function 모양(abs) {
|
|
111
|
+
try { const s = statSync(abs); return s.isFile() ? { 크기: s.size, 시각: s.mtimeMs } : { 폴더: true }; } catch { return null; }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/*
|
|
115
|
+
* 명령 전 HEAD 에 있던 모습을 **한꺼번에** 꺼낸다. 돌려주는 표: 경로 → Buffer(있던 모습) · null(HEAD 에 없던 것).
|
|
116
|
+
* 표에 없는 경로는 못 꺼낸 것이다 — 「없던 것」 과 다르다. 없던 것으로 적으면 /undo 가 멀쩡한 파일을 지운다.
|
|
117
|
+
* HEAD 부터 못 읽었으면 null.
|
|
118
|
+
*
|
|
119
|
+
* 파일마다 `git cat-file` 을 띄우면 포매터가 200 파일을 고친 뒤 윈도우에서 8초를 기다렸다. 두 번만 띄운다 —
|
|
120
|
+
* `--batch-check` 로 있나 없나를, `--batch --filters` 로 내용을. `--filters` 는 작업 폴더에 꺼낼 때의 변환
|
|
121
|
+
* (core.autocrlf 의 CRLF · .gitattributes)을 태운다. 안 태우면 윈도우용 git 기본값(autocrlf=true)에서
|
|
122
|
+
* /undo 가 CRLF 파일을 LF 로 되돌렸다.
|
|
123
|
+
*
|
|
124
|
+
* `--filters` 의 머리줄 크기는 변환 **전** 크기라 믿을 수 없다. 그래서 물음마다 뒤에 없는 개체 하나(아무도 못
|
|
125
|
+
* 맞출 제비 번호)를 물어, 그 「missing」 줄을 경계로 쓴다.
|
|
126
|
+
*/
|
|
127
|
+
function 헤드꺼내기(뿌리, 머리, 경로들) {
|
|
128
|
+
// 줄바꿈이 든 이름은 한 줄 물음에 못 싣는다 — 그 자리는 표에 없다(못 꺼냄).
|
|
129
|
+
const 실을것 = 경로들.filter((p) => !/[\r\n]/.test(p));
|
|
130
|
+
const 확인 = 깃(뿌리, ['cat-file', '--batch-check'], { 입력: `${[머리, ...실을것.map((p) => `${머리}:${p}`)].join('\n')}\n`, 제한: 꺼내기문턱 });
|
|
131
|
+
if (!확인?.ok) return null;
|
|
132
|
+
const 줄들 = String(확인.out).split('\n');
|
|
133
|
+
if (!/^[0-9a-f]+ commit \d+$/.test(줄들[0] ?? '')) return null;
|
|
134
|
+
const 표 = new Map();
|
|
135
|
+
const 꺼낼것 = [];
|
|
136
|
+
실을것.forEach((p, i) => {
|
|
137
|
+
const 줄 = 줄들[i + 1] ?? '';
|
|
138
|
+
if (줄.endsWith(' missing')) 표.set(p, null); // HEAD 에 없다 — 명령이 새로 만든 것
|
|
139
|
+
else { const m = /^([0-9a-f]+) blob \d+$/.exec(줄); if (m) 꺼낼것.push([p, m[1]]); }
|
|
140
|
+
});
|
|
141
|
+
if (!꺼낼것.length) return 표;
|
|
142
|
+
const 제비 = randomBytes(20).toString('hex');
|
|
143
|
+
const 내용 = 깃(뿌리, ['cat-file', '--batch', '--filters'], {
|
|
144
|
+
바이트: true, 제한: 꺼내기문턱, 입력: 꺼낼것.map(([p, oid]) => `${oid} ${p}\n${제비} x\n`).join(''),
|
|
145
|
+
});
|
|
146
|
+
if (!내용?.ok) return 표;
|
|
147
|
+
const 몸 = 내용.out;
|
|
148
|
+
const 경계 = Buffer.from(`\n${제비} missing\n`);
|
|
149
|
+
let i = 0;
|
|
150
|
+
for (const [p, oid] of 꺼낼것) {
|
|
151
|
+
const 머리끝 = 몸.indexOf(0x0a, i);
|
|
152
|
+
if (머리끝 < 0 || !몸.subarray(i, 머리끝).toString('latin1').startsWith(`${oid} blob `)) break;
|
|
153
|
+
const 끝 = 몸.indexOf(경계, 머리끝 + 1);
|
|
154
|
+
if (끝 < 0) break;
|
|
155
|
+
표.set(p, Buffer.from(몸.subarray(머리끝 + 1, 끝)));
|
|
156
|
+
i = 끝 + 경계.length;
|
|
157
|
+
}
|
|
158
|
+
return 표;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 명령 **앞** 모습을 뜬다.
|
|
163
|
+
*
|
|
164
|
+
* @returns {null | {뿌리, 앞머리, 머리, 표: Map, 사본: Map}}
|
|
165
|
+
* null — 저장소가 아니거나, git 이 없거나, 이 저장소에서는 그만뒀다(느려서)
|
|
166
|
+
*/
|
|
167
|
+
export function 깃앞보기(뿌리) {
|
|
168
|
+
try {
|
|
169
|
+
뿌리 = resolve(String(뿌리));
|
|
170
|
+
if (그만둔곳.has(뿌리) || !깃찾기()) return null;
|
|
171
|
+
const 시작 = Date.now();
|
|
172
|
+
const 뜬때 = 시작;
|
|
173
|
+
// 앞머리와 HEAD 를 한 번에 묻는다 — 명령마다 도는 자리라 git 을 한 번 덜 띄운다. 커밋이 아직 없으면 HEAD 가
|
|
174
|
+
// 없어 실패하니, 그때만 앞머리를 따로 묻는다.
|
|
175
|
+
let 앞머리;
|
|
176
|
+
let 머리 = null;
|
|
177
|
+
const 둘 = 깃(뿌리, ['rev-parse', '--show-prefix', 'HEAD']);
|
|
178
|
+
if (둘?.ok) {
|
|
179
|
+
const 줄들 = String(둘.out).split('\n');
|
|
180
|
+
앞머리 = 줄들[0].trim();
|
|
181
|
+
머리 = (줄들[1] ?? '').trim() || null;
|
|
182
|
+
} else {
|
|
183
|
+
const 꼭 = 깃(뿌리, ['rev-parse', '--show-prefix']);
|
|
184
|
+
if (!꼭?.ok) return null;
|
|
185
|
+
앞머리 = String(꼭.out).trim();
|
|
186
|
+
}
|
|
187
|
+
const 상태 = 상태읽기(뿌리);
|
|
188
|
+
if (!상태 || 상태.느림 || Date.now() - 시작 > 느림문턱) {
|
|
189
|
+
if (상태?.느림 || Date.now() - 시작 > 느림문턱) 그만둔곳.add(뿌리);
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 고치던 것 · 안 올린 것의 사본. 이 파일들의 앞 모습은 git 에 없다.
|
|
194
|
+
const 사본 = new Map();
|
|
195
|
+
let 합 = 0;
|
|
196
|
+
for (const [경로] of 상태.표) {
|
|
197
|
+
const rel = 맡을경로(앞머리, 경로);
|
|
198
|
+
if (rel === null) continue;
|
|
199
|
+
const 꼴 = 모양(join(뿌리, rel));
|
|
200
|
+
if (!꼴) { 사본.set(경로, { 없음: true }); continue; }
|
|
201
|
+
if (꼴.폴더) continue;
|
|
202
|
+
if (사본.size >= 사본수상한 || 꼴.크기 > 사본한개상한 || 합 + 꼴.크기 > 사본합상한) {
|
|
203
|
+
사본.set(경로, { ...꼴, 못뜸: '사본 상한' });
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
const buf = readFileSync(join(뿌리, rel));
|
|
208
|
+
합 += buf.length;
|
|
209
|
+
사본.set(경로, { ...꼴, buf });
|
|
210
|
+
} catch (err) { 사본.set(경로, { ...꼴, 못뜸: err?.code ?? '못 읽음' }); }
|
|
211
|
+
}
|
|
212
|
+
return { 뿌리, 앞머리, 머리, 표: 상태.표, 사본, 뜬때 };
|
|
213
|
+
} catch {
|
|
214
|
+
return null; // 뜨다 터져서 명령이 못 도는 일은 없어야 한다
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* 명령 **뒤**에 견주고, 바뀐 것을 되돌리기 이력에 적는다.
|
|
220
|
+
*
|
|
221
|
+
* 이번 턴에 이미 뜬 파일은 안 적는다(History.떴나) — 그 기록이 턴 처음 모습이다. 낱말 보고 뜬 것과 겹쳐도 한 번이다.
|
|
222
|
+
*
|
|
223
|
+
* @returns {{뜬것: string[], 못뜬것: {abs: string, 왜: string}[]}} 뜬것 은 절대 경로
|
|
224
|
+
*/
|
|
225
|
+
export function 깃뒤적기(앞, { history } = {}) {
|
|
226
|
+
const 뜬것 = [];
|
|
227
|
+
const 못뜬것 = [];
|
|
228
|
+
if (!앞 || typeof history?.뒤늦은기록 !== 'function') return { 뜬것, 못뜬것 };
|
|
229
|
+
try {
|
|
230
|
+
const 뒤 = 상태읽기(앞.뿌리);
|
|
231
|
+
if (!뒤 || 뒤.느림) return { 뜬것, 못뜬것 };
|
|
232
|
+
const 할것 = []; // [경로, abs, 지금, 앞모습] — 앞모습 이 '헤드' 면 아래에서 한꺼번에 꺼낸다
|
|
233
|
+
for (const 경로 of new Set([...앞.표.keys(), ...뒤.표.keys()])) {
|
|
234
|
+
const rel = 맡을경로(앞.앞머리, 경로);
|
|
235
|
+
if (rel === null) continue;
|
|
236
|
+
const abs = join(앞.뿌리, rel);
|
|
237
|
+
// 이번 턴에 이미 뜬 자리는 History.뒤늦은기록 이 거른다 — 그 기록이 턴 처음 모습이다.
|
|
238
|
+
const 지금 = 모양(abs);
|
|
239
|
+
if (지금?.폴더) continue;
|
|
240
|
+
const 떠둔것 = 앞.사본.get(경로);
|
|
241
|
+
|
|
242
|
+
let 앞모습; // Buffer · null(없던 자리) · undefined(안 바뀜) · false(바뀌었는데 앞 모습이 없다)
|
|
243
|
+
if (앞.표.has(경로)) {
|
|
244
|
+
// 명령 전부터 고치던 · 안 올린 · 지워 둔 파일. 떠 둔 것과 지금이 같으면 안 바뀐 것이다.
|
|
245
|
+
if (!떠둔것) continue; // 폴더였거나 안 맡는 자리
|
|
246
|
+
// 크기 · 시각이 같아도 그 시각이 뜬 때와 가까우면 믿지 않는다(흐릿한틈) — 한 틈에 두 번 쓴 것일 수 있다.
|
|
247
|
+
const 시각믿음 = 떠둔것.시각 < (앞.뜬때 ?? 0) - 흐릿한틈;
|
|
248
|
+
if (떠둔것.없음) 앞모습 = 지금 ? null : undefined;
|
|
249
|
+
else if (!지금) 앞모습 = 떠둔것.buf ?? false;
|
|
250
|
+
else if (지금.크기 === 떠둔것.크기 && 지금.시각 === 떠둔것.시각 && (시각믿음 || !떠둔것.buf)) 앞모습 = undefined;
|
|
251
|
+
else if (떠둔것.buf) {
|
|
252
|
+
let 지금것 = null;
|
|
253
|
+
try { 지금것 = readFileSync(abs); } catch { /* 못 읽으면 바뀐 것으로 본다 */ }
|
|
254
|
+
앞모습 = 지금것 && 지금것.equals(떠둔것.buf) ? undefined : 떠둔것.buf;
|
|
255
|
+
} else 앞모습 = false;
|
|
256
|
+
if (앞모습 === false) { 못뜬것.push({ abs, 왜: 떠둔것.못뜸 ?? '사본 없음' }); continue; }
|
|
257
|
+
} else {
|
|
258
|
+
// 명령 전에 깨끗했던 자리 — 앞 모습은 명령 전 HEAD 에 있다. 상태 글자(??)로 「새 파일」 을 가르지 않는다 —
|
|
259
|
+
// `git rm --cached` 로 추적만 뗀 파일도 ?? 로 나온다. HEAD 에 물어 가른다. 커밋이 아직 없으면 없던 자리다.
|
|
260
|
+
앞모습 = 앞.머리 ? '헤드' : null;
|
|
261
|
+
}
|
|
262
|
+
if (앞모습 === undefined) continue;
|
|
263
|
+
할것.push([경로, abs, 지금, 앞모습]);
|
|
264
|
+
}
|
|
265
|
+
const 헤드볼것 = 할것.filter((x) => x[3] === '헤드').map((x) => x[0]);
|
|
266
|
+
// 꺼내다 터져도 떠 둔 사본으로 적을 것은 적는다 — 꺼내지 못한 것만 「못 떴다」 로.
|
|
267
|
+
let 헤드 = null;
|
|
268
|
+
try { if (헤드볼것.length) 헤드 = 헤드꺼내기(앞.뿌리, 앞.머리, 헤드볼것); } catch { 헤드 = null; }
|
|
269
|
+
for (const [경로, abs, 지금, 앞것] of 할것) {
|
|
270
|
+
let 앞모습 = 앞것;
|
|
271
|
+
if (앞모습 === '헤드') {
|
|
272
|
+
앞모습 = 헤드?.get(경로);
|
|
273
|
+
if (앞모습 === undefined) { 못뜬것.push({ abs, 왜: 'git 에서 앞 모습을 못 꺼냈습니다' }); continue; }
|
|
274
|
+
}
|
|
275
|
+
if (앞모습 === null && !지금) continue; // 생겼다 사라졌다 — 되돌릴 것이 없다
|
|
276
|
+
const rec = history.뒤늦은기록(abs, 앞모습, 'Bash');
|
|
277
|
+
if (!rec) continue;
|
|
278
|
+
if (rec.skipped) 못뜬것.push({ abs, 왜: rec.skipped });
|
|
279
|
+
else 뜬것.push(abs);
|
|
280
|
+
}
|
|
281
|
+
} catch { /* 적다 터져도 명령은 이미 돌았다 — 적은 데까지만 말한다 */ }
|
|
282
|
+
return { 뜬것, 못뜬것 };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** 검사가 원래대로 돌려놓을 때. */
|
|
286
|
+
export function 깃뜨기잊기() { 그만둔곳.clear(); 깃자리 = undefined; }
|