x7-code-line 0.1.0 → 0.2.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/README.md CHANGED
@@ -100,6 +100,7 @@ git diff --no-ext-diff --unified=0 --no-color
100
100
  - 变更类型:`add` 或 `delete`
101
101
  - 行号
102
102
  - 行内容
103
+ - 该文件当前的修改时间
103
104
 
104
105
  如果该 ID 不在 AI diff 缓存中,就写入普通 diff 缓存:
105
106
 
@@ -146,6 +147,15 @@ git diff --cached --no-ext-diff --unified=0 --no-color
146
147
  .x7-code-line/pending-commit.json
147
148
  ```
148
149
 
150
+ 计算完成后,会清除当前仓库在以下两份中心缓存中的条目:
151
+
152
+ ```text
153
+ .x7-code-line/ai-diff-line-ids.json
154
+ .x7-code-line/normal-diff-line-ids.json
155
+ ```
156
+
157
+ 其他项目的缓存不会被修改。
158
+
149
159
  `commit-msg` 会读取该结果,并追加或替换 trailer:
150
160
 
151
161
  ```text
@@ -160,23 +170,43 @@ x7-ai-lines: 0
160
170
 
161
171
  ## 多项目安装
162
172
 
163
- 通过 CLI 参数指定多个目标目录:
173
+ `install` 至少需要传入一个目录;0 目录场景会直接失败。
174
+
175
+ 默认要求传入绝对路径:
164
176
 
165
177
  ```sh
166
- npx --no-install x7-code-line install --dir ../repo-a --dir ../repo-b
178
+ npx --no-install x7-code-line install --dir /abs/path/repo-a --dir /abs/path/repo-b
167
179
  ```
168
180
 
169
- 也可以在安装时通过环境变量指定。
181
+ 例如,将 `x7-code-line` 安装在 C 盘目录,但指定 D 盘的 Git 项目:
170
182
 
171
- macOS / Linux 使用 `:` 作为目录分隔符:
183
+ ```powershell
184
+ cd C:\x7-tools\x7-code-line-host
185
+ npx --no-install x7-code-line install --dir D:\work\repo-a --dir D:\work\repo-b
186
+ ```
187
+
188
+ 如果确实需要解析相对路径,必须显式传 `--relative`:
172
189
 
173
190
  ```sh
174
- X7_CODE_LINE_DIRS="../repo-a:../repo-b" npm install x7-code-line
191
+ npx --no-install x7-code-line install --relative --dir ../repo-a --dir ../repo-b
192
+ ```
193
+
194
+ 此时相对路径基于当前执行 `install` 命令的目录解析。
195
+
196
+ 也可以在安装时通过环境变量指定多个目标目录。
197
+
198
+ 如果通过环境变量或配置文件传相对路径,需要同时设置 `X7_CODE_LINE_RELATIVE_PATHS=1`。
199
+
200
+ macOS / Linux 示例:
201
+
202
+ ```sh
203
+ X7_CODE_LINE_RELATIVE_PATHS=1 X7_CODE_LINE_DIRS="../repo-a:../repo-b" npm install x7-code-line
175
204
  ```
176
205
 
177
206
  Windows 使用 `;` 作为目录分隔符:
178
207
 
179
208
  ```powershell
209
+ $env:X7_CODE_LINE_RELATIVE_PATHS = "1"
180
210
  $env:X7_CODE_LINE_DIRS = "../repo-a;../repo-b"
181
211
  npm install x7-code-line
182
212
  ```
@@ -231,10 +261,40 @@ which npx
231
261
  $env:X7_CODE_LINE_DIRS = "D:\repo-a;D:\repo-b"
