wize-dev-kit 0.26.1 → 0.27.2

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.
Files changed (69) hide show
  1. package/ARCH.md +2 -1
  2. package/CHANGELOG.md +64 -0
  3. package/README.es.md +28 -6
  4. package/README.md +28 -6
  5. package/README.pt-BR.md +28 -6
  6. package/adapters/continue/render.js +2 -3
  7. package/adapters/copilot/render.js +3 -3
  8. package/adapters/cursor/render.js +2 -3
  9. package/adapters/generic/render.js +2 -3
  10. package/adapters/opencode/render.js +3 -4
  11. package/adapters/windsurf/render.js +2 -3
  12. package/docs/WORKTREES.md +150 -0
  13. package/docs/WORKTREES.pt-BR.md +150 -0
  14. package/docs/harnesses/antigravity.md +2 -0
  15. package/docs/harnesses/antigravity.pt-BR.md +2 -0
  16. package/docs/harnesses/claude-code.md +2 -0
  17. package/docs/harnesses/claude-code.pt-BR.md +2 -0
  18. package/docs/harnesses/codex.md +3 -1
  19. package/docs/harnesses/codex.pt-BR.md +3 -1
  20. package/docs/harnesses/continue.md +2 -0
  21. package/docs/harnesses/continue.pt-BR.md +2 -0
  22. package/docs/harnesses/copilot.md +2 -0
  23. package/docs/harnesses/copilot.pt-BR.md +2 -0
  24. package/docs/harnesses/cursor.md +2 -0
  25. package/docs/harnesses/cursor.pt-BR.md +2 -0
  26. package/docs/harnesses/generic.md +2 -0
  27. package/docs/harnesses/generic.pt-BR.md +2 -0
  28. package/docs/harnesses/hermes.md +2 -0
  29. package/docs/harnesses/hermes.pt-BR.md +2 -0
  30. package/docs/harnesses/kimi-code.md +2 -0
  31. package/docs/harnesses/kimi-code.pt-BR.md +2 -0
  32. package/docs/harnesses/kiro.md +2 -0
  33. package/docs/harnesses/kiro.pt-BR.md +2 -0
  34. package/docs/harnesses/opencode.md +2 -0
  35. package/docs/harnesses/opencode.pt-BR.md +2 -0
  36. package/docs/harnesses/windsurf.md +2 -0
  37. package/docs/harnesses/windsurf.pt-BR.md +2 -0
  38. package/package.json +1 -1
  39. package/src/orchestrator-skills/module.yaml +1 -1
  40. package/src/orchestrator-skills/wize/skill.md +17 -7
  41. package/src/orchestrator-skills/wize-help/skill.md +20 -7
  42. package/src/orchestrator-skills/wize-update/skill.md +33 -27
  43. package/tools/installer/commands/doctor.js +186 -40
  44. package/tools/installer/commands/document-project.js +13 -3
  45. package/tools/installer/commands/hooks.js +215 -0
  46. package/tools/installer/commands/hydrate.js +298 -0
  47. package/tools/installer/commands/sync.js +50 -23
  48. package/tools/installer/commands/update.js +596 -120
  49. package/tools/installer/commands/version-check.js +69 -10
  50. package/tools/installer/document-project/modes/full-rescan.js +1 -1
  51. package/tools/installer/document-project/modes/initial-scan.js +5 -4
  52. package/tools/installer/document-project/modes/quick.js +11 -4
  53. package/tools/installer/git-context.js +219 -0
  54. package/tools/installer/git-ops.js +211 -0
  55. package/tools/installer/install-plan.js +136 -0
  56. package/tools/installer/marker.js +67 -0
  57. package/tools/installer/project-root.js +68 -0
  58. package/tools/installer/project-status.js +171 -0
  59. package/tools/installer/render-shared.js +170 -4
  60. package/tools/installer/render-targets.js +112 -0
  61. package/tools/installer/semver.js +59 -0
  62. package/tools/installer/setup-helpers.js +89 -31
  63. package/tools/installer/templates/hydrate.sh +163 -0
  64. package/tools/installer/templates/wize-kit-update.yml +38 -0
  65. package/tools/installer/toml.js +133 -0
  66. package/tools/installer/util.js +16 -0
  67. package/tools/installer/version-check.js +167 -37
  68. package/tools/installer/wize-cli.js +216 -78
  69. package/tools/installer/sync.js +0 -29
