git-drive 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/.github/workflows/ci.yml +77 -0
  2. package/.planning/codebase/ARCHITECTURE.md +151 -0
  3. package/.planning/codebase/CONCERNS.md +191 -0
  4. package/.planning/codebase/CONVENTIONS.md +169 -0
  5. package/.planning/codebase/INTEGRATIONS.md +94 -0
  6. package/.planning/codebase/STACK.md +77 -0
  7. package/.planning/codebase/STRUCTURE.md +157 -0
  8. package/.planning/codebase/TESTING.md +156 -0
  9. package/Dockerfile.cli +30 -0
  10. package/Dockerfile.server +32 -0
  11. package/README.md +95 -0
  12. package/docker-compose.yml +48 -0
  13. package/package.json +25 -0
  14. package/packages/cli/Dockerfile +26 -0
  15. package/packages/cli/package.json +57 -0
  16. package/packages/cli/src/commands/archive.ts +39 -0
  17. package/packages/cli/src/commands/init.ts +34 -0
  18. package/packages/cli/src/commands/link.ts +115 -0
  19. package/packages/cli/src/commands/list.ts +94 -0
  20. package/packages/cli/src/commands/push.ts +64 -0
  21. package/packages/cli/src/commands/restore.ts +36 -0
  22. package/packages/cli/src/commands/status.ts +127 -0
  23. package/packages/cli/src/config.ts +73 -0
  24. package/packages/cli/src/errors.ts +23 -0
  25. package/packages/cli/src/git.ts +55 -0
  26. package/packages/cli/src/index.ts +97 -0
  27. package/packages/cli/src/server.ts +514 -0
  28. package/packages/cli/tsconfig.json +13 -0
  29. package/packages/cli/ui/assets/index-Cc2q1t5k.js +17 -0
  30. package/packages/cli/ui/assets/index-DrL7ojPA.css +1 -0
  31. package/packages/cli/ui/index.html +14 -0
  32. package/packages/cli/ui/vite.svg +1 -0
  33. package/packages/git-drive-docker/package.json +15 -0
  34. package/packages/server/package.json +44 -0
  35. package/packages/server/src/index.ts +569 -0
  36. package/packages/server/tsconfig.json +9 -0
  37. package/packages/ui/README.md +73 -0
  38. package/packages/ui/eslint.config.js +23 -0
  39. package/packages/ui/index.html +13 -0
  40. package/packages/ui/package.json +42 -0
  41. package/packages/ui/postcss.config.js +6 -0
  42. package/packages/ui/public/vite.svg +1 -0
  43. package/packages/ui/src/App.css +23 -0
  44. package/packages/ui/src/App.tsx +726 -0
  45. package/packages/ui/src/assets/react.svg +8 -0
  46. package/packages/ui/src/assets/vite.svg +3 -0
  47. package/packages/ui/src/index.css +37 -0
  48. package/packages/ui/src/main.tsx +14 -0
  49. package/packages/ui/tailwind.config.js +11 -0
  50. package/packages/ui/tsconfig.app.json +28 -0
  51. package/packages/ui/tsconfig.json +26 -0
  52. package/packages/ui/tsconfig.node.json +12 -0
  53. package/packages/ui/vite.config.ts +7 -0
  54. package/pnpm-workspace.yaml +4 -0
  55. package/rewrite_app.js +731 -0
  56. package/tsconfig.json +14 -0
