resulgit 1.0.2 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +40 -1
  2. package/package.json +1 -1
  3. package/resulgit.js +696 -8
package/README.md CHANGED
@@ -90,11 +90,29 @@ resulgit <command> [options]
90
90
  - `resulgit pr create --title <title> [--source <branch>] [--target <branch>]` - Create pull request
91
91
  - `resulgit pr merge --id <id>` - Merge a pull request
92
92
 
93
- ### Information
93
+ ### Information & Inspection
94
94
 
95
95
  - `resulgit current` - Show current repository and branch
96
96
  - `resulgit head` - Show HEAD commit ID
97
97
  - `resulgit show --commit <id>` - Show commit details
98
+ - `resulgit log [--branch <name>] [--max <N>] [--oneline] [--stats]` - Show commit history with visualization
99
+ - `resulgit blame --path <file>` - Show line-by-line authorship information
100
+ - `resulgit grep --pattern <pattern> [--ignore-case]` - Search for patterns in repository
101
+ - `resulgit ls-files` - List all tracked files
102
+ - `resulgit reflog` - Show reference log history
103
+ - `resulgit cat-file --type <type> --object <id> [--path <file>]` - Display file/commit contents
104
+ - `resulgit rev-parse --rev <ref>` - Parse revision names to commit IDs
105
+ - `resulgit describe [--commit <id>]` - Describe a commit with nearest tag
106
+ - `resulgit shortlog [--branch <name>] [--max <N>]` - Summarize commit log by author
107
+
108
+ ### Git Hooks
109
+
110
+ - `resulgit hook list` - List installed hooks
111
+ - `resulgit hook install --name <hook> [--script <code>] [--sample]` - Install a hook
112
+ - `resulgit hook remove --name <hook>` - Remove a hook
113
+ - `resulgit hook show --name <hook>` - Show hook content
114
+
115
+ **Available hooks:** `pre-commit`, `post-commit`, `pre-push`, `post-push`, `pre-merge`, `post-merge`, `pre-checkout`, `post-checkout`
98
116
 
99
117
  ## Global Options
100
118
 
@@ -128,6 +146,27 @@ resulgit branch create --name feature-branch
128
146
 
129
147
  # Merge branches
130
148
  resulgit merge --branch feature-branch