@@ -6,33 +6,92 @@
6
6
  // by a human, or by an AI agent shelling out from a skill instruction where
7
7
  // stdout is never a TTY — so it always answers, in `--json` when a caller
8
8
  // needs to parse it.
9
+ //
10
+ // The answer carries the project's state and one `action` with the exact
11
+ // command to run, so a caller never compares versions itself.
9
12
 
10
13
  'use strict';
11
14
 
12
- const { getVersionCheckResult } = require('../version-check.js');
15
+ const path = require('node:path');
16
+ const { getVersionCheckDetailed } = require('../version-check.js');
17
+ const { getProjectStatus, ACTION_EXIT_CODES } = require('../project-status.js');
18
+
19
+ const DEFAULT_KIT_ROOT = path.resolve(__dirname, '..', '..', '..');
20
+
21
+ const ACTION_TEXT = {
22
+ hydrate: (s) => `This checkout is not hydrated (${s.reason}): run \`${s.commands.hydrate}\``,
23
+ 'use-pin': (s) => `Project pinned ${s.pin} is newer than this kit (${s.installed}): run \`${s.commands.hydrate}\``,
24
+ install: (s) => `No .wize/config/project.toml here: run \`${s.commands.install}\``,
25
+ update: (s) => `Project pinned ${s.pin || 'none'} (${s.reason}): run \`${s.commands.update}\``
26
+ };
27
+
28
+ const KIT_NOT_ON_BRANCH_TEXT = (s) => `The kit is installed on ${s.kit_at}'s branch but not on this one (kit-not-on-branch): merge the kit into this branch, or pass --here to install here (\`${s.commands.install} --here\`)`;
13
29
 