@@ -0,0 +1 @@
1
+ *,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}:root{--background: 0 0% 100%;--foreground: 222.2 84% 4.9%;--card: 0 0% 100%;--card-foreground: 222.2 84% 4.9%;--popover: 0 0% 100%;--popover-foreground: 222.2 84% 4.9%;--primary: 222.2 47.4% 11.2%;--primary-foreground: 210 40% 98%;--secondary: 210 40% 96.1%;--secondary-foreground: 222.2 47.4% 11.2%;--muted: 210 40% 96.1%;--muted-foreground: 215.4 16.3% 46.9%;--accent: 210 40% 96.1%;--accent-foreground: 222.2 47.4% 11.2%;--destructive: 0 84.2% 60.2%;--destructive-foreground: 210 40% 98%;--border: 214.3 31.8% 91.4%;--input: 214.3 31.8% 91.4%;--ring: 222.2 84% 4.9%;--radius: .5rem}.-mx-4{margin-left:-1rem;margin-right:-1rem}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-6{margin-top:1.5rem}.flex{display:flex}.grid{display:grid}.h-10{height:2.5rem}.h-12{height:3rem}.h-20{height:5rem}.h-24{height:6rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-64{height:16rem}.h-8{height:2rem}.h-fit{height:-moz-fit-content;height:fit-content}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-12{width:3rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-full{width:100%}.min-w-10{min-width:2.5rem}.min-w-12{min-width:3rem}.max-w-5xl{max-width:64rem}.max-w-sm{max-width:24rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.cursor-pointer{cursor:pointer}.grid-cols-\[80px_1fr\]{grid-template-columns:80px 1fr}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.gap-x-2{-moz-column-gap:.5rem;column-gap:.5rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(2rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem * var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse: 0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse))}.divide-gray-800\/50>:not([hidden])~:not([hidden]){border-color:#1f293780}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.text-clip{text-overflow:clip}.whitespace-normal{white-space:normal}.whitespace-pre{white-space:pre}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-dashed{border-style:dashed}.border-none{border-style:none}.border-amber-500\/20{border-color:#f59e0b33}.border-gray-700{--tw-border-opacity: 1;border-color:rgb(55 65 81 / var(--tw-border-opacity, 1))}.border-gray-800{--tw-border-opacity: 1;border-color:rgb(31 41 55 / var(--tw-border-opacity, 1))}.border-green-500\/20{border-color:#22c55e33}.bg-\[\#0d1117\]{--tw-bg-opacity: 1;background-color:rgb(13 17 23 / var(--tw-bg-opacity, 1))}.bg-amber-500\/10{background-color:#f59e0b1a}.bg-blue-500\/10{background-color:#3b82f61a}.bg-blue-500\/5{background-color:#3b82f60d}.bg-blue-600{--tw-bg-opacity: 1;background-color:rgb(37 99 235 / var(--tw-bg-opacity, 1))}.bg-blue-600\/20{background-color:#2563eb33}.bg-gray-800{--tw-bg-opacity: 1;background-color:rgb(31 41 55 / var(--tw-bg-opacity, 1))}.bg-gray-800\/50{background-color:#1f293780}.bg-gray-800\/80{background-color:#1f2937cc}.bg-gray-900{--tw-bg-opacity: 1;background-color:rgb(17 24 39 / var(--tw-bg-opacity, 1))}.bg-gray-900\/50{background-color:#11182780}.bg-gray-950{--tw-bg-opacity: 1;background-color:rgb(3 7 18 / var(--tw-bg-opacity, 1))}.bg-green-500\/10{background-color:#22c55e1a}.bg-red-500\/10{background-color:#ef44441a}.bg-transparent{background-color:transparent}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.fill-blue-400\/20{fill:#60a5fa33}.p-1{padding:.25rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-20{padding-top:5rem;padding-bottom:5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.pb-2{padding-bottom:.5rem}.pb-6{padding-bottom:1.5rem}.pl-1{padding-left:.25rem}.pt-4{padding-top:1rem}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.font-sans{font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji"}.text-2xl{font-size:1.5rem;line-height:2rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.tracking-tight{letter-spacing:-.025em}.tracking-widest{letter-spacing:.1em}.text-amber-500{--tw-text-opacity: 1;color:rgb(245 158 11 / var(--tw-text-opacity, 1))}.text-black{--tw-text-opacity: 1;color:rgb(0 0 0 / var(--tw-text-opacity, 1))}.text-blue-400{--tw-text-opacity: 1;color:rgb(96 165 250 / var(--tw-text-opacity, 1))}.text-gray-200{--tw-text-opacity: 1;color:rgb(229 231 235 / var(--tw-text-opacity, 1))}.text-gray-300{--tw-text-opacity: 1;color:rgb(209 213 219 / var(--tw-text-opacity, 1))}.text-gray-400{--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity, 1))}.text-gray-500{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity, 1))}.text-gray-600{--tw-text-opacity: 1;color:rgb(75 85 99 / var(--tw-text-opacity, 1))}.text-green-400{--tw-text-opacity: 1;color:rgb(74 222 128 / var(--tw-text-opacity, 1))}.text-red-400{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.text-red-500{--tw-text-opacity: 1;color:rgb(239 68 68 / var(--tw-text-opacity, 1))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.placeholder-gray-600::-moz-placeholder{--tw-placeholder-opacity: 1;color:rgb(75 85 99 / var(--tw-placeholder-opacity, 1))}.placeholder-gray-600::placeholder{--tw-placeholder-opacity: 1;color:rgb(75 85 99 / var(--tw-placeholder-opacity, 1))}.opacity-0{opacity:0}.opacity-60{opacity:.6}.opacity-75{opacity:.75}.shadow-lg{--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-black\/20{--tw-shadow-color: rgb(0 0 0 / .2);--tw-shadow: var(--tw-shadow-colored)}.shadow-black\/40{--tw-shadow-color: rgb(0 0 0 / .4);--tw-shadow: var(--tw-shadow-colored)}.outline-none{outline:2px solid transparent;outline-offset:2px}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.hover\:border-gray-700:hover{--tw-border-opacity: 1;border-color:rgb(55 65 81 / var(--tw-border-opacity, 1))}.hover\:bg-blue-500:hover{--tw-bg-opacity: 1;background-color:rgb(59 130 246 / var(--tw-bg-opacity, 1))}.hover\:bg-gray-200:hover{--tw-bg-opacity: 1;background-color:rgb(229 231 235 / var(--tw-bg-opacity, 1))}.hover\:bg-gray-800:hover{--tw-bg-opacity: 1;background-color:rgb(31 41 55 / var(--tw-bg-opacity, 1))}.hover\:bg-gray-800\/30:hover{background-color:#1f29374d}.hover\:bg-gray-800\/50:hover{background-color:#1f293780}.hover\:bg-red-500\/10:hover{background-color:#ef44441a}.hover\:text-gray-300:hover{--tw-text-opacity: 1;color:rgb(209 213 219 / var(--tw-text-opacity, 1))}.hover\:text-white:hover{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.hover\:underline:hover{text-decoration-line:underline}.focus\:border-gray-500:focus{--tw-border-opacity: 1;border-color:rgb(107 114 128 / var(--tw-border-opacity, 1))}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:border-blue-500\/30{border-color:#3b82f64d}.group:hover .group-hover\:bg-blue-500\/10{background-color:#3b82f61a}.group:hover .group-hover\:bg-gray-700{--tw-bg-opacity: 1;background-color:rgb(55 65 81 / var(--tw-bg-opacity, 1))}.group:hover .group-hover\:text-blue-400{--tw-text-opacity: 1;color:rgb(96 165 250 / var(--tw-text-opacity, 1))}.group:hover .group-hover\:text-gray-300{--tw-text-opacity: 1;color:rgb(209 213 219 / var(--tw-text-opacity, 1))}.group:hover .group-hover\:opacity-100{opacity:1}@media(min-width:768px){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(min-width:1024px){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}
@@ -0,0 +1,14 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Vite + React + TS</title>
8
+ <script type="module" crossorigin src="/assets/index-Cc2q1t5k.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-DrL7ojPA.css">
10
+ </head>
11
+ <body>
12
+ <div id="root"></div>
13
+ </body>
14
+ </html>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "git-drive-docker",
3
+ "version": "0.1.0",
4
+ "description": "Dockerized Git Drive environment and CLI wrapper",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "build:images": "docker compose build",
8
+ "up": "docker compose up -d",
9
+ "down": "docker compose down",
10
+ "cli": "docker compose run git-drive-cli"
11
+ },
12
+ "keywords": [],
13
+ "author": "",
14
+ "license": "ISC"
15
+ }
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "git-drive-server",
3
+ "version": "0.1.0",
4
+ "description": "Server component for git-drive",
5
+ "keywords": [
6
+ "git",
7
+ "backup",
8
+ "server"
9
+ ],
10
+ "author": "",
11
+ "license": "MIT",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/josmanvis/git-drive.git",
15
+ "directory": "packages/server"
16
+ },
17
+ "bugs": {
18
+ "url": "https://github.com/josmanvis/git-drive/issues"
19
+ },
20
+ "homepage": "https://github.com/josmanvis/git-drive#readme",
21
+ "main": "dist/index.js",
22
+ "type": "module",
23
+ "types": "dist/index.d.ts",
24
+ "files": [
25
+ "dist"
26
+ ],
27
+ "scripts": {
28
+ "build": "tsc",
29
+ "start": "node dist/index.js",
30
+ "prepublishOnly": "npm run build"
31
+ },
32
+ "dependencies": {
33
+ "express": "^4.19.2",
34
+ "node-disk-info": "^1.3.0"
35
+ },
36
+ "devDependencies": {
37
+ "@types/express": "^4.17.21",
38
+ "@types/node": "^22.0.0",
39
+ "typescript": "^5.7.0"
40
+ },
41
+ "engines": {
42
+ "node": ">=18"
43
+ }
44
+ }
@@ -0,0 +1,569 @@
1
+ import express from 'express';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import { readdirSync, existsSync, mkdirSync, statSync, readFileSync } from 'fs';
5
+ import { execSync } from 'child_process';
6
+ import { getDiskInfo } from 'node-disk-info';
7
+ import { homedir } from 'os';
8
+
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = path.dirname(__filename);
11
+
12
+ const app = express();
13
+ const port = 4483;
14
+
15
+ app.use(express.json());
16
+ app.use(express.static(path.join(__dirname, '../../../packages/ui/dist')));
17
+
18
+ // ── Helpers ──────────────────────────────────────────────────────────
19
+
20
+ function git(args: string, cwd?: string): string {
21
+ return execSync(`git ${args}`, {
22
+ cwd,
23
+ encoding: 'utf-8',
24
+ stdio: ['pipe', 'pipe', 'pipe'],
25
+ }).trim();
26
+ }
27
+
28
+ function getGitDrivePath(mountpoint: string): string {
29
+ return path.join(mountpoint, '.git-drive');
30
+ }
31
+
32
+ function ensureGitDriveDir(mountpoint: string): string {
33
+ const gitDrivePath = getGitDrivePath(mountpoint);
34
+ if (!existsSync(gitDrivePath)) {
35
+ try {
36
+ execSync(`mkdir -p "${gitDrivePath}"`);
37
+ } catch (err: any) {
38
+ throw new Error(`Failed to write to drive. Please ensure Terminal/Node has "Removable Volumes" access in macOS Privacy settings. Details: ${err.message}`);
39
+ }
40
+ }
41
+ return gitDrivePath;
42
+ }
43
+
44
+ function listRepos(gitDrivePath: string): Array<{ name: string; path: string; lastModified: string }> {
45
+ if (!existsSync(gitDrivePath)) return [];
46
+
47
+ return readdirSync(gitDrivePath)
48
+ .filter((entry) => {
49
+ const entryPath = path.join(gitDrivePath, entry);
50
+ // Accept both bare repos (name.git) and directories with HEAD file
51
+ return (
52
+ statSync(entryPath).isDirectory() &&
53
+ (entry.endsWith('.git') || existsSync(path.join(entryPath, 'HEAD')))
54
+ );
55
+ })
56
+ .map((entry) => {
57
+ const entryPath = path.join(gitDrivePath, entry);
58
+ const stat = statSync(entryPath);
59
+ return {
60
+ name: entry.replace(/\.git$/, ''),
61
+ path: entryPath,
62
+ lastModified: stat.mtime.toISOString(),
63
+ };
64
+ });
65
+ }
66
+
67
+ function loadLinks(): Record<string, { mountpoint: string; repoName: string; linkedAt: string }> {
68
+ const linksFile = path.join(homedir(), '.config', 'git-drive', 'links.json');
69
+ if (!existsSync(linksFile)) return {};
70
+ try {
71
+ return JSON.parse(readFileSync(linksFile, 'utf-8'));
72
+ } catch {
73
+ return {};
74
+ }
75
+ }
76
+
77
+ // ── API Routes ───────────────────────────────────────────────────────
78
+
79
+ // List all connected drives
80
+ app.get('/api/drives', async (_req, res) => {
81
+ try {
82
+ const drives = await getDiskInfo();
83
+ const result = drives
84
+ .filter((d: any) => {
85
+ const mp = d.mounted;
86
+ if (!mp) return false;
87
+ if (mp === "/" || mp === "100%") return false;
88
+
89
+ if (process.platform === "darwin") {
90
+ return mp.startsWith("/Volumes/") && !mp.startsWith("/Volumes/Recovery");
91
+ }
92
+
93
+ if (mp.startsWith("/sys") || mp.startsWith("/proc") || mp.startsWith("/run") || mp.startsWith("/snap") || mp.startsWith("/boot")) return false;
94
+ if (d.filesystem === "tmpfs" || d.filesystem === "devtmpfs" || d.filesystem === "udev" || d.filesystem === "overlay") return false;
95
+
96
+ return true;
97
+ })
98
+ .map((d: any) => ({
99
+ device: d.filesystem,
100
+ description: d.mounted,
101
+ size: d.blocks ? parseInt(d.blocks) * 1024 : 0,
102
+ isRemovable: true, // simplified assumption
103
+ isSystem: d.mounted === '/',
104
+ mountpoints: [d.mounted],
105
+ hasGitDrive: existsSync(getGitDrivePath(d.mounted)),
106
+ }));
107
+ res.json(result);
108
+ } catch (err) {
109
+ res.status(500).json({ error: 'Failed to list drives' });
110
+ }
111
+ });
112
+
113
+ // List repos on a specific drive
114
+ app.get('/api/drives/:mountpoint/repos', (req, res) => {
115
+ try {
116
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
117
+ const gitDrivePath = getGitDrivePath(mountpoint);
118
+
119
+ if (!existsSync(mountpoint)) {
120
+ res.status(404).json({ error: 'Drive not found or not mounted' });
121
+ return;
122
+ }
123
+
124
+ const repos = listRepos(gitDrivePath);
125
+ res.json({
126
+ mountpoint,
127
+ gitDrivePath,
128
+ initialized: existsSync(gitDrivePath),
129
+ repos,
130
+ });
131
+ } catch (err) {
132
+ res.status(500).json({ error: 'Failed to list repos' });
133
+ }
134
+ });
135
+
136
+ // Initialize git-drive on a drive (create .git-drive directory)
137
+ app.post('/api/drives/:mountpoint/init', (req, res) => {
138
+ try {
139
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
140
+
141
+ if (!existsSync(mountpoint)) {
142
+ res.status(404).json({ error: 'Drive not found or not mounted' });
143
+ return;
144
+ }
145
+
146
+ const gitDrivePath = ensureGitDriveDir(mountpoint);
147
+ res.json({
148
+ mountpoint,
149
+ gitDrivePath,
150
+ message: 'Git Drive initialized on this drive',
151
+ });
152
+ } catch (err: any) {
153
+ console.error("Init Error:", err);
154
+ res.status(500).json({ error: err.message || 'Failed to initialize drive' });
155
+ }
156
+ });
157
+
158
+ // Create a new bare repo on a drive
159
+ app.post('/api/drives/:mountpoint/repos', (req, res) => {
160
+ try {
161
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
162
+ const { name } = req.body;
163
+
164
+ if (!name || typeof name !== 'string') {
165
+ res.status(400).json({ error: 'Repo name is required' });
166
+ return;
167
+ }
168
+
169
+ // Sanitize name
170
+ const safeName = name.replace(/[^a-zA-Z0-9._-]/g, '-');
171
+
172
+ if (!existsSync(mountpoint)) {
173
+ res.status(404).json({ error: 'Drive not found or not mounted' });
174
+ return;
175
+ }
176
+
177
+ const gitDrivePath = ensureGitDriveDir(mountpoint);
178
+ const repoName = safeName.endsWith('.git') ? safeName : `${safeName}.git`;
179
+ const repoPath = path.join(gitDrivePath, repoName);
180
+
181
+ if (existsSync(repoPath)) {
182
+ res.status(409).json({ error: 'Repository already exists' });
183
+ return;
184
+ }
185
+
186
+ git(`init --bare "${repoPath}"`);
187
+
188
+ res.status(201).json({
189
+ name: safeName.replace(/\.git$/, ''),
190
+ path: repoPath,
191
+ message: `Bare repository created: ${repoName}`,
192
+ remoteUrl: repoPath,
193
+ });
194
+ } catch (err) {
195
+ res.status(500).json({ error: 'Failed to create repository' });
196
+ }
197
+ });
198
+
199
+ // Delete a repo from a drive
200
+ app.delete('/api/drives/:mountpoint/repos/:repoName', (req, res) => {
201
+ try {
202
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
203
+ const repoName = decodeURIComponent(req.params.repoName);
204
+ const gitDrivePath = getGitDrivePath(mountpoint);
205
+
206
+ const bareRepoName = repoName.endsWith('.git') ? repoName : `${repoName}.git`;
207
+ const repoPath = path.join(gitDrivePath, bareRepoName);
208
+
209
+ if (!existsSync(repoPath)) {
210
+ // Also check without .git suffix
211
+ const altPath = path.join(gitDrivePath, repoName);
212
+ if (!existsSync(altPath)) {
213
+ res.status(404).json({ error: 'Repository not found' });
214
+ return;
215
+ }
216
+ execSync(`rm -rf "${altPath}"`);
217
+ } else {
218
+ execSync(`rm -rf "${repoPath}"`);
219
+ }
220
+
221
+ res.json({ message: `Repository '${repoName}' deleted` });
222
+ } catch (err) {
223
+ res.status(500).json({ error: 'Failed to delete repository' });
224
+ }
225
+ });
226
+
227
+ // Get info about a specific repo
228
+ app.get('/api/drives/:mountpoint/repos/:repoName', (req, res) => {
229
+ try {
230
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
231
+ const repoName = decodeURIComponent(req.params.repoName);
232
+ const gitDrivePath = getGitDrivePath(mountpoint);
233
+
234
+ const bareRepoName = repoName.endsWith('.git') ? repoName : `${repoName}.git`;
235
+ let repoPath = path.join(gitDrivePath, bareRepoName);
236
+
237
+ if (!existsSync(repoPath)) {
238
+ repoPath = path.join(gitDrivePath, repoName);
239
+ if (!existsSync(repoPath)) {
240
+ res.status(404).json({ error: 'Repository not found' });
241
+ return;
242
+ }
243
+ }
244
+
245
+ // Get branches
246
+ let branches: string[] = [];
247
+ try {
248
+ const branchOutput = git("branch --format='%(refname:short)'", repoPath);
249
+ branches = branchOutput
250
+ .split('\n')
251
+ .map((b) => b.trim().replace(/^'|'$/g, ''))
252
+ .filter(Boolean);
253
+ } catch {
254
+ // Empty repo has no branches
255
+ }
256
+
257
+ // Get tags
258
+ let tags: string[] = [];
259
+ try {
260
+ const tagOutput = git("tag", repoPath);
261
+ tags = tagOutput
262
+ .split('\n')
263
+ .map((t) => t.trim())
264
+ .filter(Boolean);
265
+ } catch {
266
+ // No tags
267
+ }
268
+
269
+ // Get last commit info
270
+ let lastCommit: { hash: string; message: string; date: string } | null = null;
271
+ try {
272
+ const log = git('log -1 --format="%H|%s|%ci" --all', repoPath);
273
+ if (log) {
274
+ const [hash, message, date] = log.replace(/^"|"$/g, '').split('|');
275
+ lastCommit = { hash, message, date };
276
+ }
277
+ } catch {
278
+ // Empty repo
279
+ }
280
+
281
+ const stat = statSync(repoPath);
282
+
283
+ res.json({
284
+ name: repoName.replace(/\.git$/, ''),
285
+ path: repoPath,
286
+ branches,
287
+ tags,
288
+ lastCommit,
289
+ lastModified: stat.mtime.toISOString(),
290
+ remoteUrl: repoPath,
291
+ });
292
+ } catch (err) {
293
+ res.status(500).json({ error: 'Failed to get repo info' });
294
+ }
295
+ });
296
+
297
+ // Check if there are local working directory unpushed changes
298
+ app.get('/api/drives/:mountpoint/repos/:repoName/local-status', (req, res) => {
299
+ try {
300
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
301
+ let repoName = decodeURIComponent(req.params.repoName);
302
+ repoName = repoName.replace(/\.git$/, '');
303
+
304
+ const links = loadLinks();
305
+ let localPath: string | null = null;
306
+
307
+ // Find if this specific drive's repo is globally linked to any local folder on the user's machine
308
+ for (const [p, data] of Object.entries(links)) {
309
+ if (data.mountpoint === mountpoint && data.repoName.replace(/\.git$/, '') === repoName) {
310
+ if (existsSync(p)) {
311
+ localPath = p;
312
+ break;
313
+ }
314
+ }
315
+ }
316
+
317
+ if (!localPath) {
318
+ res.json({ linked: false });
319
+ return;
320
+ }
321
+
322
+ // Check git status locally
323
+ let hasChanges = false;
324
+ let unpushed = false;
325
+ try {
326
+ const statusOutput = git('status --porcelain', localPath);
327
+ hasChanges = statusOutput.trim().length > 0;
328
+
329
+ const unpushedOutput = git('log gd/main..HEAD --oneline', localPath); // Assuming main for now
330
+ unpushed = unpushedOutput.trim().length > 0;
331
+ } catch {
332
+ // Ignore git errors if repo is in weird state
333
+ }
334
+
335
+ res.json({
336
+ linked: true,
337
+ localPath,
338
+ hasChanges,
339
+ unpushed
340
+ });
341
+ } catch (err) {
342
+ res.status(500).json({ error: 'Failed to check local status' });
343
+ }
344
+ });
345
+
346
+ // Remotely push local working directory to git-drive
347
+ app.post('/api/drives/:mountpoint/repos/:repoName/push', (req, res) => {
348
+ try {
349
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
350
+ let repoName = decodeURIComponent(req.params.repoName);
351
+ repoName = repoName.replace(/\.git$/, '');
352
+
353
+ const links = loadLinks();
354
+ let localPath: string | null = null;
355
+ for (const [p, data] of Object.entries(links)) {
356
+ if (data.mountpoint === mountpoint && data.repoName.replace(/\.git$/, '') === repoName) {
357
+ if (existsSync(p)) {
358
+ localPath = p;
359
+ break;
360
+ }
361
+ }
362
+ }
363
+
364
+ if (!localPath) {
365
+ res.status(404).json({ error: 'Local linked repository not found.' });
366
+ return;
367
+ }
368
+
369
+ git('push gd --all', localPath);
370
+ git('push gd --tags', localPath);
371
+
372
+ // Save push telemetry
373
+ try {
374
+ const gitDrivePath = getGitDrivePath(mountpoint);
375
+ const bareRepoName = repoName.endsWith('.git') ? repoName : `${repoName}.git`;
376
+ let repoPath = path.join(gitDrivePath, bareRepoName);
377
+ if (!existsSync(repoPath)) repoPath = path.join(gitDrivePath, repoName);
378
+
379
+ const payload = {
380
+ date: new Date().toISOString(),
381
+ computer: homedir(), // Server relies on os module
382
+ user: process.env.USER || 'local-user',
383
+ localDir: localPath,
384
+ mode: 'web-ui',
385
+ };
386
+ const logFile = path.join(repoPath, "git-drive-pushlog.json");
387
+ const fs = require('fs');
388
+ fs.appendFileSync(logFile, JSON.stringify(payload) + "\n", "utf-8");
389
+ } catch { }
390
+
391
+ res.json({ success: true, message: 'Successfully backed up local code to Git Drive!' });
392
+ } catch (err: any) {
393
+ res.status(500).json({ error: err.message || 'Failed to push' });
394
+ }
395
+ });
396
+
397
+ // Browse repository files tree
398
+ app.get('/api/drives/:mountpoint/repos/:repoName/tree', (req, res) => {
399
+ try {
400
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
401
+ const repoName = decodeURIComponent(req.params.repoName);
402
+ const branch = req.query.branch || 'main'; // Provide a default if they use main
403
+ const treePath = (req.query.path as string) || '';
404
+
405
+ const gitDrivePath = getGitDrivePath(mountpoint);
406
+ const bareRepoName = repoName.endsWith('.git') ? repoName : `${repoName}.git`;
407
+ let repoPath = path.join(gitDrivePath, bareRepoName);
408
+
409
+ if (!existsSync(repoPath)) {
410
+ repoPath = path.join(gitDrivePath, repoName);
411
+ }
412
+
413
+ // Resolves default branch if passed "HEAD" or empty logic. Safely ask git for HEAD branch:
414
+ let targetBranch = branch as string;
415
+ if (!targetBranch) {
416
+ try {
417
+ const branchOutput = git('branch --show-current', repoPath);
418
+ targetBranch = branchOutput || 'HEAD';
419
+ } catch {
420
+ targetBranch = 'main';
421
+ }
422
+ }
423
+
424
+ // git ls-tree
425
+ const target = treePath ? `${targetBranch}:${treePath}` : targetBranch;
426
+ const output = git(`ls-tree ${target}`, repoPath);
427
+
428
+ const files = output.split('\n').filter(Boolean).map((line) => {
429
+ // 040000 tree <hash>\t<path>
430
+ // 100644 blob <hash>\t<path>
431
+ const parts = line.split('\t');
432
+ const meta = parts[0].split(' ');
433
+ return {
434
+ mode: meta[0],
435
+ type: meta[1],
436
+ hash: meta[2],
437
+ path: parts[1],
438
+ name: parts[1].split('/').pop(),
439
+ };
440
+ });
441
+
442
+ res.json({ files });
443
+ } catch (err) {
444
+ res.json({ files: [] }); // Typically happens if repo has zero commits
445
+ }
446
+ });
447
+
448
+ // Get combined git commit history and git-drive push logs
449
+ app.get('/api/drives/:mountpoint/repos/:repoName/commits', (req, res) => {
450
+ try {
451
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
452
+ const repoName = decodeURIComponent(req.params.repoName);
453
+ const branch = req.query.branch || 'main'; // Using branch filters
454
+
455
+ const gitDrivePath = getGitDrivePath(mountpoint);
456
+ const bareRepoName = repoName.endsWith('.git') ? repoName : `${repoName}.git`;
457
+ let repoPath = path.join(gitDrivePath, bareRepoName);
458
+
459
+ if (!existsSync(repoPath)) {
460
+ repoPath = path.join(gitDrivePath, repoName);
461
+ }
462
+
463
+ // Git commits log
464
+ let commits: any[] = [];
465
+ try {
466
+ // hash|authorName|authorEmail|message|date
467
+ const logOutput = git(`log ${branch} -n 100 --format="%H|%an|%ae|%s|%ci"`, repoPath);
468
+ commits = logOutput
469
+ .split('\n')
470
+ .filter(Boolean)
471
+ .map((line) => {
472
+ const [hash, author, email, message, date] = line.split('|');
473
+ return { hash, author, email, message, date };
474
+ });
475
+ } catch (e) {
476
+ // Empty repo or invalid branch
477
+ }
478
+
479
+ // Git drive push logs overlay
480
+ let pushLogs: any[] = [];
481
+ try {
482
+ const logFile = path.join(repoPath, "git-drive-pushlog.json");
483
+ if (existsSync(logFile)) {
484
+ const rawLogs = require('fs').readFileSync(logFile, "utf-8").trim().split('\n');
485
+ pushLogs = rawLogs.map((l: string) => {
486
+ try {
487
+ return JSON.parse(l);
488
+ } catch {
489
+ return null;
490
+ }
491
+ }).filter(Boolean);
492
+ pushLogs.reverse(); // Newest first
493
+ }
494
+ } catch { }
495
+
496
+ res.json({ commits, pushLogs });
497
+ } catch (err) {
498
+ res.status(500).json({ error: 'Failed to retrieve history' });
499
+ }
500
+ });
501
+
502
+ // Get single commit details (patch/diff)
503
+ app.get('/api/drives/:mountpoint/repos/:repoName/commits/:hash', (req, res) => {
504
+ try {
505
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
506
+ const repoName = decodeURIComponent(req.params.repoName);
507
+ const hash = req.params.hash;
508
+
509
+ const gitDrivePath = getGitDrivePath(mountpoint);
510
+ const bareRepoName = repoName.endsWith('.git') ? repoName : `${repoName}.git`;
511
+ let repoPath = path.join(gitDrivePath, bareRepoName);
512
+ if (!existsSync(repoPath)) repoPath = path.join(gitDrivePath, repoName);
513
+
514
+ // hash|authorName|authorEmail|message|date
515
+ const logOutput = git(`log -1 --format="%H|%an|%ae|%s|%ci" ${hash}`, repoPath);
516
+ const [commitHash, author, email, message, date] = logOutput.split('|');
517
+
518
+ // Get the diff/patch
519
+ const patch = git(`show --format="" ${hash}`, repoPath);
520
+
521
+ res.json({
522
+ hash: commitHash,
523
+ author,
524
+ email,
525
+ message,
526
+ date,
527
+ patch
528
+ });
529
+ } catch (err) {
530
+ res.status(500).json({ error: 'Failed to retrieve commit details' });
531
+ }
532
+ });
533
+
534
+ // Read raw file content
535
+ app.get('/api/drives/:mountpoint/repos/:repoName/blob', (req, res) => {
536
+ try {
537
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
538
+ const repoName = decodeURIComponent(req.params.repoName);
539
+ const branch = req.query.branch || 'main';
540
+ const filePath = req.query.path as string;
541
+
542
+ const gitDrivePath = getGitDrivePath(mountpoint);
543
+ const bareRepoName = repoName.endsWith('.git') ? repoName : `${repoName}.git`;
544
+ let repoPath = path.join(gitDrivePath, bareRepoName);
545
+
546
+ if (!existsSync(repoPath)) {
547
+ repoPath = path.join(gitDrivePath, repoName);
548
+ }
549
+
550
+ const content = git(`show ${branch}:${filePath}`, repoPath);
551
+ res.send(content);
552
+ } catch (err) {
553
+ res.status(500).json({ error: 'Failed to read file' });
554
+ }
555
+ });
556
+
557
+ // SPA fallback - serve index.html for all non-API routes
558
+ app.get('*', (_req, res) => {
559
+ const indexPath = path.join(__dirname, '../../../packages/ui/dist', 'index.html');
560
+ if (existsSync(indexPath)) {
561
+ res.sendFile(indexPath);
562
+ } else {
563
+ res.status(404).send('UI not built. Run: pnpm build:ui');
564
+ }
565
+ });
566
+
567
+ app.listen(port, () => {
568
+ console.log(`\n 🚀 Git Drive is running at http://localhost:${port}\n`);
569
+ });