149
+
150
+ # View commit history with graph
151
+ resulgit log --max 20
152
+
153
+ # View commit history in one line
154
+ resulgit log --oneline
155
+
156
+ # View commit statistics
157
+ resulgit log --stats
158
+
159
+ # Show line-by-line authorship
160
+ resulgit blame --path src/index.js
161
+
162
+ # Search in repository
163
+ resulgit grep --pattern "TODO"
164
+
165
+ # List tracked files
166
+ resulgit ls-files
167
+
168
+ # Install a pre-commit hook
169
+ resulgit hook install --name pre-commit --sample
131
170
  ```
132
171
 
133
172
  ## Configuration
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "resulgit",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
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": {
package/resulgit.js CHANGED
@@ -4,6 +4,11 @@ const path = require('path')
4
4
  const os = require('os')
5
5
  const crypto = require('crypto')
6
6
  const ora = require('ora')
7
+ const validation = require('./lib/validation')
8
+ const errors = require('./lib/errors')
9
+ const { parseBlame, formatBlameOutput, formatBlameJson } = require('./lib/blame')
10
+ const { generateLogGraph, formatCompactLog, generateCommitStats } = require('./lib/log-viz')
11
+ const hooks = require('./lib/hooks')
7
12
  const COLORS = { reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', magenta: '\x1b[35m', cyan: '\x1b[36m' }
8
13
  function color(str, c) { return (COLORS[c] || '') + String(str) + COLORS.reset }
9
14
 
@@ -526,6 +531,73 @@ async function cmdDiff(opts) {
526
531
 
527
532
  const filePath = opts.path
528
533
  const commitId = opts.commit
534
+ const commit1 = opts.commit1
535
+ const commit2 = opts.commit2
536
+ const showStat = opts.stat === 'true'
537
+
538
+ // Handle diff between two commits: git diff <commit1> <commit2>
539
+ if (commit1 && commit2) {
540
+ const snap1 = await fetchSnapshotByCommit(server, meta.repoId, commit1, token)
541
+ const snap2 = await fetchSnapshotByCommit(server, meta.repoId, commit2, token)
542
+ const files = filePath ? [filePath] : Object.keys(new Set([...Object.keys(snap1.files), ...Object.keys(snap2.files)]))
543
+
544
+ let added = 0, deleted = 0, modified = 0
545
+ const stats = []
546
+
547
+ for (const p of files) {
548
+ const content1 = snap1.files[p] !== undefined ? String(snap1.files[p]) : null
549
+ const content2 = snap2.files[p] !== undefined ? String(snap2.files[p]) : null
550
+ if (content1 !== content2) {
551
+ if (content1 === null) {
552
+ added++
553
+ if (showStat) stats.push({ path: p, added: content2.split(/\r?\n/).length, deleted: 0 })
554
+ } else if (content2 === null) {
555
+ deleted++
556
+ if (showStat) stats.push({ path: p, added: 0, deleted: content1.split(/\r?\n/).length })
557
+ } else {
558
+ modified++
559
+ const lines1 = content1.split(/\r?\n/)
560
+ const lines2 = content2.split(/\r?\n/)
561
+ const diff = Math.abs(lines2.length - lines1.length)
562
+ if (showStat) stats.push({ path: p, added: lines2.length > lines1.length ? diff : 0, deleted: lines1.length > lines2.length ? diff : 0 })
563
+ }
564
+
565
+ if (!showStat) {
566
+ if (opts.json === 'true') {
567
+ print({ path: p, old: content1, new: content2 }, true)
568
+ } else {
569
+ process.stdout.write(color(`diff --git a/${p} b/${p}\n`, 'dim'))
570
+ // Show full diff (same as before)
571
+ const oldLines = content1 ? content1.split(/\r?\n/) : []
572
+ const newLines = content2 ? content2.split(/\r?\n/) : []
573
+ const maxLen = Math.max(oldLines.length, newLines.length)
574
+ for (let i = 0; i < maxLen; i++) {
575
+ const oldLine = oldLines[i]
576
+ const newLine = newLines[i]
577
+ if (oldLine !== newLine) {
578
+ if (oldLine !== undefined) process.stdout.write(color(`-${oldLine}\n`, 'red'))
579
+ if (newLine !== undefined) process.stdout.write(color(`+${newLine}\n`, 'green'))
580
+ } else if (oldLine !== undefined) {
581
+ process.stdout.write(` ${oldLine}\n`)
582
+ }
583
+ }
584
+ }
585
+ }
586
+ }
587
+ }
588
+
589
+ if (showStat) {
590
+ if (opts.json === 'true') {
591
+ print({ added, deleted, modified, files: stats }, true)
592
+ } else {
593
+ for (const stat of stats) {
594
+ process.stdout.write(` ${stat.path} | ${stat.added + stat.deleted} ${stat.added > 0 ? color('+' + stat.added, 'green') : ''}${stat.deleted > 0 ? color('-' + stat.deleted, 'red') : ''}\n`)
595
+ }
596
+ process.stdout.write(` ${stats.length} file${stats.length !== 1 ? 's' : ''} changed, ${added} insertion${added !== 1 ? 's' : ''}(+), ${deleted} deletion${deleted !== 1 ? 's' : ''}(-)\n`)
597
+ }
598
+ }
599
+ return
600
+ }
529
601
 
530
602
  if (commitId) {
531
603
  // Show diff for specific commit
@@ -535,6 +607,40 @@ async function cmdDiff(opts) {
535
607
  const parentSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {}, commitId: '' }
536
608
 
537
609
  const files = filePath ? [filePath] : Object.keys(new Set([...Object.keys(parentSnap.files), ...Object.keys(commitSnap.files)]))
610
+
611
+ if (showStat) {
612
+ let added = 0, deleted = 0, modified = 0
613
+ const stats = []
614
+ for (const p of files) {
615
+ const oldContent = parentSnap.files[p] !== undefined ? String(parentSnap.files[p]) : null
616
+ const newContent = commitSnap.files[p] !== undefined ? String(commitSnap.files[p]) : null
617
+ if (oldContent !== newContent) {
618
+ if (oldContent === null) {
619
+ added++
620
+ if (showStat) stats.push({ path: p, added: newContent.split(/\r?\n/).length, deleted: 0 })
621
+ } else if (newContent === null) {
622
+ deleted++
623
+ if (showStat) stats.push({ path: p, added: 0, deleted: oldContent.split(/\r?\n/).length })
624
+ } else {
625
+ modified++
626
+ const oldLines = oldContent.split(/\r?\n/)
627
+ const newLines = newContent.split(/\r?\n/)
628
+ const diff = Math.abs(newLines.length - oldLines.length)
629
+ if (showStat) stats.push({ path: p, added: newLines.length > oldLines.length ? diff : 0, deleted: oldLines.length > newLines.length ? diff : 0 })
630
+ }
631
+ }
632
+ }
633
+ if (opts.json === 'true') {
634
+ print({ added, deleted, modified, files: stats }, true)
635
+ } else {
636
+ for (const stat of stats) {
637
+ process.stdout.write(` ${stat.path} | ${stat.added + stat.deleted} ${stat.added > 0 ? color('+' + stat.added, 'green') : ''}${stat.deleted > 0 ? color('-' + stat.deleted, 'red') : ''}\n`)
638
+ }
639
+ process.stdout.write(` ${stats.length} file${stats.length !== 1 ? 's' : ''} changed, ${added} insertion${added !== 1 ? 's' : ''}(+), ${deleted} deletion${deleted !== 1 ? 's' : ''}(-)\n`)
640
+ }
641
+ return
642
+ }
643
+
538
644
  for (const p of files) {
539
645
  const oldContent = parentSnap.files[p] !== undefined ? String(parentSnap.files[p]) : null
540
646
  const newContent = commitSnap.files[p] !== undefined ? String(commitSnap.files[p]) : null
@@ -620,6 +726,31 @@ async function cmdRm(opts) {
620
726
  const cfg = loadConfig()
621
727
  const server = getServer(opts, cfg) || meta.server
622
728
  const token = getToken(opts, cfg) || meta.token
729
+
730
+ // Handle --cached flag (remove from index but keep file)
731
+ if (opts.cached === 'true') {
732
+ // Mark file for deletion in next commit but don't delete from filesystem
733
+ const metaDir = path.join(dir, '.vcs-next')
734
+ const localPath = path.join(metaDir, 'local.json')
735
+ let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null, removedFiles: [] }
736
+ try {
737
+ const s = await fs.promises.readFile(localPath, 'utf8')
738
+ localMeta = JSON.parse(s)
739
+ } catch {}
740
+ if (!localMeta.removedFiles) localMeta.removedFiles = []
741
+ if (!localMeta.removedFiles.includes(pathArg)) {
742
+ localMeta.removedFiles.push(pathArg)
743
+ }
744
+ await fs.promises.mkdir(metaDir, { recursive: true })
745
+ await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
746
+ if (opts.json === 'true') {
747
+ print({ removed: pathArg, cached: true }, true)
748
+ } else {
749
+ process.stdout.write(color(`Removed '${pathArg}' from index (file kept in working directory)\n`, 'green'))
750
+ }
751
+ return
752
+ }
753
+
623
754
  const u = new URL(`/api/repositories/${meta.repoId}/files`, server)
624
755
  u.searchParams.set('branch', meta.branch)
625
756
  u.searchParams.set('path', pathArg)
@@ -640,6 +771,29 @@ async function cmdCommit(opts) {
640
771
 
641
772
  const spinner = createSpinner('Preparing commit...', opts.json)
642
773
  try {
774
+ // Handle -am flag (add all and commit)
775
+ if (opts.all === 'true' || opts.a === 'true') {
776
+ spinnerUpdate(spinner, 'Staging all changes...')
777
+ await cmdAdd({ dir, all: 'true', json: opts.json })
778
+ }
779
+
780
+ // Handle --amend flag
781
+ if (opts.amend === 'true') {
782
+ spinnerUpdate(spinner, 'Amending last commit...')
783
+ // Get the last commit and use its message if no new message provided
784
+ const meta = readRemoteMeta(dir)
785
+ const cfg = loadConfig()
786
+ const server = getServer(opts, cfg) || meta.server
787
+ const token = getToken(opts, cfg) || meta.token
788
+ const url = new URL(`/api/repositories/${meta.repoId}/commits`, server)
789
+ url.searchParams.set('branch', meta.branch)
790
+ url.searchParams.set('limit', '1')
791
+ const commits = await request('GET', url.toString(), null, token)
792
+ if (Array.isArray(commits) && commits.length > 0 && !message) {
793
+ opts.message = commits[0].message || 'Amended commit'
794
+ }
795
+ }
796
+
643
797
  // Check for unresolved conflicts
644
798
  spinnerUpdate(spinner, 'Checking for conflicts...')
645
799
  const unresolvedConflicts = await checkForUnresolvedConflicts(dir)
@@ -669,6 +823,20 @@ async function cmdCommit(opts) {
669
823
  const local = await collectLocal(dir)
670
824
  const files = {}
671
825
  for (const [p, v] of Object.entries(local)) files[p] = v.content
826
+
827
+ // Execute pre-commit hook
828
+ spinnerUpdate(spinner, 'Running pre-commit hook...')
829
+ try {
830
+ const hookResult = await hooks.executeHook(dir, 'pre-commit', { message, files: Object.keys(files) })
831
+ if (hookResult.executed && hookResult.exitCode !== 0) {
832
+ spinnerFail(spinner, 'Pre-commit hook failed')
833
+ throw new Error('pre-commit hook failed')
834
+ }
835
+ } catch (err) {
836
+ if (err.message === 'pre-commit hook failed') throw err
837
+ // Hook doesn't exist or other error, continue
838
+ }
839
+
672
840
  localMeta.pendingCommit = { message, files, createdAt: Date.now() }
673
841
  // Clear conflicts if they were resolved
674
842
  if (localMeta.conflicts) {
@@ -676,6 +844,12 @@ async function cmdCommit(opts) {
676
844
  }
677
845
  await fs.promises.mkdir(metaDir, { recursive: true })
678
846
  await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
847
+
848
+ // Execute post-commit hook
849
+ try {
850
+ await hooks.executeHook(dir, 'post-commit', { message, files: Object.keys(files) })
851
+ } catch {}
852
+
679
853
  spinnerSuccess(spinner, `Staged changes for commit: "${message}"`)
680
854
  print({ pendingCommit: message }, opts.json === 'true')
681
855
  } catch (err) {
@@ -1209,6 +1383,19 @@ async function cmdPush(opts) {
1209
1383
  }
1210
1384
  return
1211
1385
  }
1386
+ // Execute pre-push hook
1387
+ spinnerUpdate(spinner, 'Running pre-push hook...')
1388
+ try {
1389
+ const hookResult = await hooks.executeHook(dir, 'pre-push', { branch: remoteMeta.branch, files: Object.keys(merged) })
1390
+ if (hookResult.executed && hookResult.exitCode !== 0) {
1391
+ spinnerFail(spinner, 'Pre-push hook failed')
1392
+ throw new Error('pre-push hook failed')
1393
+ }
1394
+ } catch (err) {
1395
+ if (err.message === 'pre-push hook failed') throw err
1396
+ // Hook doesn't exist or other error, continue
1397
+ }
1398
+
1212
1399
  const body = { message: localMeta.pendingCommit?.message || (opts.message || 'Push'), files: merged, branchName: remoteMeta.branch }
1213
1400
  spinnerUpdate(spinner, `Pushing to '${remoteMeta.branch}'...`)
1214
1401
  const url = new URL(`/api/repositories/${remoteMeta.repoId}/commits`, server).toString()
@@ -1217,6 +1404,12 @@ async function cmdPush(opts) {
1217
1404
  localMeta.baseFiles = merged
1218
1405
  localMeta.pendingCommit = null
1219
1406
  await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
1407
+
1408
+ // Execute post-push hook
1409
+ try {
1410
+ await hooks.executeHook(dir, 'post-push', { branch: remoteMeta.branch, commitId: localMeta.baseCommitId })
1411
+ } catch {}
1412
+
1220
1413
  spinnerSuccess(spinner, `Pushed to '${remoteMeta.branch}' (commit: ${(data.id || '').slice(0, 7)})`)
1221
1414
  print({ pushed: localMeta.baseCommitId }, opts.json === 'true')
1222
1415
  } catch (err) {
@@ -1564,7 +1757,20 @@ async function cmdBranch(sub, opts) {
1564
1757
  if (m) current = m[1]
1565
1758
  } catch { }
1566
1759
  process.stdout.write(color('Branches:\n', 'bold'))
1567
- const list = (data.branches || []).map(b => ({ name: b.name, commitId: b.commitId || '' }))
1760
+ let list = (data.branches || []).map(b => ({ name: b.name, commitId: b.commitId || '', date: b.lastCommitDate || b.createdAt || '' }))
1761
+
1762
+ // Handle --sort option
1763
+ if (opts.sort) {
1764
+ const sortBy = opts.sort.replace(/^-/, '') // Remove leading dash
1765
+ if (sortBy === 'committerdate' || sortBy === '-committerdate') {
1766
+ list.sort((a, b) => {
1767
+ const dateA = new Date(a.date || 0).getTime()
1768
+ const dateB = new Date(b.date || 0).getTime()
1769
+ return sortBy.startsWith('-') ? dateB - dateA : dateA - dateB
1770
+ })
1771
+ }
1772
+ }
1773
+
1568
1774
  for (const b of list) {
1569
1775
  const isCur = b.name === current
1570
1776
  const mark = isCur ? color('*', 'green') : ' '
@@ -1588,12 +1794,17 @@ async function cmdBranch(sub, opts) {
1588
1794
  if (sub === 'delete') {
1589
1795
  const name = opts.name
1590
1796
  if (!name) throw new Error('Missing --name')
1797
+ const force = opts.force === 'true' || opts.D === 'true'
1591
1798
  const u = new URL(`/api/repositories/${meta.repoId}/branches`, server)
1592
1799
  u.searchParams.set('name', name)
1800
+ u.searchParams.set('force', force ? 'true' : 'false')
1593
1801
  const headers = token ? { Authorization: `Bearer ${token}` } : {}
1594
1802
  const res = await fetch(u.toString(), { method: 'DELETE', headers })
1595
1803
  if (!res.ok) {
1596
1804
  const body = await res.text().catch(() => '')
1805
+ if (force) {
1806
+ throw new Error(`Branch force delete failed: ${res.status} ${res.statusText} ${body}`)
1807
+ }
1597
1808
  throw new Error(`Branch delete failed: ${res.status} ${res.statusText} ${body}`)
1598
1809
  }
1599
1810
  const data = await res.json()
@@ -1622,10 +1833,24 @@ async function cmdSwitch(opts) {
1622
1833
  const dir = path.resolve(opts.dir || '.')
1623
1834
  const meta = readRemoteMeta(dir)
1624
1835
  const branch = opts.branch
1836
+ const create = opts.create === 'true' || opts.c === 'true'
1625
1837
  if (!branch) throw new Error('Missing --branch')
1626
1838
  const cfg = loadConfig()
1627
1839
  const server = getServer(opts, cfg) || meta.server
1628
1840
  const token = getToken(opts, cfg) || meta.token
1841
+
1842
+ // Handle -c flag (create and switch)
1843
+ if (create) {
1844
+ // Check if branch exists
1845
+ const branchesUrl = new URL(`/api/repositories/${meta.repoId}/branches`, server)
1846
+ const branchesData = await request('GET', branchesUrl.toString(), null, token)
1847
+ const exists = (branchesData.branches || []).some(b => b.name === branch)
1848
+ if (!exists) {
1849
+ // Create the branch
1850
+ await cmdBranch('create', { dir, name: branch, base: meta.branch, repo: meta.repoId, server, token })
1851
+ }
1852
+ }
1853
+
1629
1854
  await pullToDir(meta.repoId, branch, dir, server, token)
1630
1855
  print({ repoId: meta.repoId, branch, dir }, opts.json === 'true')
1631
1856
  }
@@ -1980,10 +2205,28 @@ async function cmdReset(opts) {
1980
2205
  const server = getServer(opts, cfg) || meta.server
1981
2206
  const token = getToken(opts, cfg) || meta.token
1982
2207
 
1983
- const commitId = opts.commit || 'HEAD'
2208
+ let commitId = opts.commit || 'HEAD'
1984
2209
  const mode = opts.mode || 'mixed' // soft, mixed, hard
1985
2210
  const filePath = opts.path
1986
2211
 
2212
+ // Handle HEAD^ syntax (parent commit)
2213
+ if (commitId === 'HEAD^' || commitId.endsWith('^')) {
2214
+ const baseCommit = commitId.replace(/\^+$/, '')
2215
+ const targetCommit = baseCommit === 'HEAD' ? 'HEAD' : baseCommit
2216
+ let targetSnap
2217
+ if (targetCommit === 'HEAD') {
2218
+ targetSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
2219
+ } else {
2220
+ targetSnap = await fetchSnapshotByCommit(server, meta.repoId, targetCommit, token)
2221
+ }
2222
+ const commit = await fetchCommitMeta(server, meta.repoId, targetSnap.commitId, token)
2223
+ const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
2224
+ if (!parentId) {
2225
+ throw new Error('No parent commit found')
2226
+ }
2227
+ commitId = parentId
2228
+ }
2229
+
1987
2230
  if (filePath) {
1988
2231
  // Reset specific file (unstage)
1989
2232
  const metaDir = path.join(dir, '.vcs-next')
@@ -2354,6 +2597,400 @@ async function cmdRebase(opts) {
2354
2597
  print({ rebased: sourceBranch, onto }, opts.json === 'true')
2355
2598
  }
2356
2599
  }
2600
+
2601
+ async function cmdBlame(opts) {
2602
+ const dir = path.resolve(opts.dir || '.')
2603
+ const filePath = opts.path
2604
+ if (!filePath) throw new errors.ValidationError('Missing --path', 'path')
2605
+
2606
+ const validPath = validation.validateFilePath(filePath)
2607
+ const meta = readRemoteMeta(dir)
2608
+ const cfg = loadConfig()
2609
+ const server = getServer(opts, cfg) || meta.server
2610
+ const token = getToken(opts, cfg) || meta.token
2611
+
2612
+ const spinner = createSpinner(`Getting blame for ${validPath}...`, opts.json)
2613
+
2614
+ try {
2615
+ // Get file content
2616
+ const local = await collectLocal(dir)
2617
+ if (!local[validPath]) {
2618
+ throw new errors.FileSystemError(`File not found: ${validPath}`, validPath, 'read')
2619
+ }
2620
+
2621
+ // Get commits
2622
+ const commitsUrl = new URL(`/api/repositories/${meta.repoId}/commits`, server)
2623
+ commitsUrl.searchParams.set('branch', meta.branch)
2624
+ const commitsRes = await fetch(commitsUrl.toString(), {
2625
+ headers: token ? { Authorization: `Bearer ${token}` } : {}
2626
+ })
2627
+
2628
+ if (!commitsRes.ok) {
2629
+ throw new errors.NetworkError('Failed to fetch commits', commitsRes.status, commitsUrl.toString())
2630
+ }
2631
+
2632
+ const commits = await commitsRes.json()
2633
+ const blameData = parseBlame(local[validPath].content, commits, validPath)
2634
+
2635
+ spinnerSuccess(spinner, `Blame for ${validPath}`)
2636
+
2637
+ if (opts.json === 'true') {
2638
+ print(formatBlameJson(blameData), false)
2639
+ } else {
2640
+ process.stdout.write(formatBlameOutput(blameData) + '\n')
2641
+ }
2642
+ } catch (err) {
2643
+ spinnerFail(spinner, 'Blame failed')
2644
+ throw err
2645
+ }
2646
+ }
2647
+
2648
+ async function cmdLog(opts) {
2649
+ const dir = path.resolve(opts.dir || '.')
2650
+ const meta = readRemoteMeta(dir)
2651
+ const cfg = loadConfig()
2652
+ const server = getServer(opts, cfg) || meta.server
2653
+ const token = getToken(opts, cfg) || meta.token
2654
+
2655
+ const spinner = createSpinner('Fetching commit history...', opts.json)
2656
+
2657
+ try {
2658
+ const url = new URL(`/api/repositories/${meta.repoId}/commits`, server)
2659
+ if (opts.branch) url.searchParams.set('branch', opts.branch)
2660
+ else url.searchParams.set('branch', meta.branch)
2661
+
2662
+ const data = await request('GET', url.toString(), null, token)
2663
+ let commits = Array.isArray(data) ? data : []
2664
+
2665
+ // Filter by file path if provided
2666
+ const filePath = opts.path
2667
+ if (filePath) {
2668
+ const filteredCommits = []
2669
+ for (const commit of commits) {
2670
+ const commitSnap = await fetchSnapshotByCommit(server, meta.repoId, commit.id || commit._id, token)
2671
+ if (commitSnap.files[filePath] !== undefined) {
2672
+ filteredCommits.push(commit)
2673
+ }
2674
+ }
2675
+ commits = filteredCommits
2676
+ }
2677
+
2678
+ // Filter by content pattern (-G flag)
2679
+ const pattern = opts.G || opts.pattern
2680
+ if (pattern) {
2681
+ const regex = new RegExp(pattern, opts.ignoreCase === 'true' ? 'i' : '')
2682
+ const filteredCommits = []
2683
+ for (const commit of commits) {
2684
+ const commitSnap = await fetchSnapshotByCommit(server, meta.repoId, commit.id || commit._id, token)
2685
+ const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
2686
+ const parentSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {} }
2687
+
2688
+ // Check if pattern matches in any file changed in this commit
2689
+ const allPaths = new Set([...Object.keys(parentSnap.files), ...Object.keys(commitSnap.files)])
2690
+ let matches = false
2691
+ for (const p of allPaths) {
2692
+ const oldContent = parentSnap.files[p] !== undefined ? String(parentSnap.files[p]) : ''
2693
+ const newContent = commitSnap.files[p] !== undefined ? String(commitSnap.files[p]) : ''
2694
+ if (regex.test(oldContent) || regex.test(newContent)) {
2695
+ matches = true
2696
+ break
2697
+ }
2698
+ }
2699
+ if (matches) {
2700
+ filteredCommits.push(commit)
2701
+ }
2702
+ }
2703
+ commits = filteredCommits
2704
+ }
2705
+
2706
+ spinnerSuccess(spinner, `Found ${commits.length} commits`)
2707
+
2708
+ if (opts.json === 'true') {
2709
+ print(commits, true)
2710
+ } else if (opts.oneline === 'true') {
2711
+ process.stdout.write(formatCompactLog(commits) + '\n')
2712
+ } else if (opts.stats === 'true') {
2713
+ const stats = generateCommitStats(commits)
2714
+ process.stdout.write(color('Commit Statistics\n', 'bold'))
2715
+ process.stdout.write(color('━'.repeat(50) + '\n', 'dim'))
2716
+ process.stdout.write(`Total commits: ${stats.totalCommits}\n`)
2717
+ process.stdout.write(`\nTop authors:\n`)
2718
+ for (const author of stats.topAuthors) {
2719
+ process.stdout.write(` ${author.name.padEnd(30)} ${color(author.commits + ' commits', 'cyan')}\n`)
2720
+ }
2721
+ if (stats.datesRange.earliest && stats.datesRange.latest) {
2722
+ process.stdout.write(`\nDate range: ${stats.datesRange.earliest.toISOString().split('T')[0]} to ${stats.datesRange.latest.toISOString().split('T')[0]}\n`)
2723
+ }
2724
+ } else {
2725
+ const maxCommits = parseInt(opts.max || '50', 10)
2726
+ process.stdout.write(generateLogGraph(commits, { maxCommits }) + '\n')
2727
+ }
2728
+ } catch (err) {
2729
+ spinnerFail(spinner, 'Log fetch failed')
2730
+ throw err
2731
+ }
2732
+ }
2733
+
2734
+ async function cmdHook(sub, opts) {
2735
+ const dir = path.resolve(opts.dir || '.')
2736
+
2737
+ if (sub === 'list') {
2738
+ const hooksList = await hooks.listHooks(dir)
2739
+ if (opts.json === 'true') {
2740
+ print(hooksList, true)
2741
+ } else {
2742
+ if (hooksList.length === 0) {
2743
+ process.stdout.write('No hooks installed.\n')
2744
+ } else {
2745
+ process.stdout.write(color('Installed Hooks:\n', 'bold'))
2746
+ for (const hook of hooksList) {
2747
+ const exe = hook.executable ? color('✓', 'green') : color('✗', 'red')
2748
+ process.stdout.write(` ${exe} ${color(hook.name, 'cyan')}\n`)
2749
+ }
2750
+ }
2751
+ }
2752
+ return
2753
+ }
2754
+
2755
+ if (sub === 'install') {
2756
+ const hookName = opts.name
2757
+ if (!hookName) throw new Error('Missing --name')
2758
+
2759
+ let script = opts.script
2760
+ if (!script && opts.sample === 'true') {
2761
+ script = hooks.SAMPLE_HOOKS[hookName]
2762
+ if (!script) throw new Error(`No sample available for ${hookName}`)
2763
+ }
2764
+ if (!script) throw new Error('Missing --script or use --sample')
2765
+
2766
+ const result = await hooks.installHook(dir, hookName, script)
2767
+ print(result, opts.json === 'true')
2768
+ if (opts.json !== 'true') {
2769
+ process.stdout.write(color(`✓ Hook '${hookName}' installed\n`, 'green'))
2770
+ }
2771
+ return
2772
+ }
2773
+
2774
+ if (sub === 'remove') {
2775
+ const hookName = opts.name
2776
+ if (!hookName) throw new Error('Missing --name')
2777
+
2778
+ const result = await hooks.removeHook(dir, hookName)
2779
+ print(result, opts.json === 'true')
2780
+ return
2781
+ }
2782
+
2783
+ if (sub === 'show') {
2784
+ const hookName = opts.name
2785
+ if (!hookName) throw new Error('Missing --name')
2786
+
2787
+ const result = await hooks.readHook(dir, hookName)
2788
+ if (result.content) {
2789
+ process.stdout.write(result.content + '\n')
2790
+ } else {
2791
+ process.stdout.write(`Hook '${hookName}' not found.\n`)
2792
+ }
2793
+ return
2794
+ }
2795
+
2796
+ throw new Error('Unknown hook subcommand. Use: list, install, remove, show')
2797
+ }
2798
+
2799
+ async function cmdGrep(opts) {
2800
+ const dir = path.resolve(opts.dir || '.')
2801
+ const pattern = opts.pattern || opts.p || ''
2802
+ if (!pattern) throw new Error('Missing --pattern or -p')
2803
+
2804
+ const meta = readRemoteMeta(dir)
2805
+ const local = await collectLocal(dir)
2806
+ const results = []
2807
+
2808
+ const regex = new RegExp(pattern, opts.ignoreCase === 'true' ? 'i' : '')
2809
+
2810
+ for (const [filePath, fileData] of Object.entries(local)) {
2811
+ const lines = fileData.content.split(/\r?\n/)
2812
+ for (let i = 0; i < lines.length; i++) {
2813
+ if (regex.test(lines[i])) {
2814
+ results.push({
2815
+ path: filePath,
2816
+ line: i + 1,
2817
+ content: lines[i].trim()
2818
+ })
2819
+ }
2820
+ }
2821
+ }
2822
+
2823
+ if (opts.json === 'true') {
2824
+ print(results, true)
2825
+ } else {
2826
+ for (const result of results) {
2827
+ process.stdout.write(color(`${result.path}:${result.line}`, 'cyan'))
2828
+ process.stdout.write(`: ${result.content}\n`)
2829
+ }
2830
+ }
2831
+ }
2832
+
2833
+ async function cmdLsFiles(opts) {
2834
+ const dir = path.resolve(opts.dir || '.')
2835
+ const meta = readRemoteMeta(dir)
2836
+ const cfg = loadConfig()
2837
+ const server = getServer(opts, cfg) || meta.server
2838
+ const token = getToken(opts, cfg) || meta.token
2839
+
2840
+ const remote = await fetchRemoteFilesMap(server, meta.repoId, meta.branch, token)
2841
+ const files = Object.keys(remote.map)
2842
+
2843
+ if (opts.json === 'true') {
2844
+ print(files.map(f => ({ path: f })), true)
2845
+ } else {
2846
+ for (const file of files) {
2847
+ process.stdout.write(`${file}\n`)
2848
+ }
2849
+ }
2850
+ }
2851
+
2852
+ async function cmdReflog(opts) {
2853
+ const dir = path.resolve(opts.dir || '.')
2854
+ const metaDir = path.join(dir, '.vcs-next')
2855
+ const reflogPath = path.join(metaDir, 'reflog.json')
2856
+
2857
+ let reflog = []
2858
+ try {
2859
+ const content = await fs.promises.readFile(reflogPath, 'utf8')
2860
+ reflog = JSON.parse(content)
2861
+ } catch {}
2862
+
2863
+ if (opts.json === 'true') {
2864
+ print(reflog, true)
2865
+ } else {
2866
+ if (reflog.length === 0) {
2867
+ process.stdout.write('No reflog entries.\n')
2868
+ } else {
2869
+ for (const entry of reflog) {
2870
+ const date = new Date(entry.timestamp).toLocaleString()
2871
+ process.stdout.write(`${color(entry.commitId.slice(0, 7), 'yellow')} ${entry.action} ${date} ${entry.message || ''}\n`)
2872
+ }
2873
+ }
2874
+ }
2875
+ }
2876
+
2877
+ async function cmdCatFile(opts) {
2878
+ const dir = path.resolve(opts.dir || '.')
2879
+ const type = opts.type || ''
2880
+ const object = opts.object || ''
2881
+
2882
+ if (!type || !object) throw new Error('Missing --type and --object')
2883
+
2884
+ const meta = readRemoteMeta(dir)
2885
+ const cfg = loadConfig()
2886
+ const server = getServer(opts, cfg) || meta.server
2887
+ const token = getToken(opts, cfg) || meta.token
2888
+
2889
+ if (type === 'blob') {
2890
+ const snap = await fetchSnapshotByCommit(server, meta.repoId, object, token)
2891
+ const filePath = opts.path || ''
2892
+ if (filePath && snap.files[filePath]) {
2893
+ process.stdout.write(String(snap.files[filePath]))
2894
+ } else {
2895
+ throw new Error('File not found in commit')
2896
+ }
2897
+ } else if (type === 'commit') {
2898
+ const commit = await fetchCommitMeta(server, meta.repoId, object, token)
2899
+ if (opts.json === 'true') {
2900
+ print(commit, true)
2901
+ } else {
2902
+ process.stdout.write(`commit ${commit.id || commit._id}\n`)
2903
+ process.stdout.write(`Author: ${commit.author?.name || ''} <${commit.author?.email || ''}>\n`)
2904
+ process.stdout.write(`Date: ${new Date(commit.createdAt || commit.committer?.date || '').toLocaleString()}\n\n`)
2905
+ process.stdout.write(`${commit.message || ''}\n`)
2906
+ }
2907
+ } else {
2908
+ throw new Error(`Unsupported type: ${type}`)
2909
+ }
2910
+ }
2911
+
2912
+ async function cmdRevParse(opts) {
2913
+ const dir = path.resolve(opts.dir || '.')
2914
+ const rev = opts.rev || 'HEAD'
2915
+
2916
+ const meta = readRemoteMeta(dir)
2917
+ const cfg = loadConfig()
2918
+ const server = getServer(opts, cfg) || meta.server
2919
+ const token = getToken(opts, cfg) || meta.token
2920
+
2921
+ if (rev === 'HEAD') {
2922
+ const info = await request('GET', new URL(`/api/repositories/${meta.repoId}/branches`, server).toString(), null, token)
2923
+ const found = (info.branches || []).find(b => b.name === meta.branch)
2924
+ const commitId = found ? (found.commitId || '') : ''
2925
+ print(opts.json === 'true' ? { rev, commitId } : commitId, opts.json === 'true')
2926
+ } else if (rev.startsWith('refs/heads/')) {
2927
+ const branchName = rev.replace('refs/heads/', '')
2928
+ const info = await request('GET', new URL(`/api/repositories/${meta.repoId}/branches`, server).toString(), null, token)
2929
+ const found = (info.branches || []).find(b => b.name === branchName)
2930
+ const commitId = found ? (found.commitId || '') : ''
2931
+ print(opts.json === 'true' ? { rev, commitId } : commitId, opts.json === 'true')
2932
+ } else {
2933
+ // Assume it's a commit ID
2934
+ print(opts.json === 'true' ? { rev, commitId: rev } : rev, opts.json === 'true')
2935
+ }
2936
+ }
2937
+
2938
+ async function cmdDescribe(opts) {
2939
+ const dir = path.resolve(opts.dir || '.')
2940
+ const commitId = opts.commit || 'HEAD'
2941
+
2942
+ const meta = readRemoteMeta(dir)
2943
+ const cfg = loadConfig()
2944
+ const server = getServer(opts, cfg) || meta.server
2945
+ const token = getToken(opts, cfg) || meta.token
2946
+
2947
+ // Get tags
2948
+ const tagsUrl = new URL(`/api/repositories/${meta.repoId}/tags`, server)
2949
+ const tags = await request('GET', tagsUrl.toString(), null, token)
2950
+ const tagsList = Array.isArray(tags) ? tags : []
2951
+
2952
+ // Find nearest tag (simplified - just find any tag)
2953
+ const nearestTag = tagsList[0]
2954
+
2955
+ if (nearestTag) {
2956
+ const desc = `${nearestTag.name}-0-g${commitId.slice(0, 7)}`
2957
+ print(opts.json === 'true' ? { tag: nearestTag.name, commitId, describe: desc } : desc, opts.json === 'true')
2958
+ } else {
2959
+ print(opts.json === 'true' ? { commitId, describe: commitId.slice(0, 7) } : commitId.slice(0, 7), opts.json === 'true')
2960
+ }
2961
+ }
2962
+
2963
+ async function cmdShortlog(opts) {
2964
+ const dir = path.resolve(opts.dir || '.')
2965
+ const meta = readRemoteMeta(dir)
2966
+ const cfg = loadConfig()
2967
+ const server = getServer(opts, cfg) || meta.server
2968
+ const token = getToken(opts, cfg) || meta.token
2969
+
2970
+ const url = new URL(`/api/repositories/${meta.repoId}/commits`, server)
2971
+ if (opts.branch) url.searchParams.set('branch', opts.branch)
2972
+ else url.searchParams.set('branch', meta.branch)
2973
+
2974
+ const commits = await request('GET', url.toString(), null, token)
2975
+ const commitsList = Array.isArray(commits) ? commits : []
2976
+
2977
+ const stats = generateCommitStats(commitsList)
2978
+
2979
+ if (opts.json === 'true') {
2980
+ print(stats, true)
2981
+ } else {
2982
+ for (const author of stats.topAuthors) {
2983
+ process.stdout.write(`\n${color(author.name, 'bold')} (${author.commits} commit${author.commits !== 1 ? 's' : ''})\n`)
2984
+ // Show commit messages for this author
2985
+ const authorCommits = commitsList.filter(c => (c.author?.name || 'Unknown') === author.name)
2986
+ for (const commit of authorCommits.slice(0, opts.max ? parseInt(opts.max, 10) : 10)) {
2987
+ const msg = (commit.message || 'No message').split('\n')[0].slice(0, 60)
2988
+ process.stdout.write(` ${color((commit.id || commit._id || '').slice(0, 7), 'yellow')} ${msg}\n`)
2989
+ }
2990
+ }
2991
+ }
2992
+ }
2993
+
2357
2994
  function help() {
2358
2995
  const h = [
2359
2996
  'Usage: resulgit <group> <command> [options]',
@@ -2368,26 +3005,37 @@ function help() {
2368
3005
  ' repo log --repo <id> [--branch <name>] [--json]',
2369
3006
  ' repo head --repo <id> [--branch <name>] [--json]',
2370
3007
  ' repo select [--workspace] (interactive select and clone/open)',
2371
- ' branch list|create|delete|rename [--dir <path>] [--name <branch>] [--base <branch>] [--old <name>] [--new <name>]',
2372
- ' switch --branch <name> [--dir <path>]',
3008
+ ' branch list|create|delete|rename [--dir <path>] [--name <branch>] [--base <branch>] [--old <name>] [--new <name>] [--sort <field>] [--force|-D]',
3009
+ ' switch --branch <name> [--create|-c] [--dir <path>]',
3010
+ ' checkout --branch <name> [--create|-b] [--commit <id>] [--dir <path>]',
2373
3011
  ' current [--dir <path>] (show active repo/branch)',
2374
3012
  ' add <file> [--content <text>] [--from <src>] [--all] [--dir <path>] [--commit-message <text>]',
2375
3013
  ' tag list|create|delete [--dir <path>] [--name <tag>] [--branch <name>]',
2376
3014
  ' status [--dir <path>] [--json]',
2377
- ' diff [--dir <path>] [--path <file>] [--commit <id>] [--json]',
2378
- ' commit --message <text> [--dir <path>] [--json]',
3015
+ ' diff [--dir <path>] [--path <file>] [--commit <id>] [--commit1 <id>] [--commit2 <id>] [--stat] [--json]',
3016
+ ' commit --message <text> [--all|-a] [--amend] [--dir <path>] [--json]',
2379
3017
  ' push [--dir <path>] [--json]',
2380
3018
  ' head [--dir <path>] [--json]',
2381
- ' rm --path <file> [--dir <path>] [--json]',
3019
+ ' rm --path <file> [--cached] [--dir <path>] [--json]',
2382
3020
  ' pull [--dir <path>]',
2383
3021
  ' fetch [--dir <path>]',
2384
3022
  ' merge --branch <name> [--squash] [--no-push] [--dir <path>]',
2385
3023
  ' stash [save|list|pop|apply|drop|clear] [--message <msg>] [--index <n>] [--dir <path>]',
2386
3024
  ' restore --path <file> [--source <commit>] [--dir <path>]',
2387
3025
  ' revert --commit <id> [--no-push] [--dir <path>]',
2388
- ' reset [--commit <id>] [--mode <soft|mixed|hard>] [--path <file>] [--dir <path>]',
3026
+ ' reset [--commit <id>|HEAD^] [--mode <soft|mixed|hard>] [--path <file>] [--dir <path>]',
2389
3027
  ' show --commit <id> [--dir <path>] [--json]',
2390
3028
  ' mv --from <old> --to <new> [--dir <path>]',
3029
+ ' blame --path <file> [--dir <path>] [--json] - Show line-by-line authorship',
3030
+ ' log [--branch <name>] [--max <N>] [--oneline] [--stats] [--path <file>] [-G <pattern>] [--json] - Show commit history',
3031
+ ' hook list|install|remove|show [--name <hook>] [--script <code>] [--sample] - Manage Git hooks',
3032
+ ' grep --pattern <pattern> [--ignore-case] [--dir <path>] [--json] - Search in repository',
3033
+ ' ls-files [--dir <path>] [--json] - List tracked files',
3034
+ ' reflog [--dir <path>] [--json] - Show reference log',
3035
+ ' cat-file --type <type> --object <id> [--path <file>] [--dir <path>] [--json] - Display file contents',
3036
+ ' rev-parse --rev <ref> [--dir <path>] [--json] - Parse revision names',
3037
+ ' describe [--commit <id>] [--dir <path>] [--json] - Describe a commit',
3038
+ ' shortlog [--branch <name>] [--max <N>] [--dir <path>] [--json] - Summarize commit log',
2391
3039
  '',
2392
3040
  'Conflict Resolution:',
2393
3041
  ' When conflicts occur (merge/cherry-pick/revert), conflict markers are written to files:',
@@ -2562,6 +3210,46 @@ async function main() {
2562
3210
  await cmdAdd(opts)
2563
3211
  return
2564
3212
  }
3213
+ if (cmd[0] === 'blame') {
3214
+ await cmdBlame(opts)
3215
+ return
3216
+ }
3217
+ if (cmd[0] === 'log') {
3218
+ await cmdLog(opts)
3219
+ return
3220
+ }
3221
+ if (cmd[0] === 'hook') {
3222
+ await cmdHook(cmd[1], opts)
3223
+ return
3224
+ }
3225
+ if (cmd[0] === 'grep') {
3226
+ await cmdGrep(opts)
3227
+ return
3228
+ }
3229
+ if (cmd[0] === 'ls-files') {
3230
+ await cmdLsFiles(opts)
3231
+ return
3232
+ }
3233
+ if (cmd[0] === 'reflog') {
3234
+ await cmdReflog(opts)
3235
+ return
3236
+ }
3237
+ if (cmd[0] === 'cat-file') {
3238
+ await cmdCatFile(opts)
3239
+ return
3240
+ }
3241
+ if (cmd[0] === 'rev-parse') {
3242
+ await cmdRevParse(opts)
3243
+ return
3244
+ }
3245
+ if (cmd[0] === 'describe') {
3246
+ await cmdDescribe(opts)
3247
+ return
3248
+ }
3249
+ if (cmd[0] === 'shortlog') {
3250
+ await cmdShortlog(opts)
3251
+ return
3252
+ }
2565
3253
  throw new Error('Unknown command')
2566
3254
  }
2567
3255