quackstack 1.0.23 → 1.0.24

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/dist/cli.cjs CHANGED
@@ -12,6 +12,8 @@ const context_generator_js_1 = require("./lib/context-generator.js");
12
12
  const readme_js_1 = require("./commands/readme.js");
13
13
  const agents_js_1 = require("./commands/agents.js");
14
14
  const ai_provider_js_1 = require("./lib/ai-provider.js");
15
+ const database_js_1 = require("./lib/database.js");
16
+ const git_history_js_1 = require("./lib/git-history.js");
15
17
  const path_1 = __importDefault(require("path"));
16
18
  const program = new commander_1.Command();
17
19
  const PROJECT_NAME = path_1.default.basename(process.cwd());
@@ -85,4 +87,84 @@ program
85
87
  }
86
88
  await (0, repl_js_1.startREPL)(options.reindex, options.provider, options.model);
87
89
  });
90
+ program
91
+ .command("authors")
92
+ .description("Show contributor statistics for this project")
93
+ .action(async () => {
94
+ if (!git_history_js_1.gitHistory.isRepository()) {
95
+ console.log(chalk_1.default.red("āŒ Not a git repository"));
96
+ process.exit(1);
97
+ }
98
+ console.log(chalk_1.default.cyan("\nšŸ“Š Contributor Statistics\n"));
99
+ const authors = await (0, database_js_1.getProjectAuthors)(PROJECT_NAME);
100
+ if (authors.length === 0) {
101
+ console.log(chalk_1.default.yellow("No contributor data found. Run 'quack --reindex' first."));
102
+ process.exit(0);
103
+ }
104
+ authors.forEach((author, i) => {
105
+ console.log(chalk_1.default.green(`${i + 1}. ${author.author}`) + chalk_1.default.gray(` (${author.email})`));
106
+ console.log(chalk_1.default.white(` ${author.totalCommits} commits | +${author.linesAdded}/-${author.linesRemoved} lines`));
107
+ if (author.recentActivity) {
108
+ const daysAgo = Math.floor((Date.now() - author.recentActivity.getTime()) / (1000 * 60 * 60 * 24));
109
+ console.log(chalk_1.default.gray(` Last active ${daysAgo} days ago`));
110
+ }
111
+ if (author.filesOwned.length > 0) {
112
+ console.log(chalk_1.default.gray(` Owns ${author.filesOwned.length} files`));
113
+ }
114
+ console.log();
115
+ });
116
+ console.log(chalk_1.default.cyan(`Total: ${authors.length} contributors\n`));
117
+ });
118
+ program
119
+ .command("recent")
120
+ .description("Show recently modified files")
121
+ .option("-d, --days <number>", "Number of days to look back", "7")
122
+ .action(async (options) => {
123
+ if (!git_history_js_1.gitHistory.isRepository()) {
124
+ console.log(chalk_1.default.red("āŒ Not a git repository"));
125
+ process.exit(1);
126
+ }
127
+ const days = parseInt(options.days);
128
+ console.log(chalk_1.default.cyan(`\nšŸ“ Files modified in last ${days} days\n`));
129
+ const files = await (0, database_js_1.getRecentlyModifiedFiles)(PROJECT_NAME, days);
130
+ if (files.length === 0) {
131
+ console.log(chalk_1.default.yellow(`No files modified in last ${days} days (or run 'quack --reindex')`));
132
+ process.exit(0);
133
+ }
134
+ files.forEach((file, i) => {
135
+ const daysAgo = Math.floor((Date.now() - file.lastCommitDate.getTime()) / (1000 * 60 * 60 * 24));
136
+ console.log(chalk_1.default.green(`${i + 1}. ${file.filePath}`));
137
+ console.log(chalk_1.default.gray(` Modified by ${file.lastCommitAuthor} ${daysAgo} days ago`));
138
+ if (file.lastCommitMessage) {
139
+ console.log(chalk_1.default.white(` "${file.lastCommitMessage.substring(0, 60)}${file.lastCommitMessage.length > 60 ? '...' : ''}"`));
140
+ }
141
+ console.log();
142
+ });
143
+ console.log(chalk_1.default.cyan(`Total: ${files.length} files\n`));
144
+ });
145
+ program
146
+ .command("git-info")
147
+ .description("Show git repository information")
148
+ .action(() => {
149
+ if (!git_history_js_1.gitHistory.isRepository()) {
150
+ console.log(chalk_1.default.red("āŒ Not a git repository"));
151
+ process.exit(1);
152
+ }
153
+ console.log(chalk_1.default.cyan("\nšŸ” Git Repository Info\n"));
154
+ const branch = git_history_js_1.gitHistory.getCurrentBranch();
155
+ if (branch) {
156
+ console.log(chalk_1.default.white(`Current Branch: `) + chalk_1.default.green(branch));
157
+ }
158
+ const repoRoot = git_history_js_1.gitHistory.getRepositoryRoot();
159
+ console.log(chalk_1.default.white(`Repository Root: `) + chalk_1.default.gray(repoRoot));
160
+ console.log(chalk_1.default.cyan("\nšŸ“ˆ Recent Commits:\n"));
161
+ const commits = git_history_js_1.gitHistory.getRecentCommits(10);
162
+ commits.slice(0, 5).forEach((commit, i) => {
163
+ const date = new Date(commit.date).toLocaleDateString();
164
+ console.log(chalk_1.default.green(`${i + 1}. ${commit.author}`) + chalk_1.default.gray(` (${date})`));
165
+ console.log(chalk_1.default.white(` ${commit.message.substring(0, 70)}${commit.message.length > 70 ? '...' : ''}`));
166
+ console.log(chalk_1.default.gray(` ${commit.filesChanged.length} files changed`));
167
+ console.log();
168
+ });
169
+ });
88
170
  program.parse();
@@ -2,14 +2,19 @@ import fs from "fs";
2
2
  import path from "path";