232
262
  ```
233
263
 
264
+ ## 增量添加目录
265
+
266
+ 在已经完成安装的当前安装目录下,可以使用 `addDir` 增量添加新的项目目录:
267
+
268
+ ```sh
269
+ x7-code-line addDir --dir /abs/path/repo-c
270
+ ```
271
+
272
+ 这个命令只做两件事:
273
+
274
+ - 把新目录追加到当前安装目录 `.x7-code-line/projects.json`
275
+ - 为新目录安装 `.git/hooks/pre-commit` 和 `.git/hooks/commit-msg`
276
+
277
+ 它不会重写当前安装目录的 `.cursor/hooks.json`、`.codex/hooks.json`,也不会重写已有项目集合。
278
+
279
+ `addDir` 会沿用与 `install` 相同的严格校验:
280
+
281
+ - 目录必须真实存在
282
+ - 目录必须可访问
283
+ - 目录必须包含可用的 `.git` 目录
284
+
285
+ 任一新增目录不满足条件时,命令直接失败,不会继续追加。
286
+
287
+ 默认只接受绝对路径;如果需要解析相对路径,需要显式传入:
288
+
289
+ ```sh
290
+ x7-code-line addDir --relative --dir ../repo-c
291
+ ```
292
+
234
293
  ## CLI 命令
235
294
 
236
295
  ```sh
237
- x7-code-line install [--config path] [--dir path ...]
296
+ x7-code-line install --dir <absolute-path> [--dir <absolute-path> ...] [--config path] [--relative]
297
+ x7-code-line addDir --dir <absolute-path> [--dir <absolute-path> ...] [--config path] [--relative]
238
298
  x7-code-line prompt-submit
239
299
  x7-code-line stop
240
300
  x7-code-line git-pre-commit
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "x7-code-line",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Install Cursor, Codex, and git hooks for x7 code line workflows.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/cache.js CHANGED
@@ -67,6 +67,19 @@ function addProjectId(cache, projectDir, id) {
67
67
  return false;
68
68
  }
69
69
 