14
- async function cmdVersionCheck({ currentVersion, json = false, log = console.log } = {}) {
15
- const result = await getVersionCheckResult(currentVersion);
30
+ async function cmdVersionCheck({
31
+ currentVersion,
32
+ kitRoot = DEFAULT_KIT_ROOT,
33
+ projectRoot = null,
34
+ cwd = process.cwd(),
35
+ json = false,
36
+ refresh = false,
37
+ quiet = false,
38
+ exitCode = false,
39
+ log = console.log
40
+ } = {}) {
41
+ const { published_versions: published, ...base } = await getVersionCheckDetailed(currentVersion, { skipCache: refresh });
42
+
43
+ // The background refresher: fetch, write the cache, say nothing.
44
+ if (refresh && quiet) return { ...base, exitCode: 0 };
45
+
46
+ const status = getProjectStatus({ kitRoot, cwd: projectRoot || cwd, latest: base.latest, latestSource: base.latest_source, published });
47
+ const hasProject = !!status.root;
48
+ const result = {
49
+ ...base,
50
+ schema: 2,
51
+ project: hasProject ? {
52
+ root: status.root,
53
+ pin: status.pin,
54
+ pin_status: status.pin_status,
55
+ hydrated: status.hydrated,
56
+ missing_targets: status.missing_targets,
57
+ marker_version: status.marker_version,
58
+ user_toml: status.user_toml
59
+ } : null,
60
+ git: status.git,
61
+ ...(status.kit_at ? { kit_at: status.kit_at } : {}),
62
+ skew: status.skew,
63
+ action: status.action,
64
+ reason: status.reason,
65
+ commands: status.commands
66
+ };
67
+ result.exitCode = exitCode ? (ACTION_EXIT_CODES[status.action] ?? 0) : 0;
16
68
 
17
69
  if (json) {
18
- log(JSON.stringify(result));
70
+ const { exitCode: _omit, ...printable } = result;
71
+ log(JSON.stringify(printable));
19
72
  return result;
20
73
  }
21
74
 
22
75
  if (result.disabled) {
23
76
  log('Update check disabled (WIZE_DISABLE_UPDATE_CHECK=1).');
24
- return result;
25
- }
26
- if (!result.latest) {
77
+ } else if (!result.latest) {
27
78
  log(`wize-dev-kit ${result.installed} (could not reach the registry — offline or timed out).`);
28
- return result;
29
- }
30
- if (result.updateAvailable) {
79
+ } else if (result.updateAvailable) {
31
80
  log(`↑ Update available: wize-dev-kit ${result.installed} → ${result.latest}`);
32
81
  log(' Run `npx wize-dev-kit@latest update` in your project to refresh adapters — or `/wize-update` from inside your AI harness.');
33
82
  } else {
34
83
  log(`wize-dev-kit ${result.installed} is up to date.`);
35
84
  }
85
+
86
+ if (status.reason === 'kit-not-on-branch') {
87
+ log(`→ ${KIT_NOT_ON_BRANCH_TEXT(status)}`);
88
+ return result;
89
+ }
90
+
91
+ const covered = status.action === 'update' && result.updateAvailable;
92
+ if (status.action !== 'none' && !covered && ACTION_TEXT[status.action]) {
93
+ log(`→ ${ACTION_TEXT[status.action](status)}`);
94
+ }
36
95
  return result;
37
96
  }
38
97
 
@@ -9,7 +9,7 @@ const { runInitialScan } = require('./initial-scan.js');
9
9
 
10
10
  function runFullRescan(projectRoot, options = {}) {
11
11
  archiveOldState(projectRoot);
12
- return runInitialScan(projectRoot, { ...options, mode: 'full_rescan' });
12
+ return runInitialScan(projectRoot, { ...options, mode: 'full_rescan', overwrite: true });
13
13
  }
14
14
 
15
15
  module.exports = { runFullRescan };
@@ -39,7 +39,7 @@ function existingDocs(root) {
39
39
  return candidates.filter(f => fs.existsSync(path.join(root, f))).map(f => path.join(root, f));
40
40
  }
41
41
 
42
- function runInitialScan(projectRoot, { scanLevel = 'quick', log = console.log, csvPath } = {}) {
42
+ function runInitialScan(projectRoot, { scanLevel = 'quick', log = console.log, csvPath, overwrite = false } = {}) {
43
43
  const classification = classifyProject(projectRoot, csvPath ? { csvPath } : {});
44
44
  const state = initState(projectRoot, 'initial_scan', scanLevel);
45
45
 
@@ -68,9 +68,10 @@ function runInitialScan(projectRoot, { scanLevel = 'quick', log = console.log, c
68
68
  });
69
69
  }
70
70
 
71
- // Always write baseline + index.
72
- const quick = runQuick(projectRoot, { log });
73
- generated.push(...quick.written.map(f => path.join(projectRoot, '.wize', 'knowledge', 'document-project', f)));
71
+ // Write the missing baseline files (existing ones are kept unless overwrite) + index.
72
+ const quick = runQuick(projectRoot, { log, overwrite });
73
+ const baselineDir = path.join(projectRoot, '.wize', 'knowledge', 'document-project');
74
+ generated.push(...[...quick.written, ...(quick.skipped || [])].map(f => path.join(baselineDir, f)));
74
75
 
75
76
  const indexResult = renderIndex(projectRoot, {
76
77
  projectTypes: classification.projectTypes,
@@ -196,16 +196,23 @@ function runQuick(projectRoot, opts = {}) {
196
196
  const dir = path.join(projectRoot, '.wize', 'knowledge', 'document-project');
197
197
  fs.mkdirSync(dir, { recursive: true });
198
198
 
199
+ const overwrite = opts.overwrite === true;
199
200
  const written = [];
201
+ const skipped = [];
200
202
  for (const [name, factory] of Object.entries(BASELINE_FILES)) {
201
203
  const file = path.join(dir, name);
202
- const content = factory(projectRoot, pkg);
203
- fs.writeFileSync(file, content, 'utf-8');
204
+ if (!overwrite && fs.existsSync(file)) {
205
+ skipped.push(name);
206
+ continue;
207
+ }
208
+ fs.writeFileSync(file, factory(projectRoot, pkg), 'utf-8');
204
209
  written.push(name);
205
210
  }
206
211
 
207
- if (opts.log) opts.log(`Quick baseline written to ${dir}`);
208
- return { changed: true, mode: 'quick', written };
212
+ if (opts.log) {
213
+ opts.log(`Quick baseline: wrote ${written.length}, kept ${skipped.length} existing (use --force to overwrite)`);
214
+ }
215
+ return { changed: written.length > 0, mode: 'quick', written, skipped };
209
216
  }
210
217
 
211
218
  module.exports = { runQuick, BASELINE_FILES, countLines };
@@ -0,0 +1,219 @@
1
+ // Read-only git facts about a project root: is it a repo, a linked worktree,
2
+ // which branch, where the primary checkout lives. Every call is bounded and
3
+ // failure-safe; a missing git binary degrades to a `.git` file/dir probe.
4
+
5
+ 'use strict';
6
+
7
+ const fs = require('node:fs');
8
+ const path = require('node:path');
9
+ const { execFileSync } = require('node:child_process');
10
+ const { real } = require('./util.js');
11
+
12
+ function git(root, args, { timeout = 3000 } = {}) {
13
+ try {
14
+ return execFileSync('git', args, {
15
+ cwd: root,
16
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
17
+ timeout,
18
+ encoding: 'utf-8',
19
+ maxBuffer: 256 * 1024 * 1024,
20
+ stdio: ['ignore', 'pipe', 'ignore']
21
+ });
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ function revParseTriple(root) {
28
+ let out = git(root, ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-dir', '--git-common-dir']);
29
+ if (out === null) out = git(root, ['rev-parse', '--show-toplevel', '--git-dir', '--git-common-dir']);
30
+ if (out === null) return null;
31
+ const lines = out.split('\n').filter(Boolean);
32
+ if (lines.length < 3) return null;
33
+ return lines.slice(0, 3).map(l => real(path.resolve(root, l)));
34
+ }
35
+
36
+ // Parses `git worktree list --porcelain` into [{ path, head, branch, detached, bare }].
37
+ function worktreeList(root) {
38
+ const out = git(root, ['worktree', 'list', '--porcelain']);
39
+ if (out === null) return null;
40
+ const list = [];
41
+ let cur = null;
42
+ for (const line of out.split('\n')) {
43
+ if (line.startsWith('worktree ')) {
44
+ cur = { path: line.slice('worktree '.length), head: null, branch: null, detached: false, bare: false };
45
+ list.push(cur);
46
+ } else if (!cur) {
47
+ continue;
48
+ } else if (line.startsWith('HEAD ')) cur.head = line.slice(5);
49
+ else if (line.startsWith('branch ')) cur.branch = line.slice(7).replace(/^refs\/heads\//, '');
50
+ else if (line === 'detached') cur.detached = true;
51
+ else if (line === 'bare') cur.bare = true;
52
+ }
53
+ return list;
54
+ }
55
+
56
+ function fallbackContext(root) {
57
+ const base = { isRepo: false, toplevel: null, gitDir: null, commonDir: null, linked: false, primary: null, branch: null, detached: false, head: null };
58
+ // git failed: a .git above the root (monorepo subdir) still means a repo,
59
+ // so the root's caller must keep its git guards on instead of going non-git.
60
+ let top = root;
61
+ let st;
62
+ for (;;) {
63
+ try { st = fs.statSync(path.join(top, '.git')); break; } catch { /* keep walking */ }
64
+ const up = path.dirname(top);
65
+ if (up === top) return base;
66
+ top = up;
67
+ }
68
+ const dotGit = path.join(top, '.git');
69
+ if (st.isDirectory()) {
70
+ return { ...base, isRepo: true, toplevel: real(top), gitDir: real(dotGit), commonDir: real(dotGit), primary: real(top) };
71
+ }
72
+ let gitDir = null;
73
+ try {
74
+ const m = fs.readFileSync(dotGit, 'utf-8').match(/^gitdir:\s*(.+)$/m);
75
+ if (m) gitDir = real(path.resolve(top, m[1].trim()));
76
+ } catch { /* unreadable .git file */ }
77
+ return { ...base, isRepo: true, linked: true, toplevel: real(top), gitDir };
78
+ }
79
+
80
+ function gitContext(root) {
81
+ const triple = revParseTriple(root);
82
+ if (!triple) return fallbackContext(root);
83
+ const [toplevel, gitDir, commonDir] = triple;
84
+
85
+ let primary = null;
86
+ const wt = worktreeList(root);
87
+ if (wt && wt.length) primary = real(wt[0].path);
88
+ else if (path.basename(commonDir) === '.git') primary = path.dirname(commonDir);
89
+
90
+ const branchOut = git(root, ['symbolic-ref', '--short', '-q', 'HEAD']);
91
+ const branch = branchOut ? branchOut.trim() || null : null;
92
+ const headOut = git(root, ['rev-parse', '--short', 'HEAD']);
93
+ const head = headOut ? headOut.trim() || null : null;
94
+
95
+ return {
96
+ isRepo: true,
97
+ toplevel,
98
+ gitDir,
99
+ commonDir,
100
+ linked: gitDir !== commonDir,
101
+ primary,
102
+ branch,
103
+ detached: branch === null,
104
+ head
105
+ };
106
+ }
107
+
108
+ function localBranchExists(root, name) {
109
+ return git(root, ['rev-parse', '--verify', '-q', `refs/heads/${name}`]) !== null;
110
+ }
111
+
112
+ // origin/HEAD target, else the first existing local branch among
113
+ // init.defaultBranch, main, master; null when none exists.
114
+ function defaultBranch(root) {
115
+ const head = git(root, ['symbolic-ref', '--short', '-q', 'refs/remotes/origin/HEAD']);
116
+ if (head && head.trim()) return head.trim().replace(/^origin\//, '');
117
+ const configured = git(root, ['config', 'init.defaultBranch']);
118
+ const candidates = [configured && configured.trim(), 'main', 'master'].filter(Boolean);
119
+ for (const name of candidates) {
120
+ if (localBranchExists(root, name)) return name;
121
+ }
122
+ return null;
123
+ }
124
+
125
+ // Absolute paths of every tracked file in the repository that contains `root`
126
+ // (listed from the toplevel, so a project in a monorepo subdirectory still sees
127
+ // tracked files elsewhere in the repo that a symlink could lead to; paths are
128
+ // resolved through the real toplevel, so symlinked checkouts compare equal). A submodule
129
+ // (gitlink) is tracked as one entry, so `has()` also answers true for any path
130
+ // inside one: a write there would change another repository's tracked files.
131
+ // An empty Set means a repo with nothing tracked; null means git failed
132
+ // (missing binary, "dubious ownership"), so the caller must not assume
133
+ // nothing is tracked.
134
+ class TrackedPaths extends Set {
135
+ constructor() {
136
+ super();
137
+ this.gitlinks = [];
138
+ }
139
+
140
+ has(abs) {
141
+ if (super.has(abs)) return true;
142
+ return this.gitlinks.some(g => abs.startsWith(g + path.sep));
143
+ }
144
+ }
145
+
146
+ function trackedSet(root) {
147
+ const top = git(root, ['rev-parse', '--show-toplevel']);
148
+ if (top === null || !top.trim()) return null;
149
+ const toplevel = path.resolve(root, top.trim());
150
+ const out = git(toplevel, ['ls-files', '-s', '-z', '--full-name'], { timeout: 15000 });
151
+ if (out === null) return null;
152
+ const base = real(toplevel);
153
+ const set = new TrackedPaths();
154
+ for (const entry of out.split('\0')) {
155
+ const tab = entry.indexOf('\t');
156
+ if (tab < 0) continue;
157
+ const abs = path.resolve(base, entry.slice(tab + 1));
158
+ set.add(abs);
159
+ if (entry.startsWith('160000 ')) set.gitlinks.push(abs);
160
+ }
161
+ return set;
162
+ }
163
+
164
+ // Tracked files (paths relative to `root`) under the given pathspecs; empty
165
+ // when not a repo or git fails.
166
+ function trackedUnder(root, pathspecs) {
167
+ if (!pathspecs || !pathspecs.length) return [];
168
+ const out = git(root, ['ls-files', '-z', '--', ...pathspecs], { timeout: 15000 });
169
+ return out ? out.split('\0').filter(Boolean) : [];
170
+ }
171
+
172
+ // Absolute paths of tracked files with uncommitted changes (staged or not),
173
+ // listed from the repository toplevel so a tracked symlinked output directory
174
+ // that leads elsewhere in the repo is covered too. Null when git fails.
175
+ function dirtyTrackedSet(root) {
176
+ const top = git(root, ['rev-parse', '--show-toplevel']);
177
+ if (top === null || !top.trim()) return null;
178
+ const toplevel = real(path.resolve(root, top.trim()));
179
+ const out = git(toplevel, ['status', '--porcelain', '-z', '--untracked-files=no'], { timeout: 15000 });
180
+ if (out === null) return null;
181
+ const set = new Set();
182
+ const parts = out.split('\0');
183
+ for (let i = 0; i < parts.length; i++) {
184
+ const entry = parts[i];
185
+ if (entry.length < 4) continue;
186
+ set.add(path.resolve(toplevel, entry.slice(3)));
187
+ if (/[RC]/.test(entry.slice(0, 2))) i++; // a rename/copy carries its source as the next entry
188
+ }
189
+ return set;
190
+ }
191
+
192
+ function hasRemote(root, name = 'origin') {
193
+ const out = git(root, ['remote']);
194
+ return !!out && out.split('\n').map(s => s.trim()).includes(name);
195
+ }
196
+
197
+ // Lines of `git status --porcelain` (limited to `paths` when given); null when
198
+ // git is unavailable or fails.
199
+ function statusPorcelain(root, paths = []) {
200
+ const out = git(root, ['status', '--porcelain', ...(paths.length ? ['--', ...paths] : [])], { timeout: 15000 });
201
+ if (out === null) return null;
202
+ return out.split('\n').filter(Boolean);
203
+ }
204
+
205
+ // `git config user.name` as seen from `root`; null when unset.
206
+ function configUserName(root) {
207
+ const out = git(root, ['config', 'user.name']);
208
+ return out && out.trim() ? out.trim() : null;
209
+ }
210
+
211
+ // Whether `ref` resolves to a commit.
212
+ function refExists(root, ref) {
213
+ return git(root, ['rev-parse', '--verify', '-q', `${ref}^{commit}`]) !== null;
214
+ }
215
+
216
+ module.exports = {
217
+ gitContext, defaultBranch, trackedSet, dirtyTrackedSet, trackedUnder, hasRemote, worktreeList, statusPorcelain, configUserName,
218
+ localBranchExists, refExists
219
+ };
@@ -0,0 +1,211 @@
1
+ // Mutating and network git/gh operations used by `update`. Every wrapper uses
2
+ // execFileSync (no shell), never throws, and returns { ok, out, err } so the
3
+ // caller decides what a failure means. Read-only facts live in git-context.js.
4
+
5
+ 'use strict';
6
+
7
+ const fs = require('node:fs');
8
+ const path = require('node:path');
9
+ const { execFileSync } = require('node:child_process');
10
+ const { defaultBranch, gitContext } = require('./git-context.js');
11
+ const { real } = require('./util.js');
12
+
13
+ function run(cmd, cwd, args, { timeout = 60000, env = null } = {}) {
14
+ try {
15
+ const out = execFileSync(cmd, args, {
16
+ cwd,
17
+ timeout,
18
+ encoding: 'utf-8',
19
+ maxBuffer: 256 * 1024 * 1024,
20
+ env: { ...process.env, ...(env || {}) },
21
+ stdio: ['ignore', 'pipe', 'pipe']
22
+ });
23
+ return { ok: true, out, err: '' };
24
+ } catch (e) {
25
+ const out = e && e.stdout ? String(e.stdout) : '';
26
+ const err = [e && e.stderr ? String(e.stderr) : '', out].join('').trim() || (e && e.message) || 'failed';
27
+ return { ok: false, out, err };
28
+ }
29
+ }
30
+
31
+ const git = (cwd, args, opts) => run('git', cwd, args, opts);
32
+ const NO_PROMPT = { GIT_TERMINAL_PROMPT: '0' };
33
+
34
+ // ---- reads against a ref -------------------------------------------------
35
+
36
+ // Content of `rel` (relative to cwd) at `ref`; null when absent or not a
37
+ // regular file. A symlink blob would read back as its target text and a
38
+ // gitlink is a submodule, so neither is ever treated as file content.
39
+ function treeMode(cwd, ref, rel) {
40
+ const t = git(cwd, ['ls-tree', '-z', ref, '--', rel]);
41
+ if (!t.ok) return null;
42
+ const entry = t.out.split('\0').filter(Boolean)[0];
43
+ return entry ? entry.split(' ')[0] : null;
44
+ }
45
+
46
+ // True when `rel` exists at `ref` but is not a regular file (symlink, gitlink).
47
+ function isSpecialAt(cwd, ref, rel) {
48
+ const mode = treeMode(cwd, ref, rel);
49
+ return mode !== null && mode !== '100644' && mode !== '100755';
50
+ }
51
+
52
+ function showFile(cwd, ref, rel) {
53
+ if (treeMode(cwd, ref, rel) === null || isSpecialAt(cwd, ref, rel)) return null;
54
+ const r = git(cwd, ['show', `${ref}:./${rel}`]);
55
+ return r.ok ? r.out : null;
56
+ }
57
+
58
+ // Files tracked at `ref` under `paths` (relative to cwd, no leading `./`).
59
+ function lsTree(cwd, ref, paths) {
60
+ const r = git(cwd, ['ls-tree', '-r', '--name-only', '-z', ref, '--', ...paths]);
61
+ return r.ok ? r.out.split('\0').filter(Boolean) : [];
62
+ }
63
+
64
+ // Files in the index under `paths`.
65
+ function lsFiles(cwd, paths) {
66
+ const r = git(cwd, ['ls-files', '-z', '--', ...paths]);
67
+ return r.ok ? r.out.split('\0').filter(Boolean) : [];
68
+ }
69
+
70
+ // Paths (relative to the repository top level) that differ between two commits.
71
+ // null when git fails.
72
+ function diffNames(cwd, from, to) {
73
+ const r = git(cwd, ['diff', '--name-only', '--no-renames', '-z', from, to]);
74
+ return r.ok ? r.out.split('\0').filter(Boolean) : null;
75
+ }
76
+
77
+ function headSha(cwd) {
78
+ const r = git(cwd, ['rev-parse', 'HEAD']);
79
+ return r.ok ? r.out.trim() : null;
80
+ }
81
+
82
+ // ---- guards --------------------------------------------------------------
83
+
84
+ // Hard guard: throws when `cwd` is checked out on the default branch.
85
+ function assertNotDefaultBranch(cwd) {
86
+ const ctx = gitContext(cwd);
87
+ if (!ctx.isRepo) return;
88
+ const def = defaultBranch(cwd);
89
+ if (ctx.branch && (ctx.branch === def || ctx.branch === 'main' || ctx.branch === 'master')) {
90
+ throw new Error(`refusing to commit on ${ctx.branch}`);
91
+ }
92
+ }
93
+
94
+ // ---- worktrees -----------------------------------------------------------
95
+
96
+ // A detached, fully checked-out worktree of `ref`. Commit hooks assume a
97
+ // working tree that matches the index (`git add -u` would stage the deletion of
98
+ // every file a sparse checkout left out, and tools such as the pre-commit
99
+ // framework read their config from disk), so the tree is complete. LFS
100
+ // objects are not downloaded (pointer files are checked out) and hooks are
101
+ // disabled for the checkout itself, since post-checkout can run arbitrary
102
+ // project code; the commit still runs the project's hooks.
103
+ function worktreeAdd(cwd, dir, ref) {
104
+ const env = { GIT_LFS_SKIP_SMUDGE: '1' };
105
+ return git(cwd, ['-c', 'core.hooksPath=/dev/null', 'worktree', 'add', '--detach', '-q', dir, ref], { timeout: 300000, env });
106
+ }
107
+
108
+ function worktreeRemove(cwd, dir) {
109
+ return git(cwd, ['worktree', 'remove', '--force', dir]);
110
+ }
111
+
112
+ // Drops the admin data of the worktree at `dir` only, when `worktree remove`
113
+ // could not. A repo-wide `git worktree prune` would also delete the entries of
114
+ // unrelated worktrees whose directory is momentarily missing (an unmounted
115
+ // volume, a directory moved by hand).
116
+ function worktreeForget(cwd, dir) {
117
+ const common = git(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
118
+ if (!common.ok) return;
119
+ const admin = path.join(common.out.trim(), 'worktrees');
120
+ const wanted = real(path.join(dir, '.git'));
121
+ let names;
122
+ try { names = fs.readdirSync(admin); } catch { return; }
123
+ for (const name of names) {
124
+ try {
125
+ const recorded = fs.readFileSync(path.join(admin, name, 'gitdir'), 'utf-8').trim();
126
+ if (real(recorded) === wanted) fs.rmSync(path.join(admin, name), { recursive: true, force: true });
127
+ } catch { /* not ours or unreadable: leave it */ }
128
+ }
129
+ }
130
+
131
+ // ---- index and history ---------------------------------------------------
132
+
133
+ function add(cwd, paths) {
134
+ return git(cwd, ['add', '--', ...paths]);
135
+ }
136
+
137
+ function rmCached(cwd, paths) {
138
+ return git(cwd, ['rm', '--cached', '-q', '--', ...paths]);
139
+ }
140
+
141
+ // Never passes --no-verify: the project's hooks decide.
142
+ function commit(cwd, subject, body) {
143
+ assertNotDefaultBranch(cwd);
144
+ return git(cwd, ['commit', '-q', '-m', subject, ...(body ? ['-m', body] : [])], { timeout: 300000 });
145
+ }
146
+
147
+ function branchCreate(cwd, name, sha) {
148
+ return git(cwd, ['branch', name, sha]);
149
+ }
150
+
151
+ // Compare-and-swap: fails unless the branch still points at `oldSha`.
152
+ function updateRef(cwd, branch, newSha, oldSha) {
153
+ return git(cwd, ['update-ref', `refs/heads/${branch}`, newSha, oldSha]);
154
+ }
155
+
156
+ // ---- remote --------------------------------------------------------------
157
+
158
+ function fetch(cwd, remote, branch) {
159
+ return git(cwd, ['fetch', '--quiet', remote, branch], { timeout: 30000, env: NO_PROMPT });
160
+ }
161
+
162
+ // Never forces.
163
+ function push(cwd, remote, branch) {
164
+ return git(cwd, ['push', '-u', remote, `refs/heads/${branch}:refs/heads/${branch}`], { timeout: 120000, env: NO_PROMPT });
165
+ }
166
+
167
+ // https://github.com/<owner>/<repo> for a GitHub origin (ssh or https); else null.
168
+ function originWebUrl(cwd) {
169
+ const r = git(cwd, ['remote', 'get-url', 'origin']);
170
+ if (!r.ok) return null;
171
+ const url = r.out.trim();
172
+ const m = url.match(/^(?:git@|ssh:\/\/git@|https?:\/\/(?:[^@/]+@)?)github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/);
173
+ return m ? `https://github.com/${m[1]}/${m[2]}` : null;
174
+ }
175
+
176
+ // ---- gh ------------------------------------------------------------------
177
+
178
+ function ghAvailable() {
179
+ return run('gh', process.cwd(), ['--version'], { timeout: 10000 }).ok;
180
+ }
181
+
182
+ // { url, state } of the PR for `branch`, or null.
183
+ function ghPrView(cwd, branch) {
184
+ const r = run('gh', cwd, ['pr', 'view', branch, '--json', 'url,state'], { timeout: 30000, env: NO_PROMPT });
185
+ if (!r.ok) return null;
186
+ try {
187
+ const data = JSON.parse(r.out);
188
+ return data && data.url ? { url: data.url, state: data.state || null } : null;
189
+ } catch {
190
+ return null;
191
+ }
192
+ }
193
+
194
+ // Returns { ok, url, err }.
195
+ function ghPrCreate(cwd, { base, head, title, body }) {
196
+ const r = run('gh', cwd, ['pr', 'create', '--base', base, '--head', head, '--title', title, '--body', body], { timeout: 60000, env: NO_PROMPT });
197
+ if (!r.ok) return { ok: false, url: null, err: r.err };
198
+ const url = r.out.split('\n').map(s => s.trim()).filter(Boolean).pop() || null;
199
+ return { ok: true, url, err: '' };
200
+ }
201
+
202
+ // Best-effort removal of a temp directory a failed `worktree remove` left behind.
203
+ function rmDir(dir) {
204
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
205
+ }
206
+
207
+ module.exports = {
208
+ run, showFile, isSpecialAt, lsTree, lsFiles, diffNames, headSha, assertNotDefaultBranch,
209
+ worktreeAdd, worktreeRemove, worktreeForget, add, rmCached, commit, branchCreate, updateRef,
210
+ fetch, push, originWebUrl, ghAvailable, ghPrView, ghPrCreate, rmDir
211
+ };