3
3
  import { scanDir } from "../lib/scanner.js";
4
4
  import { chunkCode } from "../lib/chunker.js";
5
- import { saveToDB } from "../lib/database.js";
5
+ import { saveToDB, saveAuthorToDB } from "../lib/database.js";
6
6
  import { localEmbeddings } from "../lib/local-embeddings.js";
7
- export async function ingest(rootDir, projectName, silent = false) {
7
+ import { gitHistory } from "../lib/git-history.js";
8
+ export async function ingest(rootDir, projectName, silent = false, includeGitHistory = true) {
8
9
  if (!silent)
9
10
  console.log("Starting ingestion...");
10
11
  const files = await scanDir(rootDir);
11
12
  if (!silent)
12
13
  console.log(`Found ${files.length} files to process`);
14
+ const isGitRepo = gitHistory.isRepository();
15
+ if (isGitRepo && includeGitHistory && !silent) {
16
+ console.log(`Git repository detected - enriching with history data`);
17
+ }
13
18
  const allChunks = [];
14
19
  for (const filePath of files) {
15
20
  try {
@@ -31,10 +36,38 @@ export async function ingest(rootDir, projectName, silent = false) {
31
36
  console.log(`Saving to database...`);
32
37
  const BATCH_SIZE = 50;
33
38
  let processedCount = 0;
39
+ const fileGitData = new Map();
40
+ if (isGitRepo && includeGitHistory) {
41
+ for (const { filePath } of allChunks) {
42
+ if (!fileGitData.has(filePath)) {
43
+ const history = gitHistory.getFileHistory(filePath, 100);
44
+ fileGitData.set(filePath, history);
45
+ }
46
+ }
47
+ }
34
48
  for (let i = 0; i < allChunks.length; i += BATCH_SIZE) {
35
49
  const batch = allChunks.slice(i, i + BATCH_SIZE);
36
50
  await Promise.all(batch.map(async ({ content, filePath, chunk }) => {
37
51
  const embedding = localEmbeddings.getVector(content);
52
+ let gitMetadata = {};
53
+ if (isGitRepo && includeGitHistory) {
54
+ const history = fileGitData.get(filePath);
55
+ if (history && history.commits.length > 0) {
56
+ const lastCommit = history.commits[0];
57
+ const primaryAuthor = history.primaryAuthors[0];
58
+ gitMetadata = {
59
+ lastCommitHash: lastCommit.hash,
60
+ lastCommitAuthor: lastCommit.author,
61
+ lastCommitEmail: lastCommit.email,
62
+ lastCommitDate: lastCommit.date,
63
+ lastCommitMessage: lastCommit.message,
64
+ totalCommits: history.totalCommits,
65
+ primaryAuthor: primaryAuthor?.author,
66
+ primaryAuthorEmail: primaryAuthor?.email,
67
+ fileOwnerCommits: primaryAuthor?.commitCount,
68
+ };
69
+ }
70
+ }
38
71
  await saveToDB({
39
72
  content,
40
73
  embedding,
@@ -44,6 +77,7 @@ export async function ingest(rootDir, projectName, silent = false) {
44
77
  functionName: chunk.functionName,
45
78
  lineStart: chunk.lineStart,
46
79
  lineEnd: chunk.lineEnd,
80
+ ...gitMetadata,
47
81
  });
48
82
  }));
49
83
  processedCount += batch.length;
@@ -51,6 +85,34 @@ export async function ingest(rootDir, projectName, silent = false) {
51
85
  console.log(`Saved ${processedCount}/${allChunks.length} chunks...`);
52
86
  }
53
87
  }
54
- if (!silent)
88
+ if (isGitRepo && includeGitHistory && !silent) {
89
+ console.log("Computing author statistics...");
90
+ const authorStats = gitHistory.getAuthorStats();
91
+ for (const stats of authorStats) {
92
+ const ownedFiles = [];
93
+ fileGitData.forEach((history, filePath) => {
94
+ if (history?.primaryAuthors[0]?.email === stats.email) {
95
+ ownedFiles.push(path.relative(gitHistory.getRepositoryRoot(), filePath));
96
+ }
97
+ });
98
+ await saveAuthorToDB({
99
+ projectName,
100
+ author: stats.author,
101
+ email: stats.email,
102
+ totalCommits: stats.totalCommits,
103
+ linesAdded: stats.linesAdded,
104
+ linesRemoved: stats.linesRemoved,
105
+ recentActivity: stats.recentActivity,
106
+ filesOwned: ownedFiles,
107
+ });
108
+ }
109
+ if (!silent)
110
+ console.log(`Stored stats for ${authorStats.length} contributors`);
111
+ }
112
+ if (!silent) {
55
113
  console.log(`Done! Processed ${processedCount} chunks from ${files.length} files.`);
114
+ if (isGitRepo && includeGitHistory) {
115
+ console.log(`Git history enrichment complete`);
116
+ }
117
+ }
56
118
  }
@@ -2,21 +2,48 @@
2
2
  import { client } from "../lib/database.js";
3
3
  import { localEmbeddings } from "../lib/local-embeddings.js";
4
4
  import { getAIClient } from "../lib/ai-provider.js";
5
- export async function search(query, projectName, provider, model) {
5
+ export async function search(query, projectName, provider, model, options = {}) {
6
+ const { boostRecent = false, boostFrequent = false, filterAuthor, recentDays = 30, } = options;
7
+ const whereClause = { projectName };
8
+ if (filterAuthor) {
9
+ whereClause.OR = [
10
+ { lastCommitEmail: filterAuthor },
11
+ { primaryAuthorEmail: filterAuthor },
12
+ ];
13
+ }
6
14
  const snippets = await client.codeSnippet.findMany({
7
- where: { projectName },
15
+ where: whereClause,
8
16
  });
9
17
  const allContent = snippets.map(s => s.content);
10
18
  localEmbeddings.addDocuments(allContent);
11
19
  const queryVector = localEmbeddings.getVector(query);
12
20
  const ranked = snippets
13
- .map(snippet => ({
14
- id: snippet.id,
15
- content: snippet.content,
16
- filePath: snippet.filePath,
17
- functionName: snippet.functionName,
18
- score: localEmbeddings.cosineSimilarity(queryVector, snippet.embedding),
19
- }))
21
+ .map(snippet => {
22
+ let score = localEmbeddings.cosineSimilarity(queryVector, snippet.embedding);
23
+ if (boostRecent && snippet.lastCommitDate) {
24
+ const daysSinceCommit = (Date.now() - snippet.lastCommitDate.getTime()) / (1000 * 60 * 60 * 24);
25
+ if (daysSinceCommit <= recentDays) {
26
+ const recencyBoost = 1 + (0.2 * (1 - daysSinceCommit / recentDays));
27
+ score *= recencyBoost;
28
+ }
29
+ }
30
+ if (boostFrequent && snippet.totalCommits) {
31
+ const commitBoost = 1 + (Math.min(snippet.totalCommits, 50) / 50) * 0.15;
32
+ score *= commitBoost;
33
+ }
34
+ return {
35
+ id: snippet.id,
36
+ content: snippet.content,
37
+ filePath: snippet.filePath,
38
+ functionName: snippet.functionName,
39
+ score,
40
+ lastCommitAuthor: snippet.lastCommitAuthor,
41
+ lastCommitDate: snippet.lastCommitDate,
42
+ lastCommitMessage: snippet.lastCommitMessage,
43
+ totalCommits: snippet.totalCommits,
44
+ primaryAuthor: snippet.primaryAuthor,
45
+ };
46
+ })
20
47
  .sort((a, b) => b.score - a.score);
21
48
  const seenFiles = new Set();
22
49
  const uniqueResults = ranked.filter(item => {
@@ -26,9 +53,86 @@ export async function search(query, projectName, provider, model) {
26
53
  return true;
27
54
  }).slice(0, 5);
28
55
  const context = uniqueResults
29
- .map((r, i) => `[${i + 1}] ${r.filePath}${r.functionName ? ` (${r.functionName})` : ""}\n${r.content}`)
56
+ .map((r, i) => {
57
+ let entry = `[${i + 1}] ${r.filePath}${r.functionName ? ` (${r.functionName})` : ""}`;
58
+ if (r.lastCommitAuthor || r.primaryAuthor) {
59
+ const author = r.primaryAuthor || r.lastCommitAuthor;
60
+ entry += `\nPrimary Author: ${author}`;
61
+ if (r.totalCommits) {
62
+ entry += ` (${r.totalCommits} commits)`;
63
+ }
64
+ }
65
+ if (r.lastCommitDate) {
66
+ const daysAgo = Math.floor((Date.now() - r.lastCommitDate.getTime()) / (1000 * 60 * 60 * 24));
67
+ entry += `\nLast Modified: ${daysAgo} days ago`;
68
+ if (r.lastCommitMessage) {
69
+ entry += `\nLast Commit: "${r.lastCommitMessage.substring(0, 60)}${r.lastCommitMessage.length > 60 ? '...' : ''}"`;
70
+ }
71
+ }
72
+ entry += `\n\n${r.content}`;
73
+ return entry;
74
+ })
30
75
  .join("\n\n---\n\n");
31
76
  const aiClient = getAIClient(provider, model);
32
- const answer = await aiClient.generateAnswer(query, context);
77
+ const enhancedPrompt = query +
78
+ "\n\nNote: Code snippets include git history metadata (authors, commit counts, last modified dates). " +
79
+ "Use this information to provide context about code ownership and recency when relevant.";
80
+ const answer = await aiClient.generateAnswer(enhancedPrompt, context);
33
81
  return { answer, sources: uniqueResults };
34
82
  }
83
+ export async function searchByAuthor(authorEmail, projectName) {
84
+ const snippets = await client.codeSnippet.findMany({
85
+ where: {
86
+ projectName,
87
+ OR: [
88
+ { lastCommitEmail: authorEmail },
89
+ { primaryAuthorEmail: authorEmail },
90
+ ],
91
+ },
92
+ select: {
93
+ filePath: true,
94
+ functionName: true,
95
+ totalCommits: true,
96
+ lastCommitDate: true,
97
+ },
98
+ orderBy: {
99
+ totalCommits: "desc",
100
+ },
101
+ });
102
+ const fileMap = new Map();
103
+ snippets.forEach(s => {
104
+ if (!fileMap.has(s.filePath)) {
105
+ fileMap.set(s.filePath, s);
106
+ }
107
+ });
108
+ return Array.from(fileMap.values());
109
+ }
110
+ export async function getRecentActivity(projectName, days = 7) {
111
+ const since = new Date();
112
+ since.setDate(since.getDate() - days);
113
+ const snippets = await client.codeSnippet.findMany({
114
+ where: {
115
+ projectName,
116
+ lastCommitDate: {
117
+ gte: since,
118
+ },
119
+ },
120
+ select: {
121
+ filePath: true,
122
+ lastCommitAuthor: true,
123
+ lastCommitDate: true,
124
+ lastCommitMessage: true,
125
+ },
126
+ orderBy: {
127
+ lastCommitDate: "desc",
128
+ },
129
+ take: 20,
130
+ });
131
+ const fileMap = new Map();
132
+ snippets.forEach(s => {
133
+ if (!fileMap.has(s.filePath)) {
134
+ fileMap.set(s.filePath, s);
135
+ }
136
+ });
137
+ return Array.from(fileMap.values());
138
+ }
@@ -1,9 +1,11 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import os from "os";
4
- import { search } from "../commands/search.js";
4
+ import { search, getRecentActivity } from "../commands/search.js";
5
+ import { getProjectAuthors } from "../lib/database.js";
6
+ import { gitHistory } from "../lib/git-history.js";
5
7
  export async function generateCodebaseDoc(projectName) {
6
- console.log("šŸ” Analyzing your codebase...\n");
8
+ console.log("Analyzing your codebase...\n");
7
9
  const queries = [
8
10
  "What is the overall architecture and design patterns used?",
9
11
  "What are the main entry points and how does the application start?",
@@ -15,8 +17,24 @@ export async function generateCodebaseDoc(projectName) {
15
17
  let doc = `# ${projectName} - Codebase Documentation\n\n`;
16
18
  doc += `**Auto-generated by QuackStack** | Last updated: ${new Date().toLocaleString()}\n\n`;
17
19
  doc += `This document provides a high-level overview of the codebase architecture, key components, and design decisions.\n\n`;
18
- doc += `---\n\n`;
19
- doc += `## šŸ“‹ Table of Contents\n\n`;
20
+ if (gitHistory.isRepository()) {
21
+ doc += `## Repository Information\n\n`;
22
+ const currentBranch = gitHistory.getCurrentBranch();
23
+ if (currentBranch) {
24
+ doc += `**Current Branch:** ${currentBranch}\n\n`;
25
+ }
26
+ const recentCommits = gitHistory.getRecentCommits(10);
27
+ if (recentCommits.length > 0) {
28
+ doc += `**Recent Activity:**\n`;
29
+ recentCommits.slice(0, 5).forEach(commit => {
30
+ const date = new Date(commit.date).toLocaleDateString();
31
+ doc += `- ${date} - ${commit.author}: ${commit.message.substring(0, 60)}${commit.message.length > 60 ? '...' : ''}\n`;
32
+ });
33
+ doc += `\n`;
34
+ }
35
+ doc += `---\n\n`;
36
+ }
37
+ doc += `## Table of Contents\n\n`;
20
38
  queries.forEach((q, i) => {
21
39
  const anchor = q.toLowerCase().replace(/[^a-z0-9]+/g, '-');
22
40
  doc += `${i + 1}. [${q}](#${anchor})\n`;
@@ -28,12 +46,15 @@ export async function generateCodebaseDoc(projectName) {
28
46
  doc += `## ${query}\n\n`;
29
47
  doc += `${answer}\n\n`;
30
48
  if (sources.length > 0) {
31
- doc += `### šŸ“ Key Files\n\n`;
49
+ doc += `### Key Files\n\n`;
32
50
  sources.slice(0, 5).forEach(s => {
33
51
  doc += `- \`${s.filePath}\``;
34
52
  if (s.functionName) {
35
53
  doc += ` - ${s.functionName}`;
36
54
  }
55
+ if (s.primaryAuthor) {
56
+ doc += ` (maintained by ${s.primaryAuthor})`;
57
+ }
37
58
  doc += `\n`;
38
59
  });
39
60
  doc += `\n`;
@@ -44,7 +65,28 @@ export async function generateCodebaseDoc(projectName) {
44
65
  console.error(`Error analyzing: ${query}`);
45
66
  }
46
67
  }
47
- doc += `## šŸ“‚ Project Structure\n\n`;
68
+ if (gitHistory.isRepository()) {
69
+ doc += `## Contributors\n\n`;
70
+ const authors = await getProjectAuthors(projectName);
71
+ if (authors.length > 0) {
72
+ doc += `This project has ${authors.length} contributor${authors.length > 1 ? 's' : ''}:\n\n`;
73
+ authors.slice(0, 10).forEach((author, i) => {
74
+ doc += `${i + 1}. **${author.author}** (${author.email})\n`;
75
+ doc += ` - ${author.totalCommits} commits\n`;
76
+ doc += ` - +${author.linesAdded} / -${author.linesRemoved} lines\n`;
77
+ if (author.recentActivity) {
78
+ const daysAgo = Math.floor((Date.now() - author.recentActivity.getTime()) / (1000 * 60 * 60 * 24));
79
+ doc += ` - Last active ${daysAgo} days ago\n`;
80
+ }
81
+ if (author.filesOwned.length > 0) {
82
+ doc += ` - Primary owner of ${author.filesOwned.length} files\n`;
83
+ }
84
+ doc += `\n`;
85
+ });
86
+ doc += `---\n\n`;
87
+ }
88
+ }
89
+ doc += `## Project Structure\n\n`;
48
90
  doc += '```\n';
49
91
  doc += await getProjectStructure(process.cwd());
50
92
  doc += '```\n\n';
@@ -59,7 +101,7 @@ export async function generateCodebaseDoc(projectName) {
59
101
  doc += `### Running the Project\n`;
60
102
  doc += `Refer to \`package.json\` scripts section for available commands.\n\n`;
61
103
  doc += `---\n\n`;
62
- doc += `## šŸ”„ Updating This Document\n\n`;
104
+ doc += `## Updating This Document\n\n`;
63
105
  doc += `This documentation is auto-generated. To regenerate:\n\n`;
64
106
  doc += '```bash\n';
65
107
  doc += 'quack --docs\n';
@@ -81,6 +123,34 @@ export async function generateContextFiles(projectName) {
81
123
  let baseContext = `# ${projectName} - Codebase Context\n\n`;
82
124
  baseContext += `Generated: ${new Date().toISOString()}\n\n`;
83
125
  baseContext += "This file is auto-generated by QuackStack to provide AI assistants with codebase context.\n\n";
126
+ if (gitHistory.isRepository()) {
127
+ baseContext += `## Git Repository Info\n\n`;
128
+ const branch = gitHistory.getCurrentBranch();
129
+ if (branch) {
130
+ baseContext += `**Branch:** ${branch}\n\n`;
131
+ }
132
+ const recentActivity = await getRecentActivity(projectName, 7);
133
+ if (recentActivity.length > 0) {
134
+ baseContext += `**Recent Changes (Last 7 days):**\n\n`;
135
+ recentActivity.slice(0, 5).forEach(item => {
136
+ const daysAgo = Math.floor((Date.now() - item.lastCommitDate.getTime()) / (1000 * 60 * 60 * 24));
137
+ baseContext += `- ${item.filePath}\n`;
138
+ baseContext += ` - Modified by ${item.lastCommitAuthor} ${daysAgo} days ago\n`;
139
+ if (item.lastCommitMessage) {
140
+ baseContext += ` - "${item.lastCommitMessage.substring(0, 60)}${item.lastCommitMessage.length > 60 ? '...' : ''}"\n`;
141
+ }
142
+ });
143
+ baseContext += `\n`;
144
+ }
145
+ const authors = await getProjectAuthors(projectName);
146
+ if (authors.length > 0) {
147
+ baseContext += `**Top Contributors:**\n\n`;
148
+ authors.slice(0, 5).forEach(author => {
149
+ baseContext += `- ${author.author} (${author.totalCommits} commits, owns ${author.filesOwned.length} files)\n`;
150
+ });
151
+ baseContext += `\n`;
152
+ }
153
+ }
84
154
  baseContext += "---\n\n";
85
155
  for (const query of queries) {
86
156
  try {
@@ -89,7 +159,11 @@ export async function generateContextFiles(projectName) {
89
159
  if (sources.length > 0) {
90
160
  baseContext += "**Key files:**\n";
91
161
  sources.slice(0, 3).forEach(s => {
92
- baseContext += `- ${s.filePath}\n`;
162
+ baseContext += `- ${s.filePath}`;
163
+ if (s.primaryAuthor) {
164
+ baseContext += ` (${s.primaryAuthor})`;
165
+ }
166
+ baseContext += `\n`;
93
167
  });
94
168
  baseContext += "\n";
95
169
  }
@@ -100,12 +174,28 @@ export async function generateContextFiles(projectName) {
100
174
  }
101
175
  baseContext += "---\n\n## Project Structure\n\n";
102
176
  baseContext += await getProjectStructure(process.cwd());
177
+ if (gitHistory.isRepository()) {
178
+ const authors = await getProjectAuthors(projectName);
179
+ if (authors.length > 0) {
180
+ baseContext += "\n---\n\n## Code Ownership & Expertise\n\n";
181
+ baseContext += "When making changes, consider consulting these experts:\n\n";
182
+ for (const author of authors.slice(0, 5)) {
183
+ if (author.filesOwned.length > 0) {
184
+ baseContext += `**${author.author}** - Expert in:\n`;
185
+ author.filesOwned.slice(0, 5).forEach(file => {
186
+ baseContext += `- ${file}\n`;
187
+ });
188
+ baseContext += `\n`;
189
+ }
190
+ }
191
+ }
192
+ }
103
193
  await generateCursorRules(baseContext);
104
194
  await generateWindsurfContext(baseContext);
105
195
  await generateClineContext(baseContext);
106
196
  await generateContinueContext(baseContext);
107
197
  await generateAiderContext(baseContext);
108
- console.log("\nāœ… Context files generated for:");
198
+ console.log("\n Context files generated for:");
109
199
  console.log(" - Cursor (.cursorrules)");
110
200
  console.log(" - Windsurf (.windsurfrules)");
111
201
  console.log(" - Cline (.clinerules)");
@@ -138,11 +228,9 @@ async function generateAiderContext(context) {
138
228
  const aiderConfig = `# Aider configuration with QuackStack context
139
229
  # Project: ${path.basename(process.cwd())}
140
230
 
141
- # Context file
142
231
  read:
143
232
  - .aider.context.md
144
233
 
145
- # Model settings
146
234
  model: gpt-4o-mini
147
235
  edit-format: whole
148
236
  `;
@@ -166,6 +254,12 @@ export async function updateGlobalContext(projectName) {
166
254
  topFiles: sources.slice(0, 5).map(s => s.filePath),
167
255
  lastUpdated: new Date().toISOString(),
168
256
  };
257
+ if (gitHistory.isRepository()) {
258
+ const branch = gitHistory.getCurrentBranch();
259
+ const authors = await getProjectAuthors(projectName);
260
+ contexts[projectName].gitBranch = branch;
261
+ contexts[projectName].contributors = authors.length;
262
+ }
169
263
  fs.writeFileSync(contextPath, JSON.stringify(contexts, null, 2), "utf-8");
170
264
  }
171
265
  export function watchAndUpdateContext(projectName) {
@@ -180,7 +274,7 @@ export function watchAndUpdateContext(projectName) {
180
274
  await generateContextFiles(projectName);
181
275
  }, 5000);
182
276
  });
183
- console.log("šŸ‘€ Watching for file changes...");
277
+ console.log("Watching for file changes...");
184
278
  }
185
279
  async function getProjectStructure(dir, prefix = "", maxDepth = 3, currentDepth = 0) {
186
280
  if (currentDepth >= maxDepth)
@@ -1,41 +1,109 @@
1
1
  import { PrismaClient } from "@prisma/client";
2
2
  export const client = new PrismaClient();
3
- export const saveToDB = async (data) => {
4
- try {
5
- const result = await client.codeSnippet.create({
6
- data: {
7
- ...data,
8
- embedding: data.embedding,
3
+ export async function saveToDB(data) {
4
+ await client.codeSnippet.create({
5
+ data: {
6
+ content: data.content,
7
+ embedding: data.embedding,
8
+ filePath: data.filePath,
9
+ projectName: data.projectName,
10
+ language: data.language,
11
+ functionName: data.functionName,
12
+ lineStart: data.lineStart,
13
+ lineEnd: data.lineEnd,
14
+ lastCommitHash: data.lastCommitHash,
15
+ lastCommitAuthor: data.lastCommitAuthor,
16
+ lastCommitEmail: data.lastCommitEmail,
17
+ lastCommitDate: data.lastCommitDate,
18
+ lastCommitMessage: data.lastCommitMessage,
19
+ totalCommits: data.totalCommits,
20
+ primaryAuthor: data.primaryAuthor,
21
+ primaryAuthorEmail: data.primaryAuthorEmail,
22
+ fileOwnerCommits: data.fileOwnerCommits,
23
+ },
24
+ });
25
+ }
26
+ export async function saveAuthorToDB(data) {
27
+ await client.gitAuthor.upsert({
28
+ where: {
29
+ projectName_email: {
30
+ projectName: data.projectName,
31
+ email: data.email,
9
32
  },
10
- });
11
- return result;
12
- }
13
- catch (e) {
14
- if (e instanceof Error) {
15
- console.error(`Error saving to DB: ${e.message}`);
33
+ },
34
+ create: data,
35
+ update: {
36
+ author: data.author,
37
+ totalCommits: data.totalCommits,
38
+ linesAdded: data.linesAdded,
39
+ linesRemoved: data.linesRemoved,
40
+ recentActivity: data.recentActivity,
41
+ filesOwned: data.filesOwned,
42
+ },
43
+ });
44
+ }
45
+ export async function clearProject(projectName) {
46
+ await client.codeSnippet.deleteMany({
47
+ where: { projectName },
48
+ });
49
+ await client.gitAuthor.deleteMany({
50
+ where: { projectName },
51
+ });
52
+ }
53
+ export async function getProjectAuthors(projectName) {
54
+ return await client.gitAuthor.findMany({
55
+ where: { projectName },
56
+ orderBy: { totalCommits: "desc" },
57
+ });
58
+ }
59
+ export async function getAuthorFiles(projectName, authorEmail) {
60
+ const snippets = await client.codeSnippet.findMany({
61
+ where: {
62
+ projectName,
63
+ primaryAuthorEmail: authorEmail,
64
+ },
65
+ select: {
66
+ filePath: true,
67
+ functionName: true,
68
+ totalCommits: true,
69
+ },
70
+ orderBy: {
71
+ totalCommits: "desc",
72
+ },
73
+ });
74
+ const fileMap = new Map();
75
+ snippets.forEach(s => {
76
+ if (!fileMap.has(s.filePath)) {
77
+ fileMap.set(s.filePath, s);
16
78
  }
17
- else {
18
- console.error(`Unknown error saving to DB:`, e);
19
- }
20
- throw e;
21
- }
22
- };
23
- export const getFromDB = async (projectName) => {
24
- try {
25
- const results = await client.codeSnippet.findMany({
26
- where: {
27
- projectName: projectName
28
- }
29
- });
30
- return results;
31
- }
32
- catch (e) {
33
- if (e instanceof Error) {
34
- console.error(`Error fetching from DB: ${e.message}`);
35
- }
36
- else {
37
- console.error(`Unknown error fetching from DB:`, e);
79
+ });
80
+ return Array.from(fileMap.values());
81
+ }
82
+ export async function getRecentlyModifiedFiles(projectName, days = 7) {
83
+ const since = new Date();
84
+ since.setDate(since.getDate() - days);
85
+ const snippets = await client.codeSnippet.findMany({
86
+ where: {
87
+ projectName,
88
+ lastCommitDate: {
89
+ gte: since,
90
+ },
91
+ },
92
+ select: {
93
+ filePath: true,
94
+ lastCommitAuthor: true,
95
+ lastCommitDate: true,
96
+ lastCommitMessage: true,
97
+ },
98
+ orderBy: {
99
+ lastCommitDate: "desc",
100
+ },
101
+ });
102
+ const fileMap = new Map();
103
+ snippets.forEach(s => {
104
+ if (!fileMap.has(s.filePath)) {
105
+ fileMap.set(s.filePath, s);
38
106
  }
39
- throw e;
40
- }
41
- };
107
+ });
108
+ return Array.from(fileMap.values());
109
+ }
@@ -0,0 +1,312 @@
1
+ import { execSync } from "child_process";
2
+ import path from "path";
3
+ import fs from "fs";
4
+ export class GitHistory {
5
+ repoRoot;
6
+ isGitRepo;
7
+ constructor(projectPath = process.cwd()) {
8
+ this.repoRoot = this.findGitRoot(projectPath);
9
+ this.isGitRepo = this.repoRoot !== "";
10
+ }
11
+ findGitRoot(startPath) {
12
+ try {
13
+ const result = execSync("git rev-parse --show-toplevel", {
14
+ cwd: startPath,
15
+ encoding: "utf-8",
16
+ stdio: ["pipe", "pipe", "pipe"],
17
+ }).trim();
18
+ return result;
19
+ }
20
+ catch {
21
+ return "";
22
+ }
23
+ }
24
+ isRepository() {
25
+ return this.isGitRepo;
26
+ }
27
+ getFileHistory(filePath, limit = 10) {
28
+ if (!this.isGitRepo)
29
+ return null;
30
+ try {
31
+ const relativePath = path.relative(this.repoRoot, filePath);
32
+ // Get commit history for file
33
+ const logOutput = execSync(`git log --follow --format="%H|%an|%ae|%aI|%s" -n ${limit} -- "${relativePath}"`, {
34
+ cwd: this.repoRoot,
35
+ encoding: "utf-8",
36
+ stdio: ["pipe", "pipe", "pipe"],
37
+ }).trim();
38
+ if (!logOutput)
39
+ return null;
40
+ const commits = logOutput.split("\n").map(line => {
41
+ const [hash, author, email, date, ...messageParts] = line.split("|");
42
+ return {
43
+ hash,
44
+ author,
45
+ email,
46
+ date: new Date(date),
47
+ message: messageParts.join("|"),
48
+ filesChanged: [relativePath],
49
+ };
50
+ });
51
+ // Get author statistics
52
+ const authorMap = new Map();
53
+ commits.forEach(commit => {
54
+ const key = commit.email;
55
+ if (authorMap.has(key)) {
56
+ authorMap.get(key).commitCount++;
57
+ }
58
+ else {
59
+ authorMap.set(key, {
60
+ author: commit.author,
61
+ email: commit.email,
62
+ commitCount: 1,
63
+ });
64
+ }
65
+ });
66
+ const primaryAuthors = Array.from(authorMap.values())
67
+ .sort((a, b) => b.commitCount - a.commitCount);
68
+ return {
69
+ filePath: relativePath,
70
+ commits,
71
+ lastModified: commits[0]?.date || new Date(),
72
+ lastAuthor: commits[0]?.author || "Unknown",
73
+ totalCommits: commits.length,
74
+ primaryAuthors,
75
+ };
76
+ }
77
+ catch (error) {
78
+ console.error(`Error getting history for ${filePath}:`, error);
79
+ return null;
80
+ }
81
+ }
82
+ getBlameInfo(filePath) {
83
+ if (!this.isGitRepo)
84
+ return null;
85
+ try {
86
+ const relativePath = path.relative(this.repoRoot, filePath);
87
+ if (!fs.existsSync(filePath))
88
+ return null;
89
+ const blameOutput = execSync(`git blame --line-porcelain "${relativePath}"`, {
90
+ cwd: this.repoRoot,
91
+ encoding: "utf-8",
92
+ stdio: ["pipe", "pipe", "pipe"],
93
+ });
94
+ const lines = blameOutput.split("\n");
95
+ const blameInfo = [];
96
+ let currentInfo = {};
97
+ let lineNumber = 0;
98
+ for (const line of lines) {
99
+ if (line.match(/^[0-9a-f]{40}/)) {
100
+ if (currentInfo.commitHash) {
101
+ blameInfo.push(currentInfo);
102
+ }
103
+ currentInfo = {
104
+ commitHash: line.split(" ")[0],
105
+ lineNumber: ++lineNumber,
106
+ };
107
+ }
108
+ else if (line.startsWith("author ")) {
109
+ currentInfo.author = line.substring(7);
110
+ }
111
+ else if (line.startsWith("author-mail ")) {
112
+ currentInfo.email = line.substring(12).replace(/[<>]/g, "");
113
+ }
114
+ else if (line.startsWith("author-time ")) {
115
+ currentInfo.date = new Date(parseInt(line.substring(12)) * 1000);
116
+ }
117
+ }
118
+ if (currentInfo.commitHash) {
119
+ blameInfo.push(currentInfo);
120
+ }
121
+ return blameInfo;
122
+ }
123
+ catch (error) {
124
+ console.error(`Error getting blame for ${filePath}:`, error);
125
+ return null;
126
+ }
127
+ }
128
+ getRecentCommits(limit = 50) {
129
+ if (!this.isGitRepo)
130
+ return [];
131
+ try {
132
+ const logOutput = execSync(`git log --format="%H|%an|%ae|%aI|%s" --name-only -n ${limit}`, {
133
+ cwd: this.repoRoot,
134
+ encoding: "utf-8",
135
+ stdio: ["pipe", "pipe", "pipe"],
136
+ }).trim();
137
+ const commits = [];
138
+ const blocks = logOutput.split("\n\n");
139
+ for (const block of blocks) {
140
+ const lines = block.split("\n");
141
+ if (lines.length < 1)
142
+ continue;
143
+ const [hash, author, email, date, ...messageParts] = lines[0].split("|");
144
+ const filesChanged = lines.slice(1).filter(f => f.trim());
145
+ commits.push({
146
+ hash,
147
+ author,
148
+ email,
149
+ date: new Date(date),
150
+ message: messageParts.join("|"),
151
+ filesChanged,
152
+ });
153
+ }
154
+ return commits;
155
+ }
156
+ catch (error) {
157
+ console.error("Error getting recent commits:", error);
158
+ return [];
159
+ }
160
+ }
161
+ getAuthorStats() {
162
+ if (!this.isGitRepo)
163
+ return [];
164
+ try {
165
+ // Get basic author stats
166
+ const authorOutput = execSync(`git shortlog -sne --all`, {
167
+ cwd: this.repoRoot,
168
+ encoding: "utf-8",
169
+ stdio: ["pipe", "pipe", "pipe"],
170
+ }).trim();
171
+ const authorMap = new Map();
172
+ authorOutput.split("\n").forEach(line => {
173
+ const match = line.match(/^\s*(\d+)\s+(.+?)\s+<(.+?)>/);
174
+ if (match) {
175
+ const [, commits, author, email] = match;
176
+ authorMap.set(email, {
177
+ author,
178
+ email,
179
+ totalCommits: parseInt(commits),
180
+ filesOwned: [],
181
+ recentActivity: new Date(0),
182
+ linesAdded: 0,
183
+ linesRemoved: 0,
184
+ });
185
+ }
186
+ });
187
+ // Get recent activity for each author
188
+ authorMap.forEach((stats, email) => {
189
+ try {
190
+ const lastCommit = execSync(`git log --author="${email}" -1 --format="%aI"`, {
191
+ cwd: this.repoRoot,
192
+ encoding: "utf-8",
193
+ stdio: ["pipe", "pipe", "pipe"],
194
+ }).trim();
195
+ if (lastCommit) {
196
+ stats.recentActivity = new Date(lastCommit);
197
+ }
198
+ // Get line changes
199
+ const lineStats = execSync(`git log --author="${email}" --pretty=tformat: --numstat`, {
200
+ cwd: this.repoRoot,
201
+ encoding: "utf-8",
202
+ stdio: ["pipe", "pipe", "pipe"],
203
+ }).trim();
204
+ lineStats.split("\n").forEach(line => {
205
+ const [added, removed] = line.split("\t").map(n => parseInt(n) || 0);
206
+ stats.linesAdded += added;
207
+ stats.linesRemoved += removed;
208
+ });
209
+ }
210
+ catch {
211
+ // Ignore errors for individual authors
212
+ }
213
+ });
214
+ return Array.from(authorMap.values())
215
+ .sort((a, b) => b.totalCommits - a.totalCommits);
216
+ }
217
+ catch (error) {
218
+ console.error("Error getting author stats:", error);
219
+ return [];
220
+ }
221
+ }
222
+ getRecentlyChangedFiles(days = 7) {
223
+ if (!this.isGitRepo)
224
+ return [];
225
+ try {
226
+ const since = new Date();
227
+ since.setDate(since.getDate() - days);
228
+ const sinceStr = since.toISOString().split("T")[0];
229
+ const output = execSync(`git log --since="${sinceStr}" --format="%aI|%an" --name-only`, {
230
+ cwd: this.repoRoot,
231
+ encoding: "utf-8",
232
+ stdio: ["pipe", "pipe", "pipe"],
233
+ }).trim();
234
+ const fileMap = new Map();
235
+ const blocks = output.split("\n\n");
236
+ for (const block of blocks) {
237
+ const lines = block.split("\n");
238
+ if (lines.length < 1)
239
+ continue;
240
+ const [dateStr, author] = lines[0].split("|");
241
+ const date = new Date(dateStr);
242
+ const files = lines.slice(1).filter(f => f.trim());
243
+ files.forEach(file => {
244
+ if (!fileMap.has(file) || fileMap.get(file).lastModified < date) {
245
+ fileMap.set(file, { lastModified: date, author });
246
+ }
247
+ });
248
+ }
249
+ return Array.from(fileMap.entries())
250
+ .map(([filePath, info]) => ({ filePath, ...info }))
251
+ .sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
252
+ }
253
+ catch (error) {
254
+ console.error("Error getting recently changed files:", error);
255
+ return [];
256
+ }
257
+ }
258
+ getFileOwner(filePath) {
259
+ const history = this.getFileHistory(filePath, 100);
260
+ if (!history || history.primaryAuthors.length === 0)
261
+ return null;
262
+ return history.primaryAuthors[0];
263
+ }
264
+ getCommitMessage(commitHash) {
265
+ if (!this.isGitRepo)
266
+ return null;
267
+ try {
268
+ return execSync(`git log --format=%B -n 1 ${commitHash}`, {
269
+ cwd: this.repoRoot,
270
+ encoding: "utf-8",
271
+ stdio: ["pipe", "pipe", "pipe"],
272
+ }).trim();
273
+ }
274
+ catch {
275
+ return null;
276
+ }
277
+ }
278
+ hasUncommittedChanges(filePath) {
279
+ if (!this.isGitRepo)
280
+ return false;
281
+ try {
282
+ const relativePath = path.relative(this.repoRoot, filePath);
283
+ const status = execSync(`git status --porcelain "${relativePath}"`, {
284
+ cwd: this.repoRoot,
285
+ encoding: "utf-8",
286
+ stdio: ["pipe", "pipe", "pipe"],
287
+ }).trim();
288
+ return status.length > 0;
289
+ }
290
+ catch {
291
+ return false;
292
+ }
293
+ }
294
+ getCurrentBranch() {
295
+ if (!this.isGitRepo)
296
+ return null;
297
+ try {
298
+ return execSync(`git rev-parse --abbrev-ref HEAD`, {
299
+ cwd: this.repoRoot,
300
+ encoding: "utf-8",
301
+ stdio: ["pipe", "pipe", "pipe"],
302
+ }).trim();
303
+ }
304
+ catch {
305
+ return null;
306
+ }
307
+ }
308
+ getRepositoryRoot() {
309
+ return this.repoRoot;
310
+ }
311
+ }
312
+ export const gitHistory = new GitHistory();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quackstack",
3
- "version": "1.0.23",
3
+ "version": "1.0.24",
4
4
  "description": "Your cracked unpaid intern for all things codebase related! AI-powered codebase search and Q&A.",
5
5
  "type": "module",
6
6
  "main": "dist/cli.cjs",
@@ -1,9 +1,6 @@
1
1
  // This is your Prisma schema file,
2
2
  // learn more about it in the docs: https://pris.ly/d/prisma-schema
3
3
 
4
- // Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
5
- // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
6
-
7
4
  generator client {
8
5
  provider = "prisma-client-js"
9
6
  }
@@ -23,8 +20,40 @@ model codeSnippet {
23
20
  functionName String?
24
21
  lineStart Int?
25
22
  lineEnd Int?
23
+
24
+ lastCommitHash String?
25
+ lastCommitAuthor String?
26
+ lastCommitEmail String?
27
+ lastCommitDate DateTime?
28
+ lastCommitMessage String?
29
+ totalCommits Int? @default(0)
30
+ primaryAuthor String?
31
+ primaryAuthorEmail String?
32
+ fileOwnerCommits Int? @default(0)
33
+
34
+ createdAt DateTime @default(now())
35
+ updatedAt DateTime @updatedAt
36
+
37
+ @@index([projectName])
38
+ @@index([lastCommitDate])
39
+ @@index([primaryAuthor])
40
+ }
41
+
42
+ model gitAuthor {
43
+ id Int @id @default(autoincrement())
44
+ projectName String
45
+ author String
46
+ email String
47
+ totalCommits Int @default(0)
48
+ linesAdded Int @default(0)
49
+ linesRemoved Int @default(0)
50
+ recentActivity DateTime?
51
+ filesOwned String[]
52
+
26
53
  createdAt DateTime @default(now())
27
54
  updatedAt DateTime @updatedAt
28
55
 
56
+ @@unique([projectName, email])
29
57
  @@index([projectName])
58
+ @@index([recentActivity])
30
59
  }