70
+ function clearProjectIds(cache, projectDir) {
71
+ const key = path.resolve(projectDir);
72
+ if (!cache.projects || typeof cache.projects !== "object" || Array.isArray(cache.projects)) {
73
+ cache.projects = {};
74
+ return false;
75
+ }
76
+ if (Object.prototype.hasOwnProperty.call(cache.projects, key)) {
77
+ delete cache.projects[key];
78
+ return true;
79
+ }
80
+ return false;
81
+ }
82
+
70
83
  function ensureJson(filePath, defaultValue) {
71
84
  if (!fs.existsSync(filePath)) {
72
85
  writeJson(filePath, defaultValue);
@@ -92,6 +105,7 @@ module.exports = {
92
105
  PENDING_COMMIT_FILE,
93
106
  PROJECTS_CACHE_FILE,
94
107
  addProjectId,
108
+ clearProjectIds,
95
109
  ensureCacheFiles,
96
110
  getCacheDir,
97
111
  getProjectIds,
package/src/cli.js CHANGED
@@ -3,11 +3,13 @@
3
3
  const fs = require("node:fs");
4
4
  const path = require("node:path");
5
5
  const { handlePromptSubmit, handleStop } = require("./agent-hooks");
6
- const { install } = require("./install");
6
+ const { addDirs, install } = require("./install");
7
7
  const { appendCommitTrailer } = require("./git-trailer");
8
8
  const {
9
9
  AI_DIFF_CACHE_FILE,
10
+ NORMAL_DIFF_CACHE_FILE,
10
11
  PENDING_COMMIT_FILE,
12
+ clearProjectIds,
11
13
  getCacheDir,
12
14
  getProjectIds,
13
15
  readLineIdCache
@@ -20,6 +22,8 @@ async function runCli(argv) {
20
22
  switch (command) {
21
23
  case "install":
22
24
  return install(parseInstallOptions(rest));
25
+ case "addDir":
26
+ return addDirs(parseInstallOptions(rest));
23
27
  case "prompt-submit":
24
28
  case "cursor-before-submit-prompt":
25
29
  case "codex-user-prompt-submit":
@@ -44,13 +48,16 @@ async function runCli(argv) {
44
48
  function parseInstallOptions(args) {
45
49
  const options = {
46
50
  targetDirs: [],
47
- configPath: undefined
51
+ configPath: undefined,
52
+ relativePaths: false
48
53
  };
49
54
 
50
55
  for (let index = 0; index < args.length; index += 1) {
51
56
  const arg = args[index];
52
57
  if (arg === "--config" || arg === "-c") {
53
58
  options.configPath = requireValue(args, (index += 1), arg);
59
+ } else if (arg === "--relative") {
60
+ options.relativePaths = true;
54
61
  } else if (arg === "--dir" || arg === "-d") {
55
62
  options.targetDirs.push(requireValue(args, (index += 1), arg));
56
63
  } else if (arg === "--dirs") {
@@ -75,6 +82,7 @@ async function runGitPreCommit() {
75
82
  const baseDir = getBaseDir();
76
83
  const projectDir = process.cwd();
77
84
  const aiCache = readLineIdCache(baseDir, AI_DIFF_CACHE_FILE);
85
+ const normalCache = readLineIdCache(baseDir, NORMAL_DIFF_CACHE_FILE);
78
86
  const aiIds = getProjectIds(aiCache, projectDir);
79
87
  const stagedIds = getGitDiffLineIds(projectDir, { staged: true });
80
88
  const aiLineCount = stagedIds.filter((id) => aiIds.has(id)).length;
@@ -83,6 +91,10 @@ async function runGitPreCommit() {
83
91
  pendingPath,
84
92
  `${JSON.stringify({ projects: { [path.resolve(projectDir)]: { aiLineCount } } }, null, 2)}\n`
85
93
  );
94
+ clearProjectIds(aiCache, projectDir);
95
+ clearProjectIds(normalCache, projectDir);
96
+ writeCacheFile(baseDir, AI_DIFF_CACHE_FILE, aiCache);
97
+ writeCacheFile(baseDir, NORMAL_DIFF_CACHE_FILE, normalCache);
86
98
 
87
99
  if (process.env.X7_CODE_LINE_DEBUG === "1") {
88
100
  console.error(`[x7-code-line] git pre-commit ai lines: ${aiLineCount}`);
@@ -113,10 +125,16 @@ function readPendingAiLineCount(baseDir, projectDir) {
113
125
  }
114
126
  }
115
127
 
128
+ function writeCacheFile(baseDir, fileName, cache) {
129
+ const cacheDir = getCacheDir(baseDir);
130
+ fs.writeFileSync(path.join(cacheDir, fileName), `${JSON.stringify(cache, null, 2)}\n`);
131
+ }
132
+
116
133
  function printHelp() {
117
134
  const text = [
118
135
  "Usage:",
119
- " x7-code-line install [--config path] [--dir path ...]",
136
+ " x7-code-line install --dir <absolute-path> [--dir <absolute-path> ...] [--config path] [--relative]",
137
+ " x7-code-line addDir --dir <absolute-path> [--dir <absolute-path> ...] [--config path] [--relative]",
120
138
  " x7-code-line prompt-submit",
121
139
  " x7-code-line stop",
122
140
  " x7-code-line git-pre-commit",
package/src/diff-lines.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
 
3
3
  const crypto = require("node:crypto");
4
+ const fs = require("node:fs");
4
5
  const path = require("node:path");
5
6
  const { execFileSync } = require("node:child_process");
6
7
 
@@ -16,14 +17,15 @@ function getGitDiffLineIds(projectDir, options = {}) {
16
17
  stdio: ["ignore", "pipe", "ignore"]
17
18
  });
18
19
 
19
- return parseDiffLineIds(projectDir, diff);
20
+ return parseDiffLineIds(projectDir, diff, options);
20
21
  }
21
22
 
22
- function parseDiffLineIds(projectDir, diff) {
23
+ function parseDiffLineIds(projectDir, diff, options = {}) {
23
24
  const ids = [];
24
25
  let currentFile = "";
25
26
  let oldLine = 0;
26
27
  let newLine = 0;
28
+ const fileMtimes = new Map();
27
29
 
28
30
  for (const line of diff.split(/\r?\n/u)) {
29
31
  if (line.startsWith("+++ b/")) {
@@ -47,10 +49,21 @@ function parseDiffLineIds(projectDir, diff) {
47
49
  }
48
50
 
49
51
  if (line.startsWith("+") && !line.startsWith("+++")) {
50
- ids.push(makeLineId(projectDir, currentFile, "add", newLine, line.slice(1)));
52
+ ids.push(
53
+ makeLineId(projectDir, currentFile, "add", newLine, line.slice(1), getFileModifiedTime(projectDir, currentFile, fileMtimes))
54
+ );
51
55
  newLine += 1;
52
56
  } else if (line.startsWith("-") && !line.startsWith("---")) {
53
- ids.push(makeLineId(projectDir, currentFile, "delete", oldLine, line.slice(1)));
57
+ ids.push(
58
+ makeLineId(
59
+ projectDir,
60
+ currentFile,
61
+ "delete",
62
+ oldLine,
63
+ line.slice(1),
64
+ getFileModifiedTime(projectDir, currentFile, fileMtimes)
65
+ )
66
+ );
54
67
  oldLine += 1;
55
68
  } else if (line.startsWith(" ")) {
56
69
  oldLine += 1;
@@ -61,7 +74,7 @@ function parseDiffLineIds(projectDir, diff) {
61
74
  return ids;
62
75
  }
63
76
 
64
- function makeLineId(projectDir, filePath, changeType, lineNumber, content) {
77
+ function makeLineId(projectDir, filePath, changeType, lineNumber, content, modifiedTimeMs = 0) {
65
78
  return crypto
66
79
  .createHash("sha256")
67
80
  .update(
@@ -70,12 +83,31 @@ function makeLineId(projectDir, filePath, changeType, lineNumber, content) {
70
83
  file: filePath,
71
84
  changeType,
72
85
  lineNumber,
73
- content
86
+ content,
87
+ modifiedTimeMs
74
88
  })
75
89
  )
76
90
  .digest("hex");
77
91
  }
78
92
 
93
+ function getFileModifiedTime(projectDir, filePath, fileMtimes) {
94
+ const key = `${path.resolve(projectDir)}\0${filePath}`;
95
+ if (fileMtimes.has(key)) {
96
+ return fileMtimes.get(key);
97
+ }
98
+
99
+ const absolutePath = path.resolve(projectDir, filePath);
100
+ let modifiedTimeMs = 0;
101
+ try {
102
+ modifiedTimeMs = fs.statSync(absolutePath).mtimeMs;
103
+ } catch {
104
+ modifiedTimeMs = 0;
105
+ }
106
+
107
+ fileMtimes.set(key, modifiedTimeMs);
108
+ return modifiedTimeMs;
109
+ }
110
+
79
111
  module.exports = {
80
112
  getGitDiffLineIds,
81
113
  makeLineId,
package/src/install.js CHANGED
@@ -2,32 +2,63 @@
2
2
 
3
3
  const fs = require("node:fs");
4
4
  const path = require("node:path");
5
- const { CACHE_DIR_NAME, ensureCacheFiles, writeProjects } = require("./cache");
5
+ const { CACHE_DIR_NAME, ensureCacheFiles, readProjects, writeProjects } = require("./cache");
6
6
 
7
7
  const PACKAGE_ROOT = path.resolve(__dirname, "..");
8
8
 
9
9
  function install(options = {}) {
10
+ const pathBaseDir = path.resolve(options.pathBaseDir || process.env.INIT_CWD || process.cwd());
10
11
  const config = readConfig(options.configPath || process.env.X7_CODE_LINE_CONFIG);
11
- const targetDirs = normalizeTargetDirs(options.targetDirs, config);
12
- const cacheBaseDir = path.resolve(options.cacheBaseDir || process.env.INIT_CWD || process.cwd());
12
+ const targetDirs = normalizeTargetDirs(options.targetDirs, config, {
13
+ relativePaths: Boolean(options.relativePaths || process.env.X7_CODE_LINE_RELATIVE_PATHS === "1"),
14
+ pathBaseDir
15
+ });
16
+ const cacheBaseDir = path.resolve(options.cacheBaseDir || pathBaseDir);
17
+ const validatedTargets = validateTargets(targetDirs, config);
13
18
 
14
19
  installCurrentProjectFiles(cacheBaseDir);
15
- for (const targetDir of targetDirs) {
16
- installGitHooksInto(targetDir, config, cacheBaseDir);
20
+ for (const target of validatedTargets) {
21
+ installGitHooksInto(target, cacheBaseDir);
17
22
  }
18
- writeProjects(cacheBaseDir, targetDirs);
23
+ writeProjects(cacheBaseDir, validatedTargets.map((target) => target.targetDir));
19
24
 
20
- return targetDirs;
25
+ return validatedTargets.map((target) => target.targetDir);
21
26
  }
22
27
 
23
28
  function installFromPostinstall() {
24
29
  const envDirs = splitList(process.env.X7_CODE_LINE_DIRS);
25
- const targetDirs = envDirs.length > 0 ? envDirs : [process.env.INIT_CWD || process.cwd()];
26
30
 
27
31
  return install({
28
32
  configPath: process.env.X7_CODE_LINE_CONFIG,
29
- targetDirs
33
+ targetDirs: envDirs,
34
+ relativePaths: process.env.X7_CODE_LINE_RELATIVE_PATHS === "1"
35
+ });
36
+ }
37
+
38
+ function addDirs(options = {}) {
39
+ const explicitDirs = Array.isArray(options.targetDirs) ? options.targetDirs.map((dir) => String(dir || "").trim()).filter(Boolean) : [];
40
+ if (explicitDirs.length === 0) {
41
+ throw new Error("addDir requires at least one --dir value");
42
+ }
43
+
44
+ const pathBaseDir = path.resolve(options.pathBaseDir || process.env.INIT_CWD || process.cwd());
45
+ const config = readConfig(options.configPath || process.env.X7_CODE_LINE_CONFIG);
46
+ const targetDirs = normalizeTargetDirs(explicitDirs, config, {
47
+ relativePaths: Boolean(options.relativePaths || process.env.X7_CODE_LINE_RELATIVE_PATHS === "1"),
48
+ pathBaseDir
30
49
  });
50
+ const cacheBaseDir = path.resolve(options.cacheBaseDir || pathBaseDir);
51
+ assertInstalledBase(cacheBaseDir);
52
+
53
+ const validatedTargets = validateTargets(targetDirs, config);
54
+ for (const target of validatedTargets) {
55
+ installGitHooksInto(target, cacheBaseDir);
56
+ }
57
+
58
+ const mergedProjects = [...new Set([...readProjects(cacheBaseDir), ...validatedTargets.map((target) => target.targetDir)])];
59
+ writeProjects(cacheBaseDir, mergedProjects);
60
+
61
+ return validatedTargets.map((target) => target.targetDir);
31
62
  }
32
63
 
33
64
  function installCurrentProjectFiles(cacheBaseDir) {
@@ -52,11 +83,8 @@ function installCurrentProjectFiles(cacheBaseDir) {
52
83
  );
53
84
  }
54
85
 
55
- function installGitHooksInto(targetDir, config = {}, cacheBaseDir = process.cwd()) {
56
- const resolvedTarget = path.resolve(targetDir);
57
- ensureDirectory(resolvedTarget);
58
- const gitTargets = resolveGitTargets(resolvedTarget, config);
59
- for (const gitDir of gitTargets) {
86
+ function installGitHooksInto(target, cacheBaseDir = process.cwd()) {
87
+ for (const gitDir of target.gitDirs) {
60
88
  installGitHooks(gitDir, cacheBaseDir);
61
89
  }
62
90
  }
@@ -87,6 +115,63 @@ function resolveGitTargets(targetDir, config = {}) {
87
115
  });
88
116
  }
89
117
 
118
+ function validateTargets(targetDirs, config = {}) {
119
+ return targetDirs.map((targetDir) => validateTarget(targetDir, config));
120
+ }
121
+
122
+ function validateTarget(targetDir, config = {}) {
123
+ const resolvedTarget = path.resolve(targetDir);
124
+ assertReachableDirectory(resolvedTarget);
125
+ const gitDirs = resolveGitTargets(resolvedTarget, config);
126
+
127
+ if (gitDirs.length === 0) {
128
+ throw new Error(`Target directory must contain a reachable .git directory: ${resolvedTarget}`);
129
+ }
130
+
131
+ const configured = Array.isArray(config.gitDirs) ? config.gitDirs : [];
132
+ if (configured.length > 0) {
133
+ const missing = configured
134
+ .map((gitDir) => path.resolve(resolvedTarget, gitDir))
135
+ .filter((gitDir) => !gitDirs.includes(gitDir));
136
+ if (missing.length > 0) {
137
+ throw new Error(`Configured gitDirs are not reachable in target directory: ${missing.join(", ")}`);
138
+ }
139
+ }
140
+
141
+ return { targetDir: resolvedTarget, gitDirs };
142
+ }
143
+
144
+ function assertReachableDirectory(directory) {
145
+ let stat;
146
+ try {
147
+ stat = fs.statSync(directory);
148
+ } catch {
149
+ throw new Error(`Target directory does not exist or is not reachable: ${directory}`);
150
+ }
151
+
152
+ if (!stat.isDirectory()) {
153
+ throw new Error(`Target path is not a directory: ${directory}`);
154
+ }
155
+
156
+ try {
157
+ fs.accessSync(directory, fs.constants.R_OK);
158
+ } catch {
159
+ throw new Error(`Target directory is not readable: ${directory}`);
160
+ }
161
+ }
162
+
163
+ function assertInstalledBase(cacheBaseDir) {
164
+ const installFile = path.join(cacheBaseDir, CACHE_DIR_NAME, "install.json");
165
+ try {
166
+ const stat = fs.statSync(installFile);
167
+ if (!stat.isFile()) {
168
+ throw new Error("");
169
+ }
170
+ } catch {
171
+ throw new Error(`Current directory is not an installed x7-code-line base: ${cacheBaseDir}`);
172
+ }
173
+ }
174
+
90
175
  function readConfig(configPath) {
91
176
  if (!configPath) {
92
177
  return {};
@@ -103,15 +188,26 @@ function readConfig(configPath) {
103
188
  return config;
104
189
  }
105
190
 
106
- function normalizeTargetDirs(targetDirs = [], config = {}) {
191
+ function normalizeTargetDirs(targetDirs = [], config = {}, options = {}) {
107
192
  const configuredDirs = Array.isArray(config.dirs) ? config.dirs : [];
108
193
  const dirs = [...targetDirs, ...configuredDirs].map((dir) => String(dir || "").trim()).filter(Boolean);
109
194
 
110
195
  if (dirs.length === 0) {
111
- return [process.env.INIT_CWD || process.cwd()];
196
+ throw new Error("install requires at least one target directory");
112
197
  }
113
198
 
114
- return [...new Set(dirs)].map((dir) => path.resolve(dir));
199
+ const baseDir = path.resolve(options.pathBaseDir || process.env.INIT_CWD || process.cwd());
200
+ return [...new Set(dirs)].map((dir) => normalizeTargetDir(dir, options.relativePaths, baseDir));
201
+ }
202
+
203
+ function normalizeTargetDir(dir, relativePaths, baseDir) {
204
+ if (path.isAbsolute(dir)) {
205
+ return path.resolve(dir);
206
+ }
207
+ if (!relativePaths) {
208
+ throw new Error(`Target directory must be an absolute path unless --relative is used: ${dir}`);
209
+ }
210
+ return path.resolve(baseDir, dir);
115
211
  }
116
212
 
117
213
  function splitList(value) {
@@ -165,6 +261,7 @@ function ensureDirectory(directory) {
165
261
  }
166
262
 
167
263
  module.exports = {
264
+ addDirs,
168
265
  CACHE_DIR_NAME,
169
266
  install,
170
267
  installFromPostinstall