resulgit 1.0.4 → 1.0.5

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/lib/blame.js ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Git Blame Implementation
3
+ * Shows line-by-line authorship information for files
4
+ */
5
+
6
+ /**
7
+ * Parse blame data from commit history
8
+ */
9
+ function parseBlame(fileContent, commits, filePath) {
10
+ const lines = fileContent.split(/\r?\n/)
11
+ const blameData = []
12
+
13
+ // For simplicity, we'll attribute all lines to the most recent commit
14
+ // In a full implementation, we'd track line-by-line changes through history
15
+ const latestCommit = commits.length > 0 ? commits[0] : null
16
+
17
+ for (let i = 0; i < lines.length; i++) {
18
+ blameData.push({
19
+ lineNumber: i + 1,
20
+ commitId: latestCommit?.id || latestCommit?._id || 'uncommitted',
21
+ author: latestCommit?.author?.name || 'Unknown',
22
+ authorEmail: latestCommit?.author?.email || '',
23
+ date: latestCommit?.createdAt || latestCommit?.committer?.date || new Date().toISOString(),
24
+ content: lines[i]
25
+ })
26
+ }
27
+
28
+ return blameData
29
+ }
30
+
31
+ /**
32
+ * Format blame output for terminal
33
+ */
34
+ function formatBlameOutput(blameData, options = {}) {
35
+ const { colors = true } = options
36
+ const COLORS = {
37
+ reset: '\x1b[0m',
38
+ dim: '\x1b[2m',
39
+ cyan: '\x1b[36m',
40
+ yellow: '\x1b[33m',
41
+ green: '\x1b[32m'
42
+ }
43
+
44
+ const lines = []
45
+ const maxLineNum = String(blameData.length).length
46
+
47
+ for (const line of blameData) {
48
+ const lineNum = String(line.lineNumber).padStart(maxLineNum, ' ')
49
+ const commitShort = line.commitId.slice(0, 8)
50
+ const author = line.author.slice(0, 20).padEnd(20, ' ')
51
+ const date = new Date(line.date).toISOString().split('T')[0]
52
+
53
+ if (colors) {
54
+ const formatted = `${COLORS.dim}${lineNum}${COLORS.reset} ` +
55
+ `${COLORS.yellow}${commitShort}${COLORS.reset} ` +
56
+ `${COLORS.cyan}${author}${COLORS.reset} ` +
57
+ `${COLORS.green}${date}${COLORS.reset} ` +
58
+ `${line.content}`
59
+ lines.push(formatted)
60
+ } else {
61
+ lines.push(`${lineNum} ${commitShort} ${author} ${date} ${line.content}`)
62
+ }
63
+ }
64
+
65
+ return lines.join('\n')
66
+ }
67
+
68
+ /**
69
+ * Format blame output as JSON
70
+ */
71
+ function formatBlameJson(blameData) {
72
+ return JSON.stringify(blameData, null, 2)
73
+ }
74
+
75
+ module.exports = {
76
+ parseBlame,
77
+ formatBlameOutput,
78
+ formatBlameJson
79
+ }
package/lib/errors.js ADDED
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Custom Error Classes for ResulGit
3
+ * Provides specific error types for better error handling and user feedback
4
+ */
5
+
6
+ /**
7
+ * Base error class for ResulGit
8
+ */
9
+ class ResulGitError extends Error {
10
+ constructor(message, code, details = {}) {
11
+ super(message)
12
+ this.name = 'ResulGitError'
13
+ this.code = code
14
+ this.details = details
15
+ Error.captureStackTrace(this, this.constructor)
16
+ }
17
+
18
+ toJSON() {
19
+ return {
20
+ error: this.name,
21
+ code: this.code,
22
+ message: this.message,
23
+ details: this.details
24
+ }
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Validation error
30
+ */
31
+ class ValidationError extends ResulGitError {
32
+ constructor(message, field) {
33
+ super(message, 'VALIDATION_ERROR', { field })
34
+ this.name = 'ValidationError'
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Authentication error
40
+ */
41
+ class AuthenticationError extends ResulGitError {
42
+ constructor(message) {
43
+ super(message, 'AUTH_ERROR')
44
+ this.name = 'AuthenticationError'
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Repository not found error
50
+ */
51
+ class RepositoryNotFoundError extends ResulGitError {
52
+ constructor(repoId) {
53
+ super(`Repository not found: ${repoId}`, 'REPO_NOT_FOUND', { repoId })
54
+ this.name = 'RepositoryNotFoundError'
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Branch not found error
60
+ */
61
+ class BranchNotFoundError extends ResulGitError {
62
+ constructor(branchName, repoId) {
63
+ super(`Branch not found: ${branchName}`, 'BRANCH_NOT_FOUND', { branchName, repoId })
64
+ this.name = 'BranchNotFoundError'
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Commit not found error
70
+ */
71
+ class CommitNotFoundError extends ResulGitError {
72
+ constructor(commitId) {
73
+ super(`Commit not found: ${commitId}`, 'COMMIT_NOT_FOUND', { commitId })
74
+ this.name = 'CommitNotFoundError'
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Merge conflict error
80
+ */
81
+ class ConflictError extends ResulGitError {
82
+ constructor(conflicts) {
83
+ const fileList = conflicts.map(c => c.path).join(', ')
84
+ super(`Merge conflicts in files: ${fileList}`, 'MERGE_CONFLICT', { conflicts })
85
+ this.name = 'ConflictError'
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Network error
91
+ */
92
+ class NetworkError extends ResulGitError {
93
+ constructor(message, statusCode, url) {
94
+ super(message, 'NETWORK_ERROR', { statusCode, url })
95
+ this.name = 'NetworkError'
96
+ }
97
+ }
98
+
99
+ /**
100
+ * File system error
101
+ */
102
+ class FileSystemError extends ResulGitError {
103
+ constructor(message, path, operation) {
104
+ super(message, 'FS_ERROR', { path, operation })
105
+ this.name = 'FileSystemError'
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Configuration error
111
+ */
112
+ class ConfigurationError extends ResulGitError {
113
+ constructor(message, configKey) {
114
+ super(message, 'CONFIG_ERROR', { configKey })
115
+ this.name = 'ConfigurationError'
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Remote repository error
121
+ */
122
+ class RemoteError extends ResulGitError {
123
+ constructor(message) {
124
+ super(message, 'REMOTE_ERROR')
125
+ this.name = 'RemoteError'
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Format error for display
131
+ */
132
+ function formatError(err, jsonMode = false) {
133
+ if (jsonMode) {
134
+ if (err instanceof ResulGitError) {
135
+ return JSON.stringify(err.toJSON(), null, 2)
136
+ }
137
+ return JSON.stringify({
138
+ error: 'Error',
139
+ message: err.message,
140
+ code: err.code || 'UNKNOWN_ERROR'
141
+ }, null, 2)
142
+ }
143
+
144
+ // Colorized output for terminal
145
+ const COLORS = {
146
+ red: '\x1b[31m',
147
+ yellow: '\x1b[33m',
148
+ reset: '\x1b[0m',
149
+ bold: '\x1b[1m'
150
+ }
151
+
152
+ let output = `${COLORS.bold}${COLORS.red}Error:${COLORS.reset} ${err.message}\n`
153
+
154
+ if (err instanceof ResulGitError && err.code) {
155
+ output += `${COLORS.yellow}Code:${COLORS.reset} ${err.code}\n`
156
+ }
157
+
158
+ if (err instanceof ConflictError && err.details.conflicts) {
159
+ output += `${COLORS.yellow}Conflicts:${COLORS.reset}\n`
160
+ for (const conflict of err.details.conflicts) {
161
+ output += ` - ${conflict.path}${conflict.line ? `:${conflict.line}` : ''}\n`
162
+ }
163
+ }
164
+
165
+ return output
166
+ }
167
+
168
+ /**
169
+ * Wrap async function with error handling
170
+ */
171
+ function wrapAsync(fn) {
172
+ return async function (...args) {
173
+ try {
174
+ return await fn(...args)
175
+ } catch (err) {
176
+ if (err instanceof ResulGitError) {
177
+ throw err
178
+ }
179
+
180
+ // Convert common errors to ResulGit errors
181
+ if (err.code === 'ENOENT') {
182
+ throw new FileSystemError(`File or directory not found: ${err.path}`, err.path, 'read')
183
+ }
184
+
185
+ if (err.code === 'EACCES') {
186
+ throw new FileSystemError(`Permission denied: ${err.path}`, err.path, 'access')
187
+ }
188
+
189
+ if (err.message && err.message.includes('fetch failed')) {
190
+ throw new NetworkError(err.message, null, null)
191
+ }
192
+
193
+ // Re-throw unknown errors
194
+ throw err
195
+ }
196
+ }
197
+ }
198
+
199
+ module.exports = {
200
+ ResulGitError,
201
+ ValidationError,
202
+ AuthenticationError,
203
+ RepositoryNotFoundError,
204
+ BranchNotFoundError,
205
+ CommitNotFoundError,
206
+ ConflictError,
207
+ NetworkError,
208
+ FileSystemError,
209
+ ConfigurationError,
210
+ RemoteError,
211
+ formatError,
212
+ wrapAsync
213
+ }
package/lib/hooks.js ADDED
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Git Hooks System
3
+ * Allows execution of custom scripts at specific points in the Git workflow
4
+ */
5
+
6
+ const fs = require('fs').promises
7
+ const path = require('path')
8
+ const { exec } = require('child_process')
9
+ const util = require('util')
10
+ const execPromise = util.promisify(exec)
11
+
12
+ /**
13
+ * Available hook types
14
+ */
15
+ const HOOK_TYPES = [
16
+ 'pre-commit',
17
+ 'post-commit',
18
+ 'pre-push',
19
+ 'post-push',
20
+ 'pre-merge',
21
+ 'post-merge',
22
+ 'pre-checkout',
23
+ 'post-checkout'
24
+ ]
25
+
26
+ /**
27
+ * Get hooks directory path
28
+ */
29
+ function getHooksDir(repoDir) {
30
+ return path.join(repoDir, '.git', 'hooks')
31
+ }
32
+
33
+ /**
34
+ * Check if a hook exists
35
+ */
36
+ async function hookExists(repoDir, hookName) {
37
+ if (!HOOK_TYPES.includes(hookName)) {
38
+ throw new Error(`Invalid hook type: ${hookName}. Valid types: ${HOOK_TYPES.join(', ')}`)
39
+ }
40
+
41
+ const hooksDir = getHooksDir(repoDir)
42
+ const hookPath = path.join(hooksDir, hookName)
43
+
44
+ try {
45
+ const stat = await fs.stat(hookPath)
46
+ return stat.isFile()
47
+ } catch {
48
+ return false
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Execute a hook
54
+ */
55
+ async function executeHook(repoDir, hookName, context = {}) {
56
+ if (!await hookExists(repoDir, hookName)) {
57
+ return { executed: false, output: null, error: null }
58
+ }
59
+
60
+ const hookPath = path.join(getHooksDir(repoDir), hookName)
61
+
62
+ try {
63
+ // Check if file is executable
64
+ const stat = await fs.stat(hookPath)
65
+ const isExecutable = (stat.mode & 0o111) !== 0
66
+
67
+ if (!isExecutable) {
68
+ return { executed: false, output: null, error: 'Hook is not executable' }
69
+ }
70
+
71
+ // Pass context as environment variables
72
+ const env = {
73
+ ...process.env,
74
+ RESULGIT_HOOK: hookName,
75
+ RESULGIT_REPO_DIR: repoDir,
76
+ ...Object.fromEntries(
77
+ Object.entries(context).map(([k, v]) => [`RESULGIT_${k.toUpperCase()}`, String(v)])
78
+ )
79
+ }
80
+
81
+ // Execute the hook
82
+ const { stdout, stderr } = await execPromise(hookPath, {
83
+ cwd: repoDir,
84
+ env,
85
+ timeout: 60000 // 60 second timeout
86
+ })
87
+
88
+ return {
89
+ executed: true,
90
+ output: stdout,
91
+ error: stderr || null,
92
+ exitCode: 0
93
+ }
94
+ } catch (err) {
95
+ return {
96
+ executed: true,
97
+ output: err.stdout || '',
98
+ error: err.stderr || err.message,
99
+ exitCode: err.code || 1
100
+ }
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Install a hook
106
+ */
107
+ async function installHook(repoDir, hookName, script) {
108
+ if (!HOOK_TYPES.includes(hookName)) {
109
+ throw new Error(`Invalid hook type: ${hookName}`)
110
+ }
111
+
112
+ const hooksDir = getHooksDir(repoDir)
113
+ await fs.mkdir(hooksDir, { recursive: true })
114
+
115
+ const hookPath = path.join(hooksDir, hookName)
116
+
117
+ // Ensure script has shebang
118
+ let finalScript = script
119
+ if (!script.startsWith('#!')) {
120
+ finalScript = '#!/bin/sh\n' + script
121
+ }
122
+
123
+ await fs.writeFile(hookPath, finalScript, { mode: 0o755 })
124
+
125
+ return { installed: true, path: hookPath }
126
+ }
127
+
128
+ /**
129
+ * Remove a hook
130
+ */
131
+ async function removeHook(repoDir, hookName) {
132
+ if (!HOOK_TYPES.includes(hookName)) {
133
+ throw new Error(`Invalid hook type: ${hookName}`)
134
+ }
135
+
136
+ const hookPath = path.join(getHooksDir(repoDir), hookName)
137
+
138
+ try {
139
+ await fs.unlink(hookPath)
140
+ return { removed: true }
141
+ } catch (err) {
142
+ if (err.code === 'ENOENT') {
143
+ return { removed: false, error: 'Hook does not exist' }
144
+ }
145
+ throw err
146
+ }
147
+ }
148
+
149
+ /**
150
+ * ListAll hooks
151
+ */
152
+ async function listHooks(repoDir) {
153
+ const hooksDir = getHooksDir(repoDir)
154
+ const hooks = []
155
+
156
+ try {
157
+ const files = await fs.readdir(hooksDir)
158
+
159
+ for (const file of files) {
160
+ if (HOOK_TYPES.includes(file)) {
161
+ const hookPath = path.join(hooksDir, file)
162
+ const stat = await fs.stat(hookPath)
163
+ const isExecutable = (stat.mode & 0o111) !== 0
164
+
165
+ hooks.push({
166
+ name: file,
167
+ path: hookPath,
168
+ executable: isExecutable,
169
+ size: stat.size
170
+ })
171
+ }
172
+ }
173
+ } catch (err) {
174
+ if (err.code !== 'ENOENT') {
175
+ throw err
176
+ }
177
+ }
178
+
179
+ return hooks
180
+ }
181
+
182
+ /**
183
+ * Read hook content
184
+ */
185
+ async function readHook(repoDir, hookName) {
186
+ if (!HOOK_TYPES.includes(hookName)) {
187
+ throw new Error(`Invalid hook type: ${hookName}`)
188
+ }
189
+
190
+ const hookPath = path.join(getHooksDir(repoDir), hookName)
191
+
192
+ try {
193
+ const content = await fs.readFile(hookPath, 'utf8')
194
+ return { content, path: hookPath }
195
+ } catch (err) {
196
+ if (err.code === 'ENOENT') {
197
+ return { content: null, error: 'Hook does not exist' }
198
+ }
199
+ throw err
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Sample hooks for common use cases
205
+ */
206
+ const SAMPLE_HOOKS = {
207
+ 'pre-commit': `#!/bin/sh
208
+ # Pre-commit hook: Run linting before commit
209
+ echo "Running pre-commit checks..."
210
+
211
+ # Example: Run linter
212
+ # npm run lint
213
+ # if [ $? -ne 0 ]; then
214
+ # echo "Linting failed. Commit aborted."
215
+ # exit 1
216
+ # fi
217
+
218
+ echo "Pre-commit checks passed!"
219
+ exit 0
220
+ `,
221
+ 'post-commit': `#!/bin/sh
222
+ # Post-commit hook: Log commit info
223
+ echo "Commit completed: $(git log -1 --pretty=%B)"
224
+ exit 0
225
+ `,
226
+ 'pre-push': `#!/bin/sh
227
+ # Pre-push hook: Run tests before push
228
+ echo "Running tests before push..."
229
+
230
+ # Example: Run tests
231
+ # npm test
232
+ # if [ $? -ne 0 ]; then
233
+ # echo "Tests failed. Push aborted."
234
+ # exit 1
235
+ # fi
236
+
237
+ echo "Tests passed!"
238
+ exit 0
239
+ `
240
+ }
241
+
242
+ module.exports = {
243
+ HOOK_TYPES,
244
+ hookExists,
245
+ executeHook,
246
+ installHook,
247
+ removeHook,
248
+ listHooks,
249
+ readHook,
250
+ SAMPLE_HOOKS
251
+ }
package/lib/log-viz.js ADDED
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Git Log Visualization
3
+ * Creates ASCII graph visualization of commit history
4
+ */
5
+
6
+ /**
7
+ * Generate ASCII graph for commit history
8
+ */
9
+ function generateLogGraph(commits, options = {}) {
10
+ const { includeGraph = true, colors = true, maxCommits = 50 } = options
11
+
12
+ const COLORS = {
13
+ reset: '\x1b[0m',
14
+ yellow: '\x1b[33m',
15
+ cyan: '\x1b[36m',
16
+ green: '\x1b[32m',
17
+ blue: '\x1b[34m',
18
+ red: '\x1b[31m',
19
+ dim: '\x1b[2m',
20
+ bold: '\x1b[1m'
21
+ }
22
+
23
+ const limitedCommits = commits.slice(0, maxCommits)
24
+ const lines = []
25
+
26
+ for (let i = 0; i < limitedCommits.length; i++) {
27
+ const commit = limitedCommits[i]
28
+ const isFirst = i === 0
29
+ const isLast = i === limitedCommits.length - 1
30
+
31
+ // Graph symbols
32
+ let graph = ''
33
+ if (includeGraph) {
34
+ if (isFirst) {
35
+ graph = '* '
36
+ } else if (isLast) {
37
+ graph = '* '
38
+ } else {
39
+ graph = '* '
40
+ }
41
+
42
+ // Add branch lines
43
+ if (!isLast) {
44
+ graph = `${graph}`
45
+ }
46
+ }
47
+
48
+ // Commit ID
49
+ const commitId = (commit.id || commit._id || '').slice(0, 7)
50
+ const coloredId = colors ? `${COLORS.yellow}${commitId}${COLORS.reset}` : commitId
51
+
52
+ // Author
53
+ const author = commit.author?.name || 'Unknown'
54
+ const authorShort = author.slice(0, 20)
55
+ const coloredAuthor = colors ? `${COLORS.cyan}${authorShort}${COLORS.reset}` : authorShort
56
+
57
+ // Date
58
+ const date = commit.createdAt || commit.committer?.date || ''
59
+ const dateObj = new Date(date)
60
+ const relativeDate = getRelativeDate(dateObj)
61
+ const coloredDate = colors ? `${COLORS.green}${relativeDate}${COLORS.reset}` : relativeDate
62
+
63
+ // Message
64
+ const message = (commit.message || 'No message').split('\n')[0].slice(0, 80)
65
+ const coloredMessage = colors ? `${COLORS.bold}${message}${COLORS.reset}` : message
66
+
67
+ // Branch tags (if any)
68
+ const tags = []
69
+ if (commit.branches && commit.branches.length > 0) {
70
+ tags.push(...commit.branches.map(b => `branch: ${b}`))
71
+ }
72
+ if (commit.tags && commit.tags.length > 0) {
73
+ tags.push(...commit.tags.map(t => `tag: ${t}`))
74
+ }
75
+
76
+ let tagStr = ''
77
+ if (tags.length > 0) {
78
+ const tagText = tags.join(', ')
79
+ tagStr = colors ? ` ${COLORS.red}(${tagText})${COLORS.reset}` : ` (${tagText})`
80
+ }
81
+
82
+ // Construct line
83
+ const line = `${graph}${coloredId} - ${coloredMessage}${tagStr} ${COLORS.dim}${coloredDate} ${coloredAuthor}${COLORS.reset}`
84
+ lines.push(line)
85
+
86
+ // Add connecting line if not last
87
+ if (!isLast && includeGraph) {
88
+ lines.push('|')
89
+ }
90
+ }
91
+
92
+ if (commits.length > maxCommits) {
93
+ lines.push('')
94
+ lines.push(`... ${commits.length - maxCommits} more commits`)
95
+ }
96
+
97
+ return lines.join('\n')
98
+ }
99
+
100
+ /**
101
+ * Get relative date string
102
+ */
103
+ function getRelativeDate(date) {
104
+ const now = new Date()
105
+ const diffMs = now - date
106
+ const diffSecs = Math.floor(diffMs / 1000)
107
+ const diffMins = Math.floor(diffSecs / 60)
108
+ const diffHours = Math.floor(diffMins / 60)
109
+ const diffDays = Math.floor(diffHours / 24)
110
+ const diffWeeks = Math.floor(diffDays / 7)
111
+ const diffMonths = Math.floor(diffDays / 30)
112
+ const diffYears = Math.floor(diffDays / 365)
113
+
114
+ if (diffYears > 0) return `${diffYears} year${diffYears > 1 ? 's' : ''} ago`
115
+ if (diffMonths > 0) return `${diffMonths} month${diffMonths > 1 ? 's' : ''} ago`
116
+ if (diffWeeks > 0) return `${diffWeeks} week${diffWeeks > 1 ? 's' : ''} ago`
117
+ if (diffDays > 0) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`
118
+ if (diffHours > 0) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`
119
+ if (diffMins > 0) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`
120
+ return 'just now'
121
+ }
122
+
123
+ /**
124
+ * Format compact log (one line per commit)
125
+ */
126
+ function formatCompactLog(commits, colors = true) {
127
+ const COLORS = {
128
+ reset: '\x1b[0m',
129
+ yellow: '\x1b[33m',
130
+ dim: '\x1b[2m'
131
+ }
132
+
133
+ return commits.map(commit => {
134
+ const id = (commit.id || commit._id || '').slice(0, 7)
135
+ const message = (commit.message || 'No message').split('\n')[0].slice(0, 60)
136
+
137
+ if (colors) {
138
+ return `${COLORS.yellow}${id}${COLORS.reset} ${message}`
139
+ }
140
+ return `${id} ${message}`
141
+ }).join('\n')
142
+ }
143
+
144
+ /**
145
+ * Generate commit statistics
146
+ */
147
+ function generateCommitStats(commits) {
148
+ const stats = {
149
+ totalCommits: commits.length,
150
+ authors: {},
151
+ datesRange: { earliest: null, latest: null },
152
+ commitsPerDay: {}
153
+ }
154
+
155
+ for (const commit of commits) {
156
+ // Author stats
157
+ const authorName = commit.author?.name || 'Unknown'
158
+ stats.authors[authorName] = (stats.authors[authorName] || 0) + 1
159
+
160
+ // Date stats
161
+ const date = new Date(commit.createdAt || commit.committer?.date || new Date())
162
+ if (!stats.datesRange.earliest || date < stats.datesRange.earliest) {
163
+ stats.datesRange.earliest = date
164
+ }
165
+ if (!stats.datesRange.latest || date > stats.datesRange.latest) {
166
+ stats.datesRange.latest = date
167
+ }
168
+
169
+ // Commits per day
170
+ const dayKey = date.toISOString().split('T')[0]
171
+ stats.commitsPerDay[dayKey] = (stats.commitsPerDay[dayKey] || 0) + 1
172
+ }
173
+
174
+ // Sort authors by commit count
175
+ stats.topAuthors = Object.entries(stats.authors)
176
+ .sort(([, a], [, b]) => b - a)
177
+ .slice(0, 5)
178
+ .map(([name, count]) => ({ name, commits: count }))
179
+
180
+ return stats
181
+ }
182
+
183
+ module.exports = {
184
+ generateLogGraph,
185
+ formatCompactLog,
186
+ generateCommitStats,
187
+ getRelativeDate
188
+ }
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Input Validation Module
3
+ * Provides validation functions for user inputs to prevent security issues
4
+ */
5
+
6
+ const path = require('path')
7
+
8
+ /**
9
+ * Validates repository ID format
10
+ */
11
+ function validateRepoId(repoId) {
12
+ if (!repoId || typeof repoId !== 'string') {
13
+ throw new Error('Invalid repository ID: must be a non-empty string')
14
+ }
15
+
16
+ // Allow alphanumeric, hyphens, underscores, and UUIDs
17
+ const validPattern = /^[a-zA-Z0-9_-]+$/
18
+ if (!validPattern.test(repoId)) {
19
+ throw new Error('Invalid repository ID: contains invalid characters')
20
+ }
21
+
22
+ if (repoId.length > 100) {
23
+ throw new Error('Invalid repository ID: too long (max 100 characters)')
24
+ }
25
+
26
+ return repoId.trim()
27
+ }
28
+
29
+ /**
30
+ * Validates branch name format
31
+ */
32
+ function validateBranchName(branchName) {
33
+ if (!branchName || typeof branchName !== 'string') {
34
+ throw new Error('Invalid branch name: must be a non-empty string')
35
+ }
36
+
37
+ const trimmed = branchName.trim()
38
+
39
+ // Git branch naming rules
40
+ const invalidPatterns = [
41
+ /\.\./, // No double dots
42
+ /\/$/, // Cannot end with /
43
+ /^\//, // Cannot start with /
44
+ /\@\{/, // No @{
45
+ /\\/, // No backslash
46
+ /[\x00-\x1f\x7f]/, // No control characters
47
+ /[\s~^:?*\[]/, // No whitespace or special chars
48
+ ]
49
+
50
+ for (const pattern of invalidPatterns) {
51
+ if (pattern.test(trimmed)) {
52
+ throw new Error(`Invalid branch name: "${branchName}" violates Git naming rules`)
53
+ }
54
+ }
55
+
56
+ if (trimmed.length > 255) {
57
+ throw new Error('Invalid branch name: too long (max 255 characters)')
58
+ }
59
+
60
+ return trimmed
61
+ }
62
+
63
+ /**
64
+ * Validates file path to prevent path traversal attacks
65
+ */
66
+ function validateFilePath(filePath, allowAbsolute = false) {
67
+ if (!filePath || typeof filePath !== 'string') {
68
+ throw new Error('Invalid file path: must be a non-empty string')
69
+ }
70
+
71
+ const normalized = path.normalize(filePath)
72
+
73
+ // Prevent path traversal
74
+ if (normalized.includes('..')) {
75
+ throw new Error('Invalid file path: path traversal detected')
76
+ }
77
+
78
+ // Check for absolute paths if not allowed
79
+ if (!allowAbsolute && path.isAbsolute(normalized)) {
80
+ throw new Error('Invalid file path: absolute paths not allowed')
81
+ }
82
+
83
+ // Prevent null bytes
84
+ if (normalized.includes('\0')) {
85
+ throw new Error('Invalid file path: null byte detected')
86
+ }
87
+
88
+ // Check length
89
+ if (normalized.length > 4096) {
90
+ throw new Error('Invalid file path: too long (max 4096 characters)')
91
+ }
92
+
93
+ return normalized
94
+ }
95
+
96
+ /**
97
+ * Validates commit message
98
+ */
99
+ function validateCommitMessage(message) {
100
+ if (!message || typeof message !== 'string') {
101
+ throw new Error('Invalid commit message: must be a non-empty string')
102
+ }
103
+
104
+ const trimmed = message.trim()
105
+
106
+ if (trimmed.length === 0) {
107
+ throw new Error('Invalid commit message: cannot be empty or whitespace only')
108
+ }
109
+
110
+ if (trimmed.length > 10000) {
111
+ throw new Error('Invalid commit message: too long (max 10000 characters)')
112
+ }
113
+
114
+ return trimmed
115
+ }
116
+
117
+ /**
118
+ * Validates email address format
119
+ */
120
+ function validateEmail(email) {
121
+ if (!email || typeof email !== 'string') {
122
+ throw new Error('Invalid email: must be a non-empty string')
123
+ }
124
+
125
+ const trimmed = email.trim()
126
+
127
+ // Basic email validation
128
+ const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
129
+ if (!emailPattern.test(trimmed)) {
130
+ throw new Error('Invalid email format')
131
+ }
132
+
133
+ if (trimmed.length > 254) {
134
+ throw new Error('Invalid email: too long (max 254 characters)')
135
+ }
136
+
137
+ return trimmed
138
+ }
139
+
140
+ /**
141
+ * Validates URL format
142
+ */
143
+ function validateUrl(url) {
144
+ if (!url || typeof url !== 'string') {
145
+ throw new Error('Invalid URL: must be a non-empty string')
146
+ }
147
+
148
+ try {
149
+ const parsed = new URL(url)
150
+
151
+ // Only allow http and https
152
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
153
+ throw new Error('Invalid URL: only HTTP and HTTPS protocols are allowed')
154
+ }
155
+
156
+ return url
157
+ } catch (err) {
158
+ throw new Error(`Invalid URL format: ${err.message}`)
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Validates username format
164
+ */
165
+ function validateUsername(username) {
166
+ if (!username || typeof username !== 'string') {
167
+ throw new Error('Invalid username: must be a non-empty string')
168
+ }
169
+
170
+ const trimmed = username.trim()
171
+
172
+ // Alphanumeric, hyphens, underscores only
173
+ const validPattern = /^[a-zA-Z0-9_-]+$/
174
+ if (!validPattern.test(trimmed)) {
175
+ throw new Error('Invalid username: only alphanumeric characters, hyphens, and underscores allowed')
176
+ }
177
+
178
+ if (trimmed.length < 3) {
179
+ throw new Error('Invalid username: too short (min 3 characters)')
180
+ }
181
+
182
+ if (trimmed.length > 39) {
183
+ throw new Error('Invalid username: too long (max 39 characters)')
184
+ }
185
+
186
+ return trimmed
187
+ }
188
+
189
+ /**
190
+ * Validates commit ID (hash) format
191
+ */
192
+ function validateCommitId(commitId) {
193
+ if (!commitId || typeof commitId !== 'string') {
194
+ throw new Error('Invalid commit ID: must be a non-empty string')
195
+ }
196
+
197
+ const trimmed = commitId.trim()
198
+
199
+ // Allow HEAD or valid hex hash
200
+ if (trimmed.toLowerCase() === 'head') {
201
+ return trimmed.toUpperCase()
202
+ }
203
+
204
+ // SHA-1 hash (40 chars) or short hash (7-40 chars)
205
+ const hashPattern = /^[a-f0-9]{7,40}$/i
206
+ if (!hashPattern.test(trimmed)) {
207
+ throw new Error('Invalid commit ID: must be "HEAD" or a valid commit hash')
208
+ }
209
+
210
+ return trimmed
211
+ }
212
+
213
+ /**
214
+ * Validates repository name
215
+ */
216
+ function validateRepoName(name) {
217
+ if (!name || typeof name !== 'string') {
218
+ throw new Error('Invalid repository name: must be a non-empty string')
219
+ }
220
+
221
+ const trimmed = name.trim()
222
+
223
+ if (trimmed.length < 1) {
224
+ throw new Error('Invalid repository name: cannot be empty')
225
+ }
226
+
227
+ if (trimmed.length > 100) {
228
+ throw new Error('Invalid repository name: too long (max 100 characters)')
229
+ }
230
+
231
+ // Prevent path traversal in repo names
232
+ if (trimmed.includes('..') || trimmed.includes('/') || trimmed.includes('\\')) {
233
+ throw new Error('Invalid repository name: cannot contain path separators or ".."')
234
+ }
235
+
236
+ return trimmed
237
+ }
238
+
239
+ /**
240
+ * Sanitizes text input for display
241
+ */
242
+ function sanitizeText(text) {
243
+ if (typeof text !== 'string') return String(text)
244
+
245
+ // Remove control characters except newline and tab
246
+ return text.replace(/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/g, '')
247
+ }
248
+
249
+ module.exports = {
250
+ validateRepoId,
251
+ validateBranchName,
252
+ validateFilePath,
253
+ validateCommitMessage,
254
+ validateEmail,
255
+ validateUrl,
256
+ validateUsername,
257
+ validateCommitId,
258
+ validateRepoName,
259
+ sanitizeText
260
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "resulgit",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "A powerful CLI tool for version control system operations - clone, commit, push, pull, merge, branch management, and more",
5
5
  "main": "resulgit.js",
6
6
  "bin": {
@@ -34,7 +34,8 @@
34
34
  },
35
35
  "files": [
36
36
  "resulgit.js",
37
- "README.md"
37
+ "README.md",
38
+ "lib"
38
39
  ],
39
40
  "dependencies": {
40
41
  "ora": "^5.4.1"
@@ -42,4 +43,4 @@
42
43
  "devDependencies": {
43
44
  "jest": "^29.7.0"
44
45
  }
45
- }
46
+ }