@thxgg/steward 0.1.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/.env.example +7 -0
- package/LICENSE +21 -0
- package/README.md +175 -0
- package/app/app.vue +14 -0
- package/app/assets/css/main.css +129 -0
- package/app/components/CommandPalette.vue +182 -0
- package/app/components/ShortcutsHelp.vue +85 -0
- package/app/components/git/ChangesMinimap.vue +143 -0
- package/app/components/git/CommitList.vue +224 -0
- package/app/components/git/DiffPanel.vue +402 -0
- package/app/components/git/DiffViewer.vue +803 -0
- package/app/components/layout/RepoSelector.vue +358 -0
- package/app/components/layout/Sidebar.vue +91 -0
- package/app/components/prd/Meta.vue +69 -0
- package/app/components/prd/Viewer.vue +285 -0
- package/app/components/tasks/Board.vue +86 -0
- package/app/components/tasks/Card.vue +108 -0
- package/app/components/tasks/Column.vue +108 -0
- package/app/components/tasks/Detail.vue +291 -0
- package/app/components/ui/badge/Badge.vue +26 -0
- package/app/components/ui/badge/index.ts +26 -0
- package/app/components/ui/button/Button.vue +29 -0
- package/app/components/ui/button/index.ts +38 -0
- package/app/components/ui/card/Card.vue +22 -0
- package/app/components/ui/card/CardAction.vue +17 -0
- package/app/components/ui/card/CardContent.vue +17 -0
- package/app/components/ui/card/CardDescription.vue +17 -0
- package/app/components/ui/card/CardFooter.vue +17 -0
- package/app/components/ui/card/CardHeader.vue +17 -0
- package/app/components/ui/card/CardTitle.vue +17 -0
- package/app/components/ui/card/index.ts +7 -0
- package/app/components/ui/combobox/Combobox.vue +19 -0
- package/app/components/ui/combobox/ComboboxAnchor.vue +23 -0
- package/app/components/ui/combobox/ComboboxEmpty.vue +21 -0
- package/app/components/ui/combobox/ComboboxGroup.vue +27 -0
- package/app/components/ui/combobox/ComboboxInput.vue +42 -0
- package/app/components/ui/combobox/ComboboxItem.vue +24 -0
- package/app/components/ui/combobox/ComboboxItemIndicator.vue +23 -0
- package/app/components/ui/combobox/ComboboxList.vue +33 -0
- package/app/components/ui/combobox/ComboboxSeparator.vue +21 -0
- package/app/components/ui/combobox/ComboboxTrigger.vue +24 -0
- package/app/components/ui/combobox/ComboboxViewport.vue +23 -0
- package/app/components/ui/combobox/index.ts +13 -0
- package/app/components/ui/command/Command.vue +103 -0
- package/app/components/ui/command/CommandDialog.vue +33 -0
- package/app/components/ui/command/CommandEmpty.vue +27 -0
- package/app/components/ui/command/CommandGroup.vue +45 -0
- package/app/components/ui/command/CommandInput.vue +54 -0
- package/app/components/ui/command/CommandItem.vue +76 -0
- package/app/components/ui/command/CommandList.vue +25 -0
- package/app/components/ui/command/CommandSeparator.vue +21 -0
- package/app/components/ui/command/CommandShortcut.vue +17 -0
- package/app/components/ui/command/index.ts +25 -0
- package/app/components/ui/dialog/Dialog.vue +19 -0
- package/app/components/ui/dialog/DialogClose.vue +15 -0
- package/app/components/ui/dialog/DialogContent.vue +53 -0
- package/app/components/ui/dialog/DialogDescription.vue +23 -0
- package/app/components/ui/dialog/DialogFooter.vue +15 -0
- package/app/components/ui/dialog/DialogHeader.vue +17 -0
- package/app/components/ui/dialog/DialogOverlay.vue +21 -0
- package/app/components/ui/dialog/DialogScrollContent.vue +59 -0
- package/app/components/ui/dialog/DialogTitle.vue +23 -0
- package/app/components/ui/dialog/DialogTrigger.vue +15 -0
- package/app/components/ui/dialog/index.ts +10 -0
- package/app/components/ui/input/Input.vue +33 -0
- package/app/components/ui/input/index.ts +1 -0
- package/app/components/ui/scroll-area/ScrollArea.vue +33 -0
- package/app/components/ui/scroll-area/ScrollBar.vue +32 -0
- package/app/components/ui/scroll-area/index.ts +2 -0
- package/app/components/ui/separator/Separator.vue +29 -0
- package/app/components/ui/separator/index.ts +1 -0
- package/app/components/ui/sheet/Sheet.vue +19 -0
- package/app/components/ui/sheet/SheetClose.vue +15 -0
- package/app/components/ui/sheet/SheetContent.vue +62 -0
- package/app/components/ui/sheet/SheetDescription.vue +21 -0
- package/app/components/ui/sheet/SheetFooter.vue +16 -0
- package/app/components/ui/sheet/SheetHeader.vue +15 -0
- package/app/components/ui/sheet/SheetOverlay.vue +21 -0
- package/app/components/ui/sheet/SheetTitle.vue +21 -0
- package/app/components/ui/sheet/SheetTrigger.vue +15 -0
- package/app/components/ui/sheet/index.ts +8 -0
- package/app/components/ui/tabs/Tabs.vue +24 -0
- package/app/components/ui/tabs/TabsContent.vue +21 -0
- package/app/components/ui/tabs/TabsList.vue +24 -0
- package/app/components/ui/tabs/TabsTrigger.vue +26 -0
- package/app/components/ui/tabs/index.ts +4 -0
- package/app/components/ui/tooltip/Tooltip.vue +19 -0
- package/app/components/ui/tooltip/TooltipContent.vue +34 -0
- package/app/components/ui/tooltip/TooltipProvider.vue +14 -0
- package/app/components/ui/tooltip/TooltipTrigger.vue +15 -0
- package/app/components/ui/tooltip/index.ts +4 -0
- package/app/composables/useFileWatch.ts +78 -0
- package/app/composables/useGit.ts +180 -0
- package/app/composables/useKeyboard.ts +180 -0
- package/app/composables/usePrd.ts +86 -0
- package/app/composables/useRepos.ts +108 -0
- package/app/composables/useThemeMode.ts +38 -0
- package/app/composables/useToast.ts +31 -0
- package/app/layouts/default.vue +197 -0
- package/app/lib/utils.ts +7 -0
- package/app/pages/[repo]/[prd].vue +263 -0
- package/app/pages/index.vue +257 -0
- package/app/types/git.ts +81 -0
- package/app/types/index.ts +29 -0
- package/app/types/prd.ts +49 -0
- package/app/types/repo.ts +37 -0
- package/app/types/task.ts +134 -0
- package/bin/prd +21 -0
- package/components.json +21 -0
- package/dist/app/types/git.js +1 -0
- package/dist/app/types/prd.js +1 -0
- package/dist/app/types/repo.js +1 -0
- package/dist/app/types/task.js +1 -0
- package/dist/host/src/api/git.js +96 -0
- package/dist/host/src/api/index.js +4 -0
- package/dist/host/src/api/prds.js +195 -0
- package/dist/host/src/api/repos.js +47 -0
- package/dist/host/src/api/state.js +63 -0
- package/dist/host/src/executor.js +109 -0
- package/dist/host/src/index.js +95 -0
- package/dist/host/src/mcp.js +62 -0
- package/dist/host/src/ui.js +64 -0
- package/dist/server/utils/db.js +125 -0
- package/dist/server/utils/git.js +396 -0
- package/dist/server/utils/prd-state.js +229 -0
- package/dist/server/utils/repos.js +256 -0
- package/docs/MCP.md +180 -0
- package/nuxt.config.ts +34 -0
- package/package.json +88 -0
- package/public/favicon.ico +0 -0
- package/public/robots.txt +1 -0
- package/server/api/browse.get.ts +52 -0
- package/server/api/repos/[repoId]/git/commits.get.ts +103 -0
- package/server/api/repos/[repoId]/git/diff.get.ts +77 -0
- package/server/api/repos/[repoId]/git/file-content.get.ts +66 -0
- package/server/api/repos/[repoId]/git/file-diff.get.ts +109 -0
- package/server/api/repos/[repoId]/prd/[prdSlug]/progress.get.ts +36 -0
- package/server/api/repos/[repoId]/prd/[prdSlug]/tasks/[taskId]/commits.get.ts +146 -0
- package/server/api/repos/[repoId]/prd/[prdSlug]/tasks.get.ts +36 -0
- package/server/api/repos/[repoId]/prd/[prdSlug].get.ts +97 -0
- package/server/api/repos/[repoId]/prds.get.ts +85 -0
- package/server/api/repos/[repoId]/refresh-git-repos.post.ts +42 -0
- package/server/api/repos/[repoId].delete.ts +27 -0
- package/server/api/repos/index.get.ts +5 -0
- package/server/api/repos/index.post.ts +39 -0
- package/server/api/watch.get.ts +63 -0
- package/server/plugins/migrate-legacy-state.ts +19 -0
- package/server/tsconfig.json +3 -0
- package/server/utils/db.ts +169 -0
- package/server/utils/git.ts +478 -0
- package/server/utils/prd-state.ts +335 -0
- package/server/utils/repos.ts +322 -0
- package/server/utils/watcher.ts +179 -0
- package/tsconfig.json +4 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
function findPackageRoot(startDir) {
|
|
8
|
+
let currentDir = startDir;
|
|
9
|
+
while (true) {
|
|
10
|
+
if (existsSync(join(currentDir, 'package.json'))) {
|
|
11
|
+
return currentDir;
|
|
12
|
+
}
|
|
13
|
+
const parentDir = dirname(currentDir);
|
|
14
|
+
if (parentDir === currentDir) {
|
|
15
|
+
throw new Error('Unable to locate package root from current runtime path');
|
|
16
|
+
}
|
|
17
|
+
currentDir = parentDir;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const packageRoot = findPackageRoot(dirname(fileURLToPath(import.meta.url)));
|
|
21
|
+
function resolveNuxtEntrypoint() {
|
|
22
|
+
const packageJsonPath = require.resolve('nuxt/package.json', { paths: [packageRoot] });
|
|
23
|
+
return join(dirname(packageJsonPath), 'bin', 'nuxt.mjs');
|
|
24
|
+
}
|
|
25
|
+
export async function runUi(options) {
|
|
26
|
+
const script = options.preview ? 'preview' : 'dev';
|
|
27
|
+
const args = [resolveNuxtEntrypoint(), script];
|
|
28
|
+
if (options.port !== undefined || options.host) {
|
|
29
|
+
if (options.port !== undefined) {
|
|
30
|
+
args.push('--port', String(options.port));
|
|
31
|
+
}
|
|
32
|
+
if (options.host) {
|
|
33
|
+
args.push('--host', options.host);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const child = spawn(process.execPath, args, {
|
|
37
|
+
cwd: packageRoot,
|
|
38
|
+
stdio: 'inherit',
|
|
39
|
+
env: process.env
|
|
40
|
+
});
|
|
41
|
+
return await new Promise((resolveExit, reject) => {
|
|
42
|
+
const forwardSignal = (signal) => {
|
|
43
|
+
if (!child.killed) {
|
|
44
|
+
child.kill(signal);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
process.on('SIGINT', forwardSignal);
|
|
48
|
+
process.on('SIGTERM', forwardSignal);
|
|
49
|
+
child.on('error', (error) => {
|
|
50
|
+
process.off('SIGINT', forwardSignal);
|
|
51
|
+
process.off('SIGTERM', forwardSignal);
|
|
52
|
+
reject(error);
|
|
53
|
+
});
|
|
54
|
+
child.on('exit', (code, signal) => {
|
|
55
|
+
process.off('SIGINT', forwardSignal);
|
|
56
|
+
process.off('SIGTERM', forwardSignal);
|
|
57
|
+
if (signal) {
|
|
58
|
+
process.kill(process.pid, signal);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
resolveExit(code ?? 0);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
const DEFAULT_DATA_HOME = process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share');
|
|
5
|
+
const DEFAULT_DB_PATH = join(DEFAULT_DATA_HOME, 'prd', 'state.db');
|
|
6
|
+
let adapterPromise = null;
|
|
7
|
+
function coerceChanges(result) {
|
|
8
|
+
if (!result || typeof result !== 'object') {
|
|
9
|
+
return 0;
|
|
10
|
+
}
|
|
11
|
+
const maybeChanges = result.changes;
|
|
12
|
+
return typeof maybeChanges === 'number' ? maybeChanges : 0;
|
|
13
|
+
}
|
|
14
|
+
function resolveDbPath() {
|
|
15
|
+
const customPath = process.env.PRD_STATE_DB_PATH;
|
|
16
|
+
if (customPath && customPath.trim().length > 0) {
|
|
17
|
+
return customPath;
|
|
18
|
+
}
|
|
19
|
+
const customHome = process.env.PRD_STATE_HOME;
|
|
20
|
+
if (customHome && customHome.trim().length > 0) {
|
|
21
|
+
return join(customHome, 'state.db');
|
|
22
|
+
}
|
|
23
|
+
return DEFAULT_DB_PATH;
|
|
24
|
+
}
|
|
25
|
+
export function getDbPath() {
|
|
26
|
+
return resolveDbPath();
|
|
27
|
+
}
|
|
28
|
+
async function createNodeAdapter(dbPath) {
|
|
29
|
+
const sqliteModule = await import('node:sqlite');
|
|
30
|
+
const db = new sqliteModule.DatabaseSync(dbPath);
|
|
31
|
+
return {
|
|
32
|
+
exec(sql) {
|
|
33
|
+
db.exec(sql);
|
|
34
|
+
},
|
|
35
|
+
run(sql, params = []) {
|
|
36
|
+
const result = db.prepare(sql).run(...params);
|
|
37
|
+
return { changes: coerceChanges(result) };
|
|
38
|
+
},
|
|
39
|
+
get(sql, params = []) {
|
|
40
|
+
const row = db.prepare(sql).get(...params);
|
|
41
|
+
return row ? row : null;
|
|
42
|
+
},
|
|
43
|
+
all(sql, params = []) {
|
|
44
|
+
return db.prepare(sql).all(...params);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
async function createBunAdapter(dbPath) {
|
|
49
|
+
const bunModuleName = 'bun:sqlite';
|
|
50
|
+
const sqliteModule = await import(bunModuleName);
|
|
51
|
+
const Database = sqliteModule.Database;
|
|
52
|
+
const db = new Database(dbPath, { create: true });
|
|
53
|
+
return {
|
|
54
|
+
exec(sql) {
|
|
55
|
+
db.exec(sql);
|
|
56
|
+
},
|
|
57
|
+
run(sql, params = []) {
|
|
58
|
+
const result = db.query(sql).run(...params);
|
|
59
|
+
return { changes: coerceChanges(result) };
|
|
60
|
+
},
|
|
61
|
+
get(sql, params = []) {
|
|
62
|
+
const row = db.query(sql).get(...params);
|
|
63
|
+
return row ?? null;
|
|
64
|
+
},
|
|
65
|
+
all(sql, params = []) {
|
|
66
|
+
return db.query(sql).all(...params);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
async function initializeDatabase() {
|
|
71
|
+
const dbPath = resolveDbPath();
|
|
72
|
+
await fs.mkdir(dirname(dbPath), { recursive: true });
|
|
73
|
+
const isBunRuntime = typeof globalThis.Bun !== 'undefined';
|
|
74
|
+
const adapter = isBunRuntime
|
|
75
|
+
? await createBunAdapter(dbPath)
|
|
76
|
+
: await createNodeAdapter(dbPath);
|
|
77
|
+
adapter.exec('PRAGMA journal_mode = WAL;');
|
|
78
|
+
adapter.exec('PRAGMA foreign_keys = ON;');
|
|
79
|
+
adapter.exec('PRAGMA busy_timeout = 5000;');
|
|
80
|
+
adapter.exec(`
|
|
81
|
+
CREATE TABLE IF NOT EXISTS repos (
|
|
82
|
+
id TEXT PRIMARY KEY,
|
|
83
|
+
name TEXT NOT NULL,
|
|
84
|
+
path TEXT NOT NULL UNIQUE,
|
|
85
|
+
added_at TEXT NOT NULL,
|
|
86
|
+
git_repos_json TEXT
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
CREATE TABLE IF NOT EXISTS prd_states (
|
|
90
|
+
repo_id TEXT NOT NULL,
|
|
91
|
+
slug TEXT NOT NULL,
|
|
92
|
+
tasks_json TEXT,
|
|
93
|
+
progress_json TEXT,
|
|
94
|
+
notes_md TEXT,
|
|
95
|
+
updated_at TEXT NOT NULL,
|
|
96
|
+
PRIMARY KEY (repo_id, slug),
|
|
97
|
+
FOREIGN KEY (repo_id) REFERENCES repos(id) ON DELETE CASCADE
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
CREATE INDEX IF NOT EXISTS idx_prd_states_repo_id ON prd_states(repo_id);
|
|
101
|
+
`);
|
|
102
|
+
return adapter;
|
|
103
|
+
}
|
|
104
|
+
async function getAdapter() {
|
|
105
|
+
if (!adapterPromise) {
|
|
106
|
+
adapterPromise = initializeDatabase();
|
|
107
|
+
}
|
|
108
|
+
return adapterPromise;
|
|
109
|
+
}
|
|
110
|
+
export async function dbRun(sql, params = []) {
|
|
111
|
+
const adapter = await getAdapter();
|
|
112
|
+
return adapter.run(sql, params);
|
|
113
|
+
}
|
|
114
|
+
export async function dbGet(sql, params = []) {
|
|
115
|
+
const adapter = await getAdapter();
|
|
116
|
+
return adapter.get(sql, params);
|
|
117
|
+
}
|
|
118
|
+
export async function dbAll(sql, params = []) {
|
|
119
|
+
const adapter = await getAdapter();
|
|
120
|
+
return adapter.all(sql, params);
|
|
121
|
+
}
|
|
122
|
+
export async function dbExec(sql) {
|
|
123
|
+
const adapter = await getAdapter();
|
|
124
|
+
adapter.exec(sql);
|
|
125
|
+
}
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { join, resolve, relative, isAbsolute } from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* Execute a git command and return stdout
|
|
6
|
+
*/
|
|
7
|
+
async function execGit(repoPath, args) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
const proc = spawn('git', args, {
|
|
10
|
+
cwd: repoPath,
|
|
11
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
12
|
+
});
|
|
13
|
+
let stdout = '';
|
|
14
|
+
let stderr = '';
|
|
15
|
+
proc.stdout.on('data', (data) => {
|
|
16
|
+
stdout += data.toString();
|
|
17
|
+
});
|
|
18
|
+
proc.stderr.on('data', (data) => {
|
|
19
|
+
stderr += data.toString();
|
|
20
|
+
});
|
|
21
|
+
proc.on('close', (code) => {
|
|
22
|
+
if (code === 0) {
|
|
23
|
+
resolve(stdout);
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
reject(new Error(stderr || `git exited with code ${code}`));
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
proc.on('error', (err) => {
|
|
30
|
+
reject(new Error(`Failed to spawn git: ${err.message}`));
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Check if a path is a valid git repository
|
|
36
|
+
*/
|
|
37
|
+
export async function isGitRepo(path) {
|
|
38
|
+
try {
|
|
39
|
+
await execGit(path, ['rev-parse', '--git-dir']);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Validate that a file path is within the repository
|
|
48
|
+
*/
|
|
49
|
+
export function validatePathInRepo(repoPath, filePath) {
|
|
50
|
+
const resolvedRepo = resolve(repoPath);
|
|
51
|
+
const resolvedFile = isAbsolute(filePath)
|
|
52
|
+
? resolve(filePath)
|
|
53
|
+
: resolve(repoPath, filePath);
|
|
54
|
+
// Check that the file is within the repo
|
|
55
|
+
const relativePath = relative(resolvedRepo, resolvedFile);
|
|
56
|
+
return !relativePath.startsWith('..') && !isAbsolute(relativePath);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Get commit information by SHA
|
|
60
|
+
*/
|
|
61
|
+
export async function getCommitInfo(repoPath, sha) {
|
|
62
|
+
// Validate SHA format (hex string, 4-40 chars)
|
|
63
|
+
if (!/^[0-9a-f]{4,40}$/i.test(sha)) {
|
|
64
|
+
throw new Error(`Invalid commit SHA: ${sha}`);
|
|
65
|
+
}
|
|
66
|
+
// Get commit details
|
|
67
|
+
const format = '%H%n%h%n%s%n%an%n%aI';
|
|
68
|
+
const output = await execGit(repoPath, ['show', sha, '--format=' + format, '--no-patch']);
|
|
69
|
+
const lines = output.trim().split('\n');
|
|
70
|
+
if (lines.length < 5) {
|
|
71
|
+
throw new Error(`Failed to parse commit info for ${sha}`);
|
|
72
|
+
}
|
|
73
|
+
// Get stats
|
|
74
|
+
const statsOutput = await execGit(repoPath, ['show', sha, '--format=', '--numstat']);
|
|
75
|
+
const statsLines = statsOutput.trim().split('\n').filter(l => l.trim());
|
|
76
|
+
let additions = 0;
|
|
77
|
+
let deletions = 0;
|
|
78
|
+
let filesChanged = 0;
|
|
79
|
+
for (const line of statsLines) {
|
|
80
|
+
const parts = line.split('\t');
|
|
81
|
+
const added = parts[0];
|
|
82
|
+
const deleted = parts[1];
|
|
83
|
+
if (added && deleted && added !== '-' && deleted !== '-') {
|
|
84
|
+
additions += parseInt(added, 10) || 0;
|
|
85
|
+
deletions += parseInt(deleted, 10) || 0;
|
|
86
|
+
}
|
|
87
|
+
filesChanged++;
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
sha: lines[0],
|
|
91
|
+
shortSha: lines[1],
|
|
92
|
+
message: lines[2],
|
|
93
|
+
author: lines[3],
|
|
94
|
+
date: lines[4],
|
|
95
|
+
filesChanged,
|
|
96
|
+
additions,
|
|
97
|
+
deletions,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Get list of changed files in a commit with stats
|
|
102
|
+
*/
|
|
103
|
+
export async function getCommitDiff(repoPath, sha) {
|
|
104
|
+
// Validate SHA format
|
|
105
|
+
if (!/^[0-9a-f]{4,40}$/i.test(sha)) {
|
|
106
|
+
throw new Error(`Invalid commit SHA: ${sha}`);
|
|
107
|
+
}
|
|
108
|
+
// Get file status and stats
|
|
109
|
+
const output = await execGit(repoPath, [
|
|
110
|
+
'show', sha,
|
|
111
|
+
'--format=',
|
|
112
|
+
'--name-status',
|
|
113
|
+
'--numstat',
|
|
114
|
+
]);
|
|
115
|
+
// Parse the output - first part is numstat, then name-status
|
|
116
|
+
const lines = output.trim().split('\n').filter(l => l.trim());
|
|
117
|
+
// We need to get both numstat and name-status info
|
|
118
|
+
const numstatOutput = await execGit(repoPath, ['show', sha, '--format=', '--numstat']);
|
|
119
|
+
const nameStatusOutput = await execGit(repoPath, ['show', sha, '--format=', '--name-status']);
|
|
120
|
+
const numstatLines = numstatOutput.trim().split('\n').filter(l => l.trim());
|
|
121
|
+
const nameStatusLines = nameStatusOutput.trim().split('\n').filter(l => l.trim());
|
|
122
|
+
const files = [];
|
|
123
|
+
const statsMap = new Map();
|
|
124
|
+
// Parse numstat (additions, deletions, path)
|
|
125
|
+
// Binary files show as "-\t-\tfilepath"
|
|
126
|
+
for (const line of numstatLines) {
|
|
127
|
+
const parts = line.split('\t');
|
|
128
|
+
if (parts.length >= 3) {
|
|
129
|
+
const added = parts[0];
|
|
130
|
+
const deleted = parts[1];
|
|
131
|
+
const pathParts = parts.slice(2);
|
|
132
|
+
const path = pathParts.join('\t'); // Handle paths with tabs (rare but possible)
|
|
133
|
+
const isBinary = added === '-' && deleted === '-';
|
|
134
|
+
statsMap.set(path, {
|
|
135
|
+
additions: isBinary ? 0 : parseInt(added, 10) || 0,
|
|
136
|
+
deletions: isBinary ? 0 : parseInt(deleted, 10) || 0,
|
|
137
|
+
binary: isBinary,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// Parse name-status (status, path, [oldPath for renames])
|
|
142
|
+
for (const line of nameStatusLines) {
|
|
143
|
+
const parts = line.split('\t');
|
|
144
|
+
if (parts.length < 2 || !parts[0] || !parts[1])
|
|
145
|
+
continue;
|
|
146
|
+
const statusChar = parts[0].charAt(0);
|
|
147
|
+
let status;
|
|
148
|
+
let path;
|
|
149
|
+
let oldPath;
|
|
150
|
+
switch (statusChar) {
|
|
151
|
+
case 'A':
|
|
152
|
+
status = 'added';
|
|
153
|
+
path = parts[1];
|
|
154
|
+
break;
|
|
155
|
+
case 'D':
|
|
156
|
+
status = 'deleted';
|
|
157
|
+
path = parts[1];
|
|
158
|
+
break;
|
|
159
|
+
case 'M':
|
|
160
|
+
status = 'modified';
|
|
161
|
+
path = parts[1];
|
|
162
|
+
break;
|
|
163
|
+
case 'R':
|
|
164
|
+
status = 'renamed';
|
|
165
|
+
oldPath = parts[1];
|
|
166
|
+
path = parts[2] || parts[1];
|
|
167
|
+
break;
|
|
168
|
+
case 'C':
|
|
169
|
+
status = 'added'; // Treat copy as added
|
|
170
|
+
path = parts[2] || parts[1];
|
|
171
|
+
break;
|
|
172
|
+
default:
|
|
173
|
+
status = 'modified';
|
|
174
|
+
path = parts[1];
|
|
175
|
+
}
|
|
176
|
+
// Get stats for this file
|
|
177
|
+
const stats = statsMap.get(path) ||
|
|
178
|
+
(oldPath ? statsMap.get(`${oldPath} => ${path}`) : undefined) ||
|
|
179
|
+
statsMap.get(`${oldPath}\t${path}`) ||
|
|
180
|
+
{ additions: 0, deletions: 0, binary: false };
|
|
181
|
+
files.push({
|
|
182
|
+
path,
|
|
183
|
+
status,
|
|
184
|
+
oldPath,
|
|
185
|
+
additions: stats.additions,
|
|
186
|
+
deletions: stats.deletions,
|
|
187
|
+
binary: stats.binary,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
return files;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Get diff hunks for a specific file in a commit
|
|
194
|
+
*/
|
|
195
|
+
export async function getFileDiff(repoPath, sha, filePath) {
|
|
196
|
+
// Validate SHA format
|
|
197
|
+
if (!/^[0-9a-f]{4,40}$/i.test(sha)) {
|
|
198
|
+
throw new Error(`Invalid commit SHA: ${sha}`);
|
|
199
|
+
}
|
|
200
|
+
// Validate path is within repo
|
|
201
|
+
if (!validatePathInRepo(repoPath, filePath)) {
|
|
202
|
+
throw new Error('File path is outside repository');
|
|
203
|
+
}
|
|
204
|
+
// Get diff for specific file
|
|
205
|
+
const output = await execGit(repoPath, [
|
|
206
|
+
'show', sha,
|
|
207
|
+
'--format=',
|
|
208
|
+
'--unified=3',
|
|
209
|
+
'--', filePath,
|
|
210
|
+
]);
|
|
211
|
+
return parseDiffHunks(output);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Parse git diff output into hunks
|
|
215
|
+
*/
|
|
216
|
+
function parseDiffHunks(diffOutput) {
|
|
217
|
+
const hunks = [];
|
|
218
|
+
const lines = diffOutput.split('\n');
|
|
219
|
+
let currentHunk = null;
|
|
220
|
+
let oldLineNum = 0;
|
|
221
|
+
let newLineNum = 0;
|
|
222
|
+
for (const line of lines) {
|
|
223
|
+
// Hunk header: @@ -oldStart,oldLines +newStart,newLines @@
|
|
224
|
+
const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
|
|
225
|
+
if (hunkMatch) {
|
|
226
|
+
if (currentHunk) {
|
|
227
|
+
hunks.push(currentHunk);
|
|
228
|
+
}
|
|
229
|
+
const oldStart = parseInt(hunkMatch[1], 10);
|
|
230
|
+
const oldLines = hunkMatch[2] ? parseInt(hunkMatch[2], 10) : 1;
|
|
231
|
+
const newStart = parseInt(hunkMatch[3], 10);
|
|
232
|
+
const newLines = hunkMatch[4] ? parseInt(hunkMatch[4], 10) : 1;
|
|
233
|
+
currentHunk = {
|
|
234
|
+
oldStart,
|
|
235
|
+
oldLines,
|
|
236
|
+
newStart,
|
|
237
|
+
newLines,
|
|
238
|
+
lines: [],
|
|
239
|
+
};
|
|
240
|
+
oldLineNum = oldStart;
|
|
241
|
+
newLineNum = newStart;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
// Skip diff headers
|
|
245
|
+
if (line.startsWith('diff --git') ||
|
|
246
|
+
line.startsWith('index ') ||
|
|
247
|
+
line.startsWith('---') ||
|
|
248
|
+
line.startsWith('+++') ||
|
|
249
|
+
line.startsWith('\\')) {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
// Parse diff lines
|
|
253
|
+
if (currentHunk) {
|
|
254
|
+
if (line.startsWith('+')) {
|
|
255
|
+
const diffLine = {
|
|
256
|
+
type: 'add',
|
|
257
|
+
content: line.substring(1),
|
|
258
|
+
newNumber: newLineNum++,
|
|
259
|
+
};
|
|
260
|
+
currentHunk.lines.push(diffLine);
|
|
261
|
+
}
|
|
262
|
+
else if (line.startsWith('-')) {
|
|
263
|
+
const diffLine = {
|
|
264
|
+
type: 'remove',
|
|
265
|
+
content: line.substring(1),
|
|
266
|
+
oldNumber: oldLineNum++,
|
|
267
|
+
};
|
|
268
|
+
currentHunk.lines.push(diffLine);
|
|
269
|
+
}
|
|
270
|
+
else if (line.startsWith(' ') || line === '') {
|
|
271
|
+
const diffLine = {
|
|
272
|
+
type: 'context',
|
|
273
|
+
content: line.substring(1),
|
|
274
|
+
oldNumber: oldLineNum++,
|
|
275
|
+
newNumber: newLineNum++,
|
|
276
|
+
};
|
|
277
|
+
currentHunk.lines.push(diffLine);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (currentHunk) {
|
|
282
|
+
hunks.push(currentHunk);
|
|
283
|
+
}
|
|
284
|
+
return hunks;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Check if a file is binary by attempting to get its diff
|
|
288
|
+
*/
|
|
289
|
+
export async function isBinaryFile(repoPath, sha, filePath) {
|
|
290
|
+
try {
|
|
291
|
+
const output = await execGit(repoPath, [
|
|
292
|
+
'show', sha,
|
|
293
|
+
'--format=',
|
|
294
|
+
'--', filePath,
|
|
295
|
+
]);
|
|
296
|
+
return output.includes('Binary files');
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Get file content at a specific commit
|
|
304
|
+
*/
|
|
305
|
+
export async function getFileContent(repoPath, sha, filePath) {
|
|
306
|
+
// Validate SHA format
|
|
307
|
+
if (!/^[0-9a-f]{4,40}$/i.test(sha)) {
|
|
308
|
+
throw new Error(`Invalid commit SHA: ${sha}`);
|
|
309
|
+
}
|
|
310
|
+
// Validate path is within repo
|
|
311
|
+
if (!validatePathInRepo(repoPath, filePath)) {
|
|
312
|
+
throw new Error('File path is outside repository');
|
|
313
|
+
}
|
|
314
|
+
// Get file content at commit
|
|
315
|
+
const output = await execGit(repoPath, ['show', `${sha}:${filePath}`]);
|
|
316
|
+
return output;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Check if a commit exists in a repository
|
|
320
|
+
*/
|
|
321
|
+
async function commitExistsInRepo(repoPath, sha) {
|
|
322
|
+
try {
|
|
323
|
+
await execGit(repoPath, ['cat-file', '-t', sha]);
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Find which repository contains a given commit SHA.
|
|
332
|
+
* Checks the root path first (if it's a git repo), then searches discovered repos in parallel.
|
|
333
|
+
*
|
|
334
|
+
* @param repoConfig - The repository configuration with optional gitRepos
|
|
335
|
+
* @param sha - The commit SHA to find
|
|
336
|
+
* @returns The GitRepoInfo where the commit was found, or throws if not found
|
|
337
|
+
*/
|
|
338
|
+
export async function findRepoForCommit(repoConfig, sha) {
|
|
339
|
+
// Validate SHA format
|
|
340
|
+
if (!/^[0-9a-f]{4,40}$/i.test(sha)) {
|
|
341
|
+
throw new Error(`Invalid commit SHA: ${sha}`);
|
|
342
|
+
}
|
|
343
|
+
// Check if root path is a git repo first
|
|
344
|
+
if (await isGitRepo(repoConfig.path)) {
|
|
345
|
+
if (await commitExistsInRepo(repoConfig.path, sha)) {
|
|
346
|
+
return {
|
|
347
|
+
sha,
|
|
348
|
+
repoPath: '',
|
|
349
|
+
absolutePath: repoConfig.path,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
// If no discovered repos, commit not found
|
|
354
|
+
if (!repoConfig.gitRepos || repoConfig.gitRepos.length === 0) {
|
|
355
|
+
throw new Error(`Commit ${sha.substring(0, 7)} not found in repository "${repoConfig.name}"`);
|
|
356
|
+
}
|
|
357
|
+
// Search discovered repos in parallel
|
|
358
|
+
const results = await Promise.all(repoConfig.gitRepos.map(async (gitRepo) => {
|
|
359
|
+
if (await commitExistsInRepo(gitRepo.absolutePath, sha)) {
|
|
360
|
+
return {
|
|
361
|
+
sha,
|
|
362
|
+
repoPath: gitRepo.relativePath,
|
|
363
|
+
absolutePath: gitRepo.absolutePath,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
return null;
|
|
367
|
+
}));
|
|
368
|
+
// Find first match
|
|
369
|
+
const found = results.find((r) => r !== null);
|
|
370
|
+
if (found) {
|
|
371
|
+
return found;
|
|
372
|
+
}
|
|
373
|
+
throw new Error(`Commit ${sha.substring(0, 7)} not found in repository "${repoConfig.name}" or any of its ${repoConfig.gitRepos.length} discovered git repos`);
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Resolve a commit entry (string or CommitRef) to its repository information.
|
|
377
|
+
* For CommitRef objects, returns immediately (O(1)).
|
|
378
|
+
* For string SHAs, searches repositories to find the commit.
|
|
379
|
+
*
|
|
380
|
+
* @param repoConfig - The repository configuration
|
|
381
|
+
* @param commitEntry - Either a commit SHA string or a CommitRef object
|
|
382
|
+
* @returns Resolved commit information with repo path
|
|
383
|
+
*/
|
|
384
|
+
export async function resolveCommitRepo(repoConfig, commitEntry) {
|
|
385
|
+
// If it's a CommitRef object, we already have the repo info (O(1))
|
|
386
|
+
if (typeof commitEntry === 'object' && commitEntry.sha && commitEntry.repo) {
|
|
387
|
+
return {
|
|
388
|
+
sha: commitEntry.sha,
|
|
389
|
+
repoPath: commitEntry.repo,
|
|
390
|
+
absolutePath: join(repoConfig.path, commitEntry.repo),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
// It's a string SHA, need to search for it
|
|
394
|
+
const sha = typeof commitEntry === 'string' ? commitEntry : commitEntry.sha;
|
|
395
|
+
return findRepoForCommit(repoConfig, sha);
|
|
396
|
+
}
|