megit-app 0.5.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,28 @@ surface, and the HTTP API may change in any minor release.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [0.7.0] - 2026-08-19
13
+
14
+ ### Added
15
+
16
+ - A search box filters the recent-repositories list in the picker. It matches the full
17
+ path, not just the folder name, so two checkouts of the same repo stay distinguishable.
18
+
19
+ ### Changed
20
+
21
+ - The recent-repositories list keeps 12 entries instead of 10.
22
+
23
+ ## [0.6.0] - 2026-08-16
24
+
25
+ ### Added
26
+
27
+ - `megit start` runs the server in the background and `megit stop` shuts it down, so the
28
+ terminal that launched megit can be closed. Output goes to `~/.config/megit/megit.log`.
29
+
30
+ ### Removed
31
+
32
+ - The `[repo-path]` argument is gone — open repositories from the picker in the app.
33
+
12
34
  ## [0.5.2] - 2026-08-12
13
35
 
14
36
  ### Fixed
@@ -190,7 +212,11 @@ First public release.
190
212
  - Windows (arm64, x64): builds and runs, but untested on real hardware — recursive `fs.watch` crashes the test worker there, so auto-refresh is not covered by CI
191
213
  - Linux: everything except the terminal — node-pty is an optionalDependency with no Linux prebuild, and the terminal button is hidden when it is unavailable
192
214
 
193
- [Unreleased]: https://github.com/vuongvu1/megit/compare/v0.5.0...HEAD
215
+ [Unreleased]: https://github.com/vuongvu1/megit/compare/v0.7.0...HEAD
216
+ [0.7.0]: https://github.com/vuongvu1/megit/compare/v0.6.0...v0.7.0
217
+ [0.6.0]: https://github.com/vuongvu1/megit/compare/v0.5.2...v0.6.0
218
+ [0.5.2]: https://github.com/vuongvu1/megit/compare/v0.5.1...v0.5.2
219
+ [0.5.1]: https://github.com/vuongvu1/megit/compare/v0.5.0...v0.5.1
194
220
  [0.5.0]: https://github.com/vuongvu1/megit/compare/v0.4.1...v0.5.0
195
221
  [0.4.1]: https://github.com/vuongvu1/megit/compare/v0.4.0...v0.4.1
196
222
  [0.4.0]: https://github.com/vuongvu1/megit/compare/v0.3.0...v0.4.0
package/README.md CHANGED
@@ -12,22 +12,20 @@ It writes, too: stage/unstage/discard, commit and amend, branch and tag create/d
12
12
 
13
13
  Requires Node ≥ 22. The package is 1.8 MB, with `ws` as its only runtime dependency and no install scripts.
14
14
 
15
- **Run it once, without installing:**
16
-
17
15
  ```bash
18
16
  npx megit-app
19
17
  ```
20
18
 
21
- **Or install it, and run `megit` from anywhere:**
19
+ That starts the server on port 3411 — set `PORT` to change that — and opens your browser at it. <kbd>Ctrl</kbd><kbd>C</kbd> stops it.
20
+
21
+ **To keep it running after you close the terminal:**
22
22
 
23
23
  ```bash
24
- npm i -g megit-app
25
- megit
24
+ npx megit-app start
25
+ npx megit-app stop
26
26
  ```
27
27
 
28
- Upgrade later with `npm i -g megit-app@latest`.
29
-
30
- Either way the server starts on port 3411 — set `PORT` to change that — and opens your browser at it.
28
+ Or install it once with `npm i -g megit-app` and run `megit`, `megit start` and `megit stop` from anywhere; upgrade later with `npm i -g megit-app@latest`.
31
29
 
32
30
  <sub>The package is `megit-app`; `megit` on npm is unrelated, so keep the `-app`. The installed command is still `megit`.</sub>
33
31
 
package/bin/megit.js CHANGED
@@ -1,22 +1,23 @@
1
1
  #!/usr/bin/env node
2
- // megit CLI — start the local server, open the browser at it, and register the
3
- // repo path given as an argument (if any) by POSTing to our own /api/repos, so
4
- // path validation stays in one place.
5
- import { execFile } from 'node:child_process'
2
+ // megit CLI — run the local server in this terminal, or detach it with
3
+ // `start`/`stop` so it outlives the terminal that launched it.
4
+ import { execFile, spawn } from 'node:child_process'
5
+ import { mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
6
6
  import { createRequire } from 'node:module'
7
- import { resolve } from 'node:path'
7
+ import { homedir } from 'node:os'
8
+ import { join } from 'node:path'
9
+ import { fileURLToPath } from 'node:url'
8
10
 
9
- const arg = process.argv[2]
11
+ const cmd = process.argv[2]
10
12
 
11
13
  // handled before importing the server, so --help never binds a port
12
- if (arg === '-h' || arg === '--help') {
14
+ if (cmd === '-h' || cmd === '--help') {
13
15
  console.log(`megit — git repository viewer in the browser
14
16
 
15
17
  Usage:
16
- megit [repo-path]
17
-
18
- Arguments:
19
- repo-path open this repository in a tab (default: reopen last session)
18
+ megit run in this terminal
19
+ megit start run in the background
20
+ megit stop stop the background server
20
21
 
21
22
  Options:
22
23
  -h, --help show this message
@@ -27,7 +28,7 @@ Environment:
27
28
  process.exit(0)
28
29
  }
29
30
 
30
- if (arg === '-v' || arg === '--version') {
31
+ if (cmd === '-v' || cmd === '--version') {
31
32
  console.log(createRequire(import.meta.url)('../package.json').version)
32
33
  process.exit(0)
33
34
  }
@@ -35,19 +36,110 @@ if (arg === '-v' || arg === '--version') {
35
36
  const port = Number(process.env.PORT) || 3411
36
37
  const url = `http://127.0.0.1:${port}`
37
38
 
38
- const { server } = await import('../dist-server/index.js')
39
- if (!server.listening) await new Promise(r => server.once('listening', r))
39
+ const dir = join(homedir(), '.config', 'megit')
40
+ const stateFile = join(dir, 'daemon.json')
41
+ const logFile = join(dir, 'megit.log')
42
+
43
+ const readState = () => {
44
+ try {
45
+ return JSON.parse(readFileSync(stateFile, 'utf8'))
46
+ } catch {
47
+ return null
48
+ }
49
+ }
50
+
51
+ const pidAlive = pid => {
52
+ try {
53
+ process.kill(pid, 0) // signal 0 tests for existence, sends nothing
54
+ return true
55
+ } catch {
56
+ return false
57
+ }
58
+ }
59
+
60
+ // "Is megit up?" is answered by megit answering, not by a PID existing. A PID
61
+ // can be reused after an unclean exit, and a port can be held by anything.
62
+ const megitAnswers = async p => {
63
+ try {
64
+ const r = await fetch(`http://127.0.0.1:${p}/api/config`, { signal: AbortSignal.timeout(1000) })
65
+ return r.ok && 'hasTerminal' in (await r.json())
66
+ } catch {
67
+ return false
68
+ }
69
+ }
70
+
71
+ if (cmd === 'start') {
72
+ const prev = readState()
73
+ if (prev && pidAlive(prev.pid) && (await megitAnswers(prev.port))) {
74
+ // ponytail: one daemon is tracked, so a second PORT is a conflict rather than
75
+ // a second server. Key the state file by port if anyone actually wants two.
76
+ if (prev.port === port) {
77
+ console.log(`megit already running → http://127.0.0.1:${prev.port}`)
78
+ process.exit(0)
79
+ }
80
+ console.error(`megit: already running on http://127.0.0.1:${prev.port} — run 'megit stop' first`)
81
+ process.exit(1)
82
+ }
40
83
 
41
- if (arg) {
42
- await fetch(`${url}/api/repos`, {
43
- method: 'POST',
44
- headers: { 'content-type': 'application/json' },
45
- body: JSON.stringify({ path: resolve(arg) }),
84
+ mkdirSync(dir, { recursive: true })
85
+ // Without a log, a daemon that dies during startup dies invisibly.
86
+ const log = openSync(logFile, 'a')
87
+ // Re-run this same script with no arguments: the foreground path below is the
88
+ // one startup sequence, and the daemon must not diverge from it. `detached`
89
+ // gives the child its own process group so the shell's SIGHUP on exit misses
90
+ // it; `unref` lets this process leave without waiting.
91
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url)], {
92
+ detached: true,
93
+ stdio: ['ignore', log, log],
94
+ windowsHide: true,
46
95
  })
47
- .then(async r => { if (!r.ok) console.error(`megit: ${(await r.json()).error}`) })
48
- .catch(() => {})
96
+ child.unref()
97
+
98
+ // unref() only stops the child from holding this process open — 'exit' still
99
+ // fires, which turns the common failure (port already in use) into an
100
+ // immediate answer instead of five seconds of polling a corpse.
101
+ let died = false
102
+ child.once('exit', () => (died = true))
103
+
104
+ // Poll rather than assume: a failed bind would otherwise be reported as success.
105
+ for (let i = 0; i < 50 && !died; i++) {
106
+ if (await megitAnswers(port)) {
107
+ writeFileSync(stateFile, JSON.stringify({ pid: child.pid, port }))
108
+ console.log(`megit → ${url}`)
109
+ process.exit(0)
110
+ }
111
+ await new Promise(r => setTimeout(r, 100))
112
+ }
113
+ console.error(`megit: server did not come up on ${url} — see ${logFile}`)
114
+ process.exit(1)
115
+ }
116
+
117
+ if (cmd === 'stop') {
118
+ const state = readState()
119
+ if (!state || !pidAlive(state.pid)) {
120
+ rmSync(stateFile, { force: true })
121
+ console.log('megit: no background server running')
122
+ process.exit(0)
123
+ }
124
+ if (!(await megitAnswers(state.port))) {
125
+ rmSync(stateFile, { force: true })
126
+ console.error(`megit: stale state file — PID ${state.pid} is not megit, leaving it alone`)
127
+ process.exit(1)
128
+ }
129
+ process.kill(state.pid, 'SIGTERM')
130
+ rmSync(stateFile, { force: true })
131
+ console.log('megit stopped')
132
+ process.exit(0)
49
133
  }
50
134
 
135
+ if (cmd) {
136
+ console.error(`megit: unknown command '${cmd}' — try 'megit --help'`)
137
+ process.exit(1)
138
+ }
139
+
140
+ const { server } = await import('../dist-server/index.js')
141
+ if (!server.listening) await new Promise(r => server.once('listening', r))
142
+
51
143
  // ponytail: no `open` dependency — three platform names cover what it does.
52
144
  const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open'
53
145
  execFile(opener, [url], () => {}) // exit code ignored: explorer.exe returns 1 on success
@@ -1 +1 @@
1
- import{a as e,i as t,n,r}from"./index-BZBjYCd4.js";var i=e(),a=/^<<<<<<< ?(.*)$/,o=/^\|\|\|\|\|\|\| ?(.*)$/,s=/^=======$/,c=/^>>>>>>> ?(.*)$/,l=e=>e.replace(/\r?\n$/,``);function u(e){let t=e.split(/(?<=\n)/),n=[],r=[],i=null,u=`context`,d=()=>{r.length&&n.push({kind:`context`,lines:r}),r=[]};for(let e of t){let t=l(e),f=a.exec(t);if(f){if(u!==`context`)return null;d(),i={ours:[],base:null,theirs:[],oursLabel:f[1].trim(),theirsLabel:``},u=`ours`;continue}if(u===`context`){r.push(e);continue}if(o.exec(t)){if(u!==`ours`)return null;i.base=[],u=`base`;continue}if(s.test(t)){if(u!==`ours`&&u!==`base`)return null;u=`theirs`;continue}let p=c.exec(t);if(p){if(u!==`theirs`)return null;i.theirsLabel=p[1].trim(),n.push({kind:`conflict`,block:i}),i=null,u=`context`;continue}u===`ours`?i.ours.push(e):u===`base`?i.base.push(e):i.theirs.push(e)}return u===`context`?(d(),n.some(e=>e.kind===`conflict`)?n:null):null}function d(e,t){let n=[];return e.forEach((e,r)=>{if(e.kind===`context`){n.push(...e.lines);return}let i=t.get(r);if(!i)throw Error(`no pick for conflict at segment ${r}`);(i===`ours`||i===`both`)&&n.push(...e.block.ours),(i===`theirs`||i===`both`)&&n.push(...e.block.theirs)}),n.join(``)}var f=n(),p=[[`ours`,`Use ours`],[`theirs`,`Use theirs`],[`both`,`Use both`]];function m({note:e,busy:t,onPick:n,onMarkResolved:r}){return(0,f.jsxs)(`div`,{className:`cf-card`,children:[(0,f.jsx)(`div`,{className:`cf-card-note`,children:e}),(0,f.jsxs)(`div`,{className:`cf-card-actions`,children:[r&&(0,f.jsx)(`button`,{className:`primary`,disabled:t,onClick:r,children:`Mark resolved`}),(0,f.jsx)(`button`,{disabled:t,onClick:()=>n(`ours`),children:`Keep ours`}),(0,f.jsx)(`button`,{disabled:t,onClick:()=>n(`theirs`),children:`Keep theirs`}),(0,f.jsx)(`button`,{className:`danger`,disabled:t,onClick:()=>n(`delete`),children:`Delete file`})]})]})}function h({repo:e,file:n,onResolved:a}){let[o,s]=(0,i.useState)(null),[c,l]=(0,i.useState)(new Map),[h,g]=(0,i.useState)(``),[_,v]=(0,i.useState)(!1),y=`repo=${encodeURIComponent(e)}`;(0,i.useEffect)(()=>{s(null),l(new Map),g(``),r(`/api/conflict?${y}&file=${encodeURIComponent(n)}`).then(s).catch(e=>g(e.message))},[e,n]);let b=(0,i.useMemo)(()=>o?.content?u(o.content):null,[o]),x=(0,i.useMemo)(()=>b?.filter(e=>e.kind===`conflict`).length??0,[b]);(0,i.useEffect)(()=>{!b||!x||c.size<x||(v(!0),r(`/api/conflict?${y}`,t(`POST`,{action:`resolve`,file:n,content:d(b,c)})).then(a).catch(e=>{g(e.message),v(!1)}))},[c,b,x]);let S=e=>{v(!0),r(`/api/conflict?${y}`,t(`POST`,{action:e,file:n})).then(a).catch(e=>{g(e.message),v(!1)})},C=()=>{v(!0),r(`/api/conflict?${y}`,t(`POST`,{action:`resolve`,file:n,content:o?.content??``})).then(a).catch(e=>{g(e.message),v(!1)})},w=(e,t)=>l(n=>new Map(n).set(e,t)),T=e=>l(t=>{let n=new Map(t);return n.delete(e),n});return h?(0,f.jsx)(`div`,{className:`diffview error`,children:h}):o?o.tooLarge?(0,f.jsxs)(`div`,{className:`diffview empty`,children:[`File too large to resolve here (`,Math.round((o.size??0)/1024),` KB) — use the terminal`]}):o.binary?(0,f.jsx)(`div`,{className:`cf-view`,children:(0,f.jsx)(m,{note:`Binary file — there is nothing to merge line by line.`,busy:_,onPick:S})}):o.missing?(0,f.jsx)(`div`,{className:`cf-view`,children:(0,f.jsx)(m,{note:`Deleted on one side and modified on the other.`,busy:_,onPick:S})}):b?(0,f.jsxs)(`div`,{className:`cf-view`,children:[(0,f.jsxs)(`div`,{className:`cf-progress`,children:[c.size,` of `,x,` resolved`]}),b.map((e,t)=>{if(e.kind===`context`)return(0,f.jsx)(`pre`,{className:`cf-context`,children:e.lines.join(``)},t);let n=c.get(t);return(0,f.jsxs)(`div`,{className:`cf-block${n?` picked`:``}`,children:[(0,f.jsxs)(`div`,{className:`cf-bar`,children:[(0,f.jsx)(`span`,{className:`cf-side-name`,children:e.block.oursLabel||`ours`}),n?(0,f.jsxs)(f.Fragment,{children:[(0,f.jsxs)(`span`,{className:`cf-chosen`,children:[`took `,n]}),(0,f.jsx)(`button`,{disabled:_,onClick:()=>T(t),children:`Reset`})]}):p.map(([e,n])=>(0,f.jsx)(`button`,{disabled:_,onClick:()=>w(t,e),children:n},e))]}),(!n||n===`ours`||n===`both`)&&(0,f.jsx)(`pre`,{className:`cf-ours`,children:e.block.ours.join(``)}),!n&&(0,f.jsx)(`div`,{className:`cf-mid`,children:e.block.theirsLabel||`theirs`}),(!n||n===`theirs`||n===`both`)&&(0,f.jsx)(`pre`,{className:`cf-theirs`,children:e.block.theirs.join(``)})]},t)})]}):(0,f.jsx)(`div`,{className:`cf-view`,children:(0,f.jsx)(m,{note:`No conflict markers left in this file — it looks already resolved.`,busy:_,onPick:S,onMarkResolved:C})}):(0,f.jsx)(`div`,{className:`diffview empty`,children:`Loading…`})}export{h as default};
1
+ import{a as e,i as t,n,r}from"./index-Ch9W5KCh.js";var i=e(),a=/^<<<<<<< ?(.*)$/,o=/^\|\|\|\|\|\|\| ?(.*)$/,s=/^=======$/,c=/^>>>>>>> ?(.*)$/,l=e=>e.replace(/\r?\n$/,``);function u(e){let t=e.split(/(?<=\n)/),n=[],r=[],i=null,u=`context`,d=()=>{r.length&&n.push({kind:`context`,lines:r}),r=[]};for(let e of t){let t=l(e),f=a.exec(t);if(f){if(u!==`context`)return null;d(),i={ours:[],base:null,theirs:[],oursLabel:f[1].trim(),theirsLabel:``},u=`ours`;continue}if(u===`context`){r.push(e);continue}if(o.exec(t)){if(u!==`ours`)return null;i.base=[],u=`base`;continue}if(s.test(t)){if(u!==`ours`&&u!==`base`)return null;u=`theirs`;continue}let p=c.exec(t);if(p){if(u!==`theirs`)return null;i.theirsLabel=p[1].trim(),n.push({kind:`conflict`,block:i}),i=null,u=`context`;continue}u===`ours`?i.ours.push(e):u===`base`?i.base.push(e):i.theirs.push(e)}return u===`context`?(d(),n.some(e=>e.kind===`conflict`)?n:null):null}function d(e,t){let n=[];return e.forEach((e,r)=>{if(e.kind===`context`){n.push(...e.lines);return}let i=t.get(r);if(!i)throw Error(`no pick for conflict at segment ${r}`);(i===`ours`||i===`both`)&&n.push(...e.block.ours),(i===`theirs`||i===`both`)&&n.push(...e.block.theirs)}),n.join(``)}var f=n(),p=[[`ours`,`Use ours`],[`theirs`,`Use theirs`],[`both`,`Use both`]];function m({note:e,busy:t,onPick:n,onMarkResolved:r}){return(0,f.jsxs)(`div`,{className:`cf-card`,children:[(0,f.jsx)(`div`,{className:`cf-card-note`,children:e}),(0,f.jsxs)(`div`,{className:`cf-card-actions`,children:[r&&(0,f.jsx)(`button`,{className:`primary`,disabled:t,onClick:r,children:`Mark resolved`}),(0,f.jsx)(`button`,{disabled:t,onClick:()=>n(`ours`),children:`Keep ours`}),(0,f.jsx)(`button`,{disabled:t,onClick:()=>n(`theirs`),children:`Keep theirs`}),(0,f.jsx)(`button`,{className:`danger`,disabled:t,onClick:()=>n(`delete`),children:`Delete file`})]})]})}function h({repo:e,file:n,onResolved:a}){let[o,s]=(0,i.useState)(null),[c,l]=(0,i.useState)(new Map),[h,g]=(0,i.useState)(``),[_,v]=(0,i.useState)(!1),y=`repo=${encodeURIComponent(e)}`;(0,i.useEffect)(()=>{s(null),l(new Map),g(``),r(`/api/conflict?${y}&file=${encodeURIComponent(n)}`).then(s).catch(e=>g(e.message))},[e,n]);let b=(0,i.useMemo)(()=>o?.content?u(o.content):null,[o]),x=(0,i.useMemo)(()=>b?.filter(e=>e.kind===`conflict`).length??0,[b]);(0,i.useEffect)(()=>{!b||!x||c.size<x||(v(!0),r(`/api/conflict?${y}`,t(`POST`,{action:`resolve`,file:n,content:d(b,c)})).then(a).catch(e=>{g(e.message),v(!1)}))},[c,b,x]);let S=e=>{v(!0),r(`/api/conflict?${y}`,t(`POST`,{action:e,file:n})).then(a).catch(e=>{g(e.message),v(!1)})},C=()=>{v(!0),r(`/api/conflict?${y}`,t(`POST`,{action:`resolve`,file:n,content:o?.content??``})).then(a).catch(e=>{g(e.message),v(!1)})},w=(e,t)=>l(n=>new Map(n).set(e,t)),T=e=>l(t=>{let n=new Map(t);return n.delete(e),n});return h?(0,f.jsx)(`div`,{className:`diffview error`,children:h}):o?o.tooLarge?(0,f.jsxs)(`div`,{className:`diffview empty`,children:[`File too large to resolve here (`,Math.round((o.size??0)/1024),` KB) — use the terminal`]}):o.binary?(0,f.jsx)(`div`,{className:`cf-view`,children:(0,f.jsx)(m,{note:`Binary file — there is nothing to merge line by line.`,busy:_,onPick:S})}):o.missing?(0,f.jsx)(`div`,{className:`cf-view`,children:(0,f.jsx)(m,{note:`Deleted on one side and modified on the other.`,busy:_,onPick:S})}):b?(0,f.jsxs)(`div`,{className:`cf-view`,children:[(0,f.jsxs)(`div`,{className:`cf-progress`,children:[c.size,` of `,x,` resolved`]}),b.map((e,t)=>{if(e.kind===`context`)return(0,f.jsx)(`pre`,{className:`cf-context`,children:e.lines.join(``)},t);let n=c.get(t);return(0,f.jsxs)(`div`,{className:`cf-block${n?` picked`:``}`,children:[(0,f.jsxs)(`div`,{className:`cf-bar`,children:[(0,f.jsx)(`span`,{className:`cf-side-name`,children:e.block.oursLabel||`ours`}),n?(0,f.jsxs)(f.Fragment,{children:[(0,f.jsxs)(`span`,{className:`cf-chosen`,children:[`took `,n]}),(0,f.jsx)(`button`,{disabled:_,onClick:()=>T(t),children:`Reset`})]}):p.map(([e,n])=>(0,f.jsx)(`button`,{disabled:_,onClick:()=>w(t,e),children:n},e))]}),(!n||n===`ours`||n===`both`)&&(0,f.jsx)(`pre`,{className:`cf-ours`,children:e.block.ours.join(``)}),!n&&(0,f.jsx)(`div`,{className:`cf-mid`,children:e.block.theirsLabel||`theirs`}),(!n||n===`theirs`||n===`both`)&&(0,f.jsx)(`pre`,{className:`cf-theirs`,children:e.block.theirs.join(``)})]},t)})]}):(0,f.jsx)(`div`,{className:`cf-view`,children:(0,f.jsx)(m,{note:`No conflict markers left in this file — it looks already resolved.`,busy:_,onPick:S,onMarkResolved:C})}):(0,f.jsx)(`div`,{className:`diffview empty`,children:`Loading…`})}export{h as default};