gitoriaLog in with ident

gitoria

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit8d9450fd8d9450fdantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre8d9450fd/git.hl

29.2 KB

  1. // git.hl — READING A REPO'S FILES for its homepage (ticket gitoria#8). Each repo is one BARE git
  2. // repository, <gitRoot>/<slug>.git (created with the repo, repos.hl createRepo). Reads go through the
  3. // `git` binary with hl:proc (docs/git-backend.md): an argv list, never a shell, the slug is
  4. // validated by the caller (repos.hl slugError), a path only ever comes from `git ls-tree`.
  5. // Every read calls git with hl:proc run() (hybriel#80: it waits), so the readers answer through their callback
  6. // before they return; the page components use the `...Now` forms at construction (server-rendered, gitoria#16).
  7. //
  8. // THE HOMEPAGE (the creator, ticket #1): the repo's `/` shows README.md as a page; if there is a
  9. // `$docs` folder, all Markdown files inside it form the homepage instead. Read from the repo's
  10. // HEAD (its default branch; the setting to switch it comes with #9).
  11. import { run, env, cwd } from 'hl:proc'
  12. import { markdownBlocks } from './markdown.hl'
  13. import { localStamp } from './localtime.hl'
  14. static NL = "
  15. "
  16. // where the bare repositories live: GITORIA_GIT, else ./storage/git under the launch directory
  17. static gitRoot = env('GITORIA_GIT') != null && env('GITORIA_GIT') != '' ? env('GITORIA_GIT') : cwd() + '/storage/git'
  18. static repoDir = (slug) => { return gitRoot + '/' + slug + '.git' }
  19. static maxFiles = 30 // Markdown files of a $docs folder
  20. static maxLines = 5000 // lines of one file
  21. static maxTree = 20000 // paths looked at
  22. static seconds = 15 // one git call
  23. // one git call: argv (after `git --git-dir=<dir>`), `done({ ok, lines })` exactly once — BEFORE git() returns:
  24. // hl:proc run() waits for the program (hybriel#80), so a page's root can read git while it is built on the server
  25. // and the callback style of the readers below is synchronous. Past `seconds` git is killed; run() is then an Error
  26. // (absorbed by project.hl `on Error`) and yields null → ok = false.
  27. static git = (slug, rest, limit, done) => {
  28. let argv = ['git', '--git-dir=' + repoDir(slug), '-c', 'core.quotePath=false']
  29. for (a of rest) { argv.push(a) }
  30. let r = run(argv, { timeout = seconds })
  31. let lines = []
  32. if (r != null) {
  33. for (l of r.lines) { if (lines.length < limit) { lines.push(l) } }
  34. }
  35. done({ ok = r != null && r.exit == 0 lines = lines })
  36. return null
  37. }
  38. // the SYNCHRONOUS answers of the readers (git() calls back before it returns): what a page's root uses
  39. static homepageNow = (slug, setting) => {
  40. let out = { files = [] found = false truncated = false }
  41. defaultBranch(slug, setting, (ref) => {
  42. loadHomepage(slug, ref, (result) => {
  43. out = result
  44. return null
  45. })
  46. return null
  47. })
  48. return out
  49. }
  50. static browseNow = (slug, kind, rest, setting, owner) => {
  51. let out = null
  52. browse(slug, kind, rest, setting, owner, (result) => {
  53. out = result
  54. return null
  55. })
  56. return out
  57. }
  58. static pullsNow = (slug, setting) => {
  59. let out = null
  60. pulls(slug, setting, (result) => {
  61. out = result
  62. return null
  63. })
  64. return out
  65. }
  66. static releasesNow = (slug, setting) => {
  67. let out = null
  68. releases(slug, setting, (result) => {
  69. out = result
  70. return null
  71. })
  72. return out
  73. }
  74. // a path we let through: printable, no leading dash, no backslash / quote / control characters
  75. static safePath = (path) => {
  76. if (path == null || path == '' || path.length > 300 || path.startsWith('-')) { return false }
  77. let i = 0
  78. while (i < path.length) {
  79. let c = path.charCodeAt(i)
  80. if (c < 32 || c == 127 || c == 34 || c == 92 || c == 58) { return false }
  81. i = i + 1
  82. }
  83. return true
  84. }
  85. static isMarkdown = (path) => { return path.toLowerCase().endsWith('.md') }
  86. // the files that make the homepage: [] (none), or the paths in tree order
  87. static homepagePaths = (tree) => {
  88. let docs = []
  89. let readme = null
  90. for (path of tree) {
  91. if (safePath(path)) {
  92. if (path.startsWith('$docs/') && isMarkdown(path) && docs.length < maxFiles) { docs.push(path) }
  93. if (readme == null && !path.includes('/') && path.toLowerCase() == 'readme.md') { readme = path }
  94. }
  95. }
  96. if (docs.length > 0) { return docs }
  97. return readme != null ? [readme] : []
  98. }
  99. // the homepage of a branch (its ref) → done({ files: [{ path, isDocs, blocks }], found, truncated })
  100. static loadHomepage = (slug, ref, done) => {
  101. if (ref == null) {
  102. done({ files = [] found = false truncated = false })
  103. return null
  104. }
  105. git(slug, ['ls-tree', '-r', '--name-only', ref], maxTree, (t) => {
  106. let paths = t.ok ? homepagePaths(t.lines) : []
  107. let files = []
  108. let truncated = false
  109. let next = (i) => {
  110. if (i >= paths.length) {
  111. done({ files = files found = files.length > 0 truncated = truncated })
  112. return null
  113. }
  114. git(slug, ['cat-file', 'blob', ref + ':' + paths[i]], maxLines + 1, (r) => {
  115. if (r.ok) {
  116. if (r.lines.length > maxLines) { truncated = true }
  117. let lines = r.lines.length > maxLines ? r.lines.slice(0, maxLines) : r.lines
  118. files.push({ path = paths[i] isDocs = paths[i].startsWith('$docs/') blocks = markdownBlocks(lines.join(NL)) })
  119. }
  120. next(i + 1)
  121. return null
  122. })
  123. return null
  124. }
  125. next(0)
  126. return null
  127. })
  128. }
  129. // ---- BROWSING CODE (ticket gitoria#9): /code, /branch/<name>, /commit/<id> ----------------------
  130. // /code = the repo's main branch (the owner's setting, else `main`, else the first branch);
  131. // /branch/<name> = that branch at its last commit; /commit/<id> = the whole project at that commit.
  132. // Behind each: the folder tree, a file's lines, the branches, the latest commits.
  133. // a line of valid UTF-8, as bytes (@ stands for a backslash: a string ending in a backslash cannot be written, so it is cut from '\u005cx')
  134. static utf8Line = '^(?:[@x00-@x7F]|[@xC2-@xDF][@x80-@xBF]|@xE0[@xA0-@xBF][@x80-@xBF]|[@xE1-@xEC@xEE@xEF][@x80-@xBF]{2}|@xED[@x80-@x9F][@x80-@xBF]|@xF0[@x90-@xBF][@x80-@xBF]{2}|[@xF1-@xF3][@x80-@xBF]{3}|@xF4[@x80-@x8F][@x80-@xBF]{2})*$'.replaceAll('@', '\u005cx'.slice(0, 1))
  135. static maxBrowseLines = 2000
  136. static maxBlobBytes = 1000000
  137. static maxCommits = 20
  138. // a URL path piece: percent-encoded, the slashes kept (encodeURIComponent is a global since hybriel #14)
  139. static enc = (s) => { return encodeURIComponent(s).replaceAll('%2F', '/') }
  140. static isHexId = (s) => {
  141. if (s == null || s.length < 4 || s.length > 40) { return false }
  142. let i = 0
  143. while (i < s.length) {
  144. let c = s.charCodeAt(i)
  145. if (!((c >= 48 && c <= 57) || (c >= 97 && c <= 102))) { return false }
  146. i = i + 1
  147. }
  148. return true
  149. }
  150. static safeName = (n) => { return safePath(n) && n.length <= 100 && !n.endsWith('/') && !n.includes('//') }
  151. // the branch names of a repo → done([name])
  152. static branchNames = (slug, done) => {
  153. git(slug, ['for-each-ref', '--format=%(refname:short)', 'refs/heads'], 500, (r) => {
  154. let out = []
  155. if (r.ok) { for (l of r.lines) { if (safeName(l)) { out.push(l) } } }
  156. done(out)
  157. return null
  158. })
  159. }
  160. // which branch is "main" for a repo: the setting if it exists, else `main`, else the first one; null = no branch
  161. static pickDefault = (names, setting) => {
  162. if (setting != null && setting != '' && names.includes(setting)) { return setting }
  163. if (names.includes('main')) { return 'main' }
  164. return names.length > 0 ? names[0] : null
  165. }
  166. static defaultBranch = (slug, setting, done) => {
  167. branchNames(slug, (names) => {
  168. done(pickDefault(names, setting))
  169. return null
  170. })
  171. }
  172. static sizeText = (n) => {
  173. if (n < 1024) { return n + ' B' }
  174. let unit = n < 1048576 ? 1024 : 1048576
  175. let t = (n * 10 - (n * 10) % unit) / unit
  176. return ((t - t % 10) / 10) + '.' + (t % 10) + (n < 1048576 ? ' KB' : ' MB')
  177. }
  178. // which view a page path is: { view: 'home' | 'code' | 'tickets' | 'pulls' | 'releases', kind, rest }
  179. static parseView = (path) => {
  180. let none = { view = 'home' kind = '' rest = '' }
  181. if (path == null || hlTypeName(path) != 'String' || path.length > 500 || path.slice(0, 1) != '/') { return none }
  182. let body = path.slice(1)
  183. let slash = body.indexOf('/')
  184. let head = slash < 0 ? body : body.slice(0, slash)
  185. let rest = slash < 0 ? '' : body.slice(slash + 1)
  186. if (head == 'code' || head == 'branch' || head == 'commit') { return { view = 'code' kind = head rest = rest } }
  187. if (head == 'tickets' && rest == '') { return { view = 'tickets' kind = '' rest = '' } }
  188. if (head == 'pulls' && rest == '') { return { view = 'pulls' kind = '' rest = '' } }
  189. if (head == 'releases' && rest == '') { return { view = 'releases' kind = '' rest = '' } }
  190. return none
  191. }
  192. // tab-separated fields of a line git wrote. (split() / indexOf() do not find a tab in a line that
  193. // came from hl:proc, so scan by character code.)
  194. static tabAt = (line) => {
  195. let i = 0
  196. while (i < line.length) {
  197. if (line.charCodeAt(i) == 9) { return i }
  198. i = i + 1
  199. }
  200. return -1
  201. }
  202. static tabs = (line) => {
  203. let out = []
  204. let start = 0
  205. let i = 0
  206. while (i < line.length) {
  207. if (line.charCodeAt(i) == 9) {
  208. out.push(line.slice(start, i))
  209. start = i + 1
  210. }
  211. i = i + 1
  212. }
  213. out.push(line.slice(start))
  214. return out
  215. }
  216. // the answer's fixed shape: the page reads these names, whatever the state
  217. static shape = (state, message) => {
  218. return { state = state isMessage = message != '' message = message isTree = false isFile = false refLabel = '' kindLabel = ''
  219. sha = '' short = '' commitHref = '' subject = '' author = '' date = '' hasCrumbs = false crumbs = [] entries = [] noEntries = false lines = [] binary = false tooBig = false truncated = false fileSize = ''
  220. branches = [] commits = [] hasCommits = false defaultName = '' isCommitView = false canSet = false }
  221. }
  222. // the answer for one view → done(result). kind: 'code' | 'branch' | 'commit'; rest = what follows in the URL;
  223. // setting = the repo's saved main branch ('' = none); owner = the viewer may change it
  224. static browse = (slug, kind, rest, setting, owner, done) => {
  225. let segs = []
  226. for (x of rest.split('/')) { if (x != '') { segs.push(x) } }
  227. branchNames(slug, (names) => {
  228. if (names.length == 0) {
  229. done(shape('empty', 'This repository has no commits yet. See the Code page to find out how to push code.'))
  230. return null
  231. }
  232. let dflt = pickDefault(names, setting)
  233. let ref = null
  234. let label = ''
  235. let base = ''
  236. let pathSegs = segs
  237. let sha = ''
  238. let go = () => {
  239. let path = pathSegs.join('/')
  240. let bad = false
  241. for (sg of pathSegs) { if (sg == '.' || sg == '..' || !safePath(sg)) { bad = true } }
  242. if (bad) {
  243. done(shape('nopath', 'No such path in ' + label + '.'))
  244. return null
  245. }
  246. let here = (p) => { return p == '' ? (kind == 'code' ? '/code' : base.slice(0, base.length - 1)) : base + enc(p) }
  247. let branchLinks = []
  248. for (n of names) { branchLinks.push({ name = n href = '/branch/' + enc(n) isDefault = n == dflt isCurrent = kind == 'branch' && n == label notCurrent = !(kind == 'branch' && n == label) canSet = owner && n != dflt }) }
  249. git(slug, ['log', '-' + maxCommits, '--format=%H%x09%ct%x09%an%x09%s', ref], maxCommits, (lg) => {
  250. let commits = []
  251. for (l of lg.lines) {
  252. let f = tabs(l)
  253. if (f.length >= 4) {
  254. let sub = f.slice(3).join(' ')
  255. commits.push({ sha = f[0] short = f[0].slice(0, 8) date = localStamp(toNumber(f[1]) * 1000) author = f[2] subject = sub href = '/commit/' + f[0] })
  256. }
  257. }
  258. if (commits.length == 0) {
  259. done(shape('empty', 'This repository has no commits yet. See the Code page to find out how to push code.'))
  260. return null
  261. }
  262. let r = shape('ok', '')
  263. r.refLabel = label
  264. r.kindLabel = kind == 'commit' ? 'Commit' : (kind == 'code' ? 'Main branch' : 'Branch')
  265. r.isCommitView = kind == 'commit'
  266. r.sha = commits[0].sha
  267. r.short = commits[0].short
  268. r.commitHref = '/commit/' + commits[0].sha
  269. r.subject = commits[0].subject
  270. r.author = commits[0].author
  271. r.date = commits[0].date
  272. r.branches = branchLinks
  273. r.commits = commits
  274. r.hasCommits = true
  275. r.defaultName = dflt
  276. r.canSet = owner
  277. let crumbs = [{ name = label href = here('') last = path == '' linked = path != '' }]
  278. let acc = ''
  279. let ci = 0
  280. while (ci < pathSegs.length) {
  281. acc = acc == '' ? pathSegs[ci] : acc + '/' + pathSegs[ci]
  282. crumbs.push({ name = pathSegs[ci] href = here(acc) last = ci == pathSegs.length - 1 linked = ci != pathSegs.length - 1 })
  283. ci = ci + 1
  284. }
  285. r.crumbs = crumbs
  286. r.hasCrumbs = true
  287. let spec = ref + ':' + path
  288. git(slug, ['cat-file', '-t', spec], 1, (ty) => {
  289. let t = ty.ok && ty.lines.length > 0 ? ty.lines[0] : ''
  290. if (t == 'tree') {
  291. git(slug, ['ls-tree', '-l', spec], maxTree, (ls) => {
  292. let dirs = []
  293. let files = []
  294. for (l of ls.ok ? ls.lines : []) {
  295. let tab = tabAt(l)
  296. if (tab > 0) {
  297. let meta = []
  298. for (m of l.slice(0, tab).split(' ')) { if (m != '') { meta.push(m) } }
  299. let name = l.slice(tab + 1)
  300. if (meta.length >= 3 && safePath(name)) {
  301. let full = path == '' ? name : path + '/' + name
  302. let isDir = meta[1] == 'tree'
  303. let isBlob = meta[1] == 'blob'
  304. let e = { name = name isDir = isDir isFile = isBlob isOther = !isDir && !isBlob href = (isDir || isBlob) ? here(full) : '' size = (isBlob && meta.length >= 4 && meta[3] != '-') ? sizeText(toNumber(meta[3])) : '' isSub = meta[1] == 'commit' }
  305. if (isDir) { dirs.push(e) } else { files.push(e) }
  306. }
  307. }
  308. }
  309. for (e of files) { dirs.push(e) }
  310. r.isTree = true
  311. r.entries = dirs
  312. r.noEntries = dirs.length == 0
  313. done(r)
  314. return null
  315. })
  316. } else {
  317. if (t == 'blob') {
  318. git(slug, ['cat-file', '-s', spec], 1, (sz) => {
  319. let bytes = sz.ok && sz.lines.length > 0 ? toNumber(sz.lines[0]) : 0
  320. r.isFile = true
  321. r.fileSize = sizeText(bytes)
  322. if (bytes > maxBlobBytes) {
  323. r.tooBig = true
  324. done(r)
  325. return null
  326. }
  327. // only UTF-8 text is read as lines: any other bytes cannot travel to the page. git's own verdict
  328. // for text (`grep -I`), then a PCRE check that every line is valid UTF-8 (`-v` finds the others).
  329. git(slug, ['--literal-pathspecs', 'grep', '-I', '-l', '-e', '', ref, '--', path], 1, (tx) => {
  330. if (bytes == 0) {
  331. done(r)
  332. return null
  333. }
  334. if (!tx.ok) {
  335. r.binary = true
  336. done(r)
  337. return null
  338. }
  339. git(slug, ['--literal-pathspecs', 'grep', '-I', '-P', '-v', '-c', '-e', utf8Line, ref, '--', path], 1, (bad) => {
  340. if (bad.ok) {
  341. r.binary = true
  342. done(r)
  343. return null
  344. }
  345. git(slug, ['cat-file', 'blob', spec], maxBrowseLines + 1, (b) => {
  346. let lines = []
  347. let n = 1
  348. for (l of b.ok ? b.lines : []) {
  349. if (n <= maxBrowseLines) { lines.push({ n = n text = l }) }
  350. n = n + 1
  351. }
  352. if (n - 1 > maxBrowseLines) { r.truncated = true }
  353. r.lines = lines
  354. done(r)
  355. return null
  356. })
  357. return null
  358. })
  359. return null
  360. })
  361. return null
  362. })
  363. } else {
  364. done(shape('nopath', 'No such path in ' + label + '.'))
  365. }
  366. }
  367. return null
  368. })
  369. return null
  370. })
  371. return null
  372. }
  373. if (kind == 'code') {
  374. ref = dflt
  375. label = dflt
  376. base = '/code/'
  377. go()
  378. return null
  379. }
  380. if (kind == 'branch') {
  381. let k = segs.length
  382. while (k >= 1 && ref == null) {
  383. let cand = segs.slice(0, k).join('/')
  384. if (names.includes(cand)) {
  385. ref = cand
  386. label = cand
  387. pathSegs = segs.slice(k)
  388. }
  389. k = k - 1
  390. }
  391. if (ref == null) {
  392. done(shape('nobranch', segs.length == 0 ? 'Choose a branch.' : "This repository has no branch '" + segs.join('/') + "'."))
  393. return null
  394. }
  395. base = '/branch/' + enc(ref) + '/'
  396. go()
  397. return null
  398. }
  399. // a commit
  400. if (segs.length == 0 || !isHexId(segs[0])) {
  401. done(shape('nocommit', 'No such commit.'))
  402. return null
  403. }
  404. git(slug, ['rev-parse', '--verify', '--quiet', segs[0] + '^{commit}'], 1, (rp) => {
  405. if (!rp.ok || rp.lines.length == 0 || !isHexId(rp.lines[0]) || rp.lines[0].length != 40) {
  406. done(shape('nocommit', "No commit '" + segs[0] + "' in this repository."))
  407. return null
  408. }
  409. sha = rp.lines[0]
  410. ref = sha
  411. label = sha.slice(0, 8)
  412. base = '/commit/' + sha + '/'
  413. pathSegs = segs.slice(1)
  414. go()
  415. return null
  416. })
  417. })
  418. }
  419. // ---- PULL REQUESTS (ticket gitoria#11): /pulls -------------------------------------------------
  420. // A pull request is a COMMIT whose subject starts `|||PR ` (target = the repo's main branch) or
  421. // `|||PR|<branch>] ` (target = <branch>). Nothing is stored: the list is read from git each time.
  422. // Title = the text after the marker (else the source branch's name). Source = the branch holding the
  423. // commit that is not the target. State: `merged` when the commit is in the target branch, else `open`.
  424. // One request per source branch (its newest marker commit). Merging happens with git itself (push to the target).
  425. static maxPulls = 50
  426. static pullScan = 500
  427. // a commit subject → null (not a request) or { target: '' (= default) | name, title }
  428. static parsePull = (subject) => {
  429. if (subject == null) { return null }
  430. if (subject == '|||PR') { return { target = '' title = '' } }
  431. if (subject.startsWith('|||PR ')) { return { target = '' title = subject.slice(6).trim() } }
  432. if (subject.startsWith('|||PR|')) {
  433. let close = subject.indexOf(']')
  434. if (close < 7) { return null }
  435. let target = subject.slice(6, close)
  436. let rest = subject.slice(close + 1)
  437. if (rest != '' && !rest.startsWith(' ')) { return null }
  438. if (!safeName(target)) { return null }
  439. return { target = target title = rest.trim() }
  440. }
  441. return null
  442. }
  443. static pullShape = (state, message) => {
  444. return { state = state isMessage = message != '' message = message pulls = [] hasPulls = false noPulls = false defaultName = '' openCount = 0 mergedCount = 0 }
  445. }
  446. // every pull request of a repo → done(result). setting = the repo's saved main branch ('' = none)
  447. static pulls = (slug, setting, done) => {
  448. branchNames(slug, (names) => {
  449. if (names.length == 0) {
  450. done(pullShape('empty', 'This repository has no commits yet.'))
  451. return null
  452. }
  453. let dflt = pickDefault(names, setting)
  454. git(slug, ['log', '-' + pullScan, '--branches', '--format=%H%x09%ct%x09%an%x09%s'], pullScan, (lg) => {
  455. let found = []
  456. for (l of lg.ok ? lg.lines : []) {
  457. let f = tabs(l)
  458. if (f.length >= 4) {
  459. let sub = f.slice(3).join(' ')
  460. let pr = parsePull(sub)
  461. if (pr != null && isHexId(f[0]) && found.length < maxPulls) {
  462. found.push({ sha = f[0] short = f[0].slice(0, 8) date = localStamp(toNumber(f[1]) * 1000) author = f[2] target = pr.target == '' ? dflt : pr.target title = pr.title })
  463. }
  464. }
  465. }
  466. let out = pullShape('ok', '')
  467. out.defaultName = dflt
  468. let seen = []
  469. let next = (i) => {
  470. if (i >= found.length) {
  471. out.hasPulls = out.pulls.length > 0
  472. out.noPulls = out.pulls.length == 0
  473. done(out)
  474. return null
  475. }
  476. let c = found[i]
  477. git(slug, ['for-each-ref', '--contains', c.sha, '--format=%(refname:short)', 'refs/heads'], 500, (ct) => {
  478. let source = ''
  479. for (n of ct.ok ? ct.lines : []) { if (source == '' && safeName(n) && n != c.target) { source = n } }
  480. if (source != '' && seen.includes(source)) {
  481. next(i + 1)
  482. return null
  483. }
  484. if (source != '') { seen.push(source) }
  485. let finish = (merged, targetMissing) => {
  486. let title = c.title != '' ? c.title : (source != '' ? source : c.short)
  487. out.pulls.push({ sha = c.sha short = c.short href = '/commit/' + c.sha title = title source = source hasSource = source != '' noSource = source == '' sourceHref = source != '' ? '/branch/' + enc(source) : '' target = c.target targetHref = targetMissing ? '' : '/branch/' + enc(c.target) targetLinked = !targetMissing state = merged ? 'merged' : 'open' isMerged = merged isOpen = !merged mergeable = !merged && source != '' && !targetMissing targetMissing = targetMissing author = c.author date = c.date })
  488. if (merged) { out.mergedCount = out.mergedCount + 1 } else { out.openCount = out.openCount + 1 }
  489. next(i + 1)
  490. return null
  491. }
  492. if (!names.includes(c.target)) { return finish(false, true) }
  493. git(slug, ['merge-base', '--is-ancestor', c.sha, c.target], 1, (mb) => {
  494. return finish(mb.ok, false)
  495. })
  496. return null
  497. })
  498. return null
  499. }
  500. next(0)
  501. return null
  502. })
  503. })
  504. }
  505. // the rows with `canMerge` (the owner, an open request whose branches exist) and the button's class: an `if` inside a
  506. // `for` row is not re-evaluated when the list is reassigned (the old button stayed), an attribute is — so the button
  507. // is always there and `.nomerge` hides it (styles.hl)
  508. static withMerge = (list, owner) => {
  509. let out = []
  510. for (p of list) {
  511. let q = p
  512. q.canMerge = owner && p.mergeable
  513. q.mergeClass = q.canMerge ? 'quiet mergepr' : 'nomerge'
  514. out.push(q)
  515. }
  516. return out
  517. }
  518. // ---- MERGE a pull request (ticket gitoria#17): only when the owner clicks the button ---------------------------
  519. // The repo is BARE (no work tree), so the merge is made with plumbing: `merge-tree --write-tree` merges the two
  520. // branches into a tree (exit 1 = conflict: nothing is written to any branch), `commit-tree` makes the merge commit
  521. // (parents: target, source), `update-ref` moves the target — with the old value given, so a push that arrived meanwhile
  522. // makes it fail instead of being overwritten. Always a merge commit (like GitHub's "Create a merge commit").
  523. // The pull request is found again on the SERVER by the sha of its `|||PR` commit (the client only names that).
  524. // → { ok } | { error }. who = the user's name (author of the merge commit).
  525. static gitRaw = (slug, rest) => {
  526. let argv = ['git', '--git-dir=' + repoDir(slug), '-c', 'core.quotePath=false']
  527. for (a of rest) { argv.push(a) }
  528. return run(argv, { timeout = seconds })
  529. }
  530. static mergePullNow = (slug, setting, sha, who) => {
  531. let found = null
  532. if (sha == null || hlTypeName(sha) != 'String' || !isHexId(sha) || sha.length != 40) { return { error = 'no such pull request' } }
  533. let got = pullsNow(slug, setting)
  534. if (got == null || got.isMessage) { return { error = 'no such pull request' } }
  535. for (p of got.pulls) { if (p.sha == sha) { found = p } }
  536. if (found == null) { return { error = 'no such pull request' } }
  537. if (found.isMerged) { return { error = 'this pull request is already merged' } }
  538. if (found.noSource) { return { error = 'the branch of this pull request is gone' } }
  539. if (found.targetMissing) { return { error = 'the target branch ' + found.target + ' does not exist' } }
  540. let target = 'refs/heads/' + found.target
  541. let old = gitRaw(slug, ['rev-parse', '--verify', '-q', target])
  542. let tip = gitRaw(slug, ['rev-parse', '--verify', '-q', 'refs/heads/' + found.source])
  543. if (old == null || old.exit != 0 || old.lines.length == 0 || tip == null || tip.exit != 0 || tip.lines.length == 0) { return { error = 'could not read the branches' } }
  544. let oldSha = old.lines[0]
  545. let tipSha = tip.lines[0]
  546. if (!isHexId(oldSha) || !isHexId(tipSha)) { return { error = 'could not read the branches' } }
  547. let m = gitRaw(slug, ['merge-tree', '--write-tree', '--name-only', '--no-messages', oldSha, tipSha])
  548. if (m == null) { return { error = 'could not merge (git did not answer)' } }
  549. if (m.exit == 1) {
  550. let files = []
  551. let i = 1
  552. while (i < m.lines.length && m.lines[i] != '') {
  553. if (files.length < 5) { files.push(m.lines[i]) }
  554. i = i + 1
  555. }
  556. return { error = 'Not merged: ' + found.source + ' conflicts with ' + found.target + (files.length > 0 ? ' in ' + files.join(', ') : '') + '. Resolve it with git, then push.' }
  557. }
  558. if (m.exit != 0 || m.lines.length == 0 || !isHexId(m.lines[0])) { return { error = 'could not merge' } }
  559. let name = who != null && who != '' ? who : 'gitoria'
  560. let msg = "Merge branch '" + found.source + "' into " + found.target
  561. let c = gitRaw(slug, ['-c', 'user.name=' + name, '-c', 'user.email=noreply@gitoria', 'commit-tree', m.lines[0], '-p', oldSha, '-p', tipSha, '-m', msg])
  562. if (c == null || c.exit != 0 || c.lines.length == 0 || !isHexId(c.lines[0])) { return { error = 'could not write the merge commit' } }
  563. let u = gitRaw(slug, ['update-ref', '-m', msg, target, c.lines[0], oldSha])
  564. if (u == null || u.exit != 0) { return { error = 'The branch ' + found.target + ' changed meanwhile — nothing merged, try again.' } }
  565. return { ok = true }
  566. }
  567. // ---- RELEASES (ticket gitoria#12): /releases ---------------------------------------------------
  568. // A release is a COMMIT on the repo's main branch whose subject starts `|||RL ` (patch +1), `|||RL|med `
  569. // (minor +1, patch 0), `|||RL|mj ` (major +1, minor and patch 0) or `|||RL|<version> ` (set by hand,
  570. // e.g. 1.1.1a). Nothing is stored: the main branch's history is read oldest → newest and the versions are
  571. // counted up from 0.0.0 (so the first `|||RL ` is 0.0.1). A hand-set version that was released before
  572. // is not a release. A release = its version, the text after the marker, the commit (link), author, date.
  573. static maxReleases = 200
  574. static releaseScan = 5000
  575. static isDigits = (s) => {
  576. if (s == '' || s.length > 9) { return false }
  577. let i = 0
  578. while (i < s.length) {
  579. let c = s.charCodeAt(i)
  580. if (c < 48 || c > 57) { return false }
  581. i = i + 1
  582. }
  583. return true
  584. }
  585. // the part of a version after its patch digits: only letters, digits, `.` and `-`
  586. static suffixOk = (s) => {
  587. let i = 0
  588. while (i < s.length) {
  589. let c = s.charCodeAt(i)
  590. if (!((c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c == 45 || c == 46)) { return false }
  591. i = i + 1
  592. }
  593. return true
  594. }
  595. // `1.2.3` or `1.2.3a` → { major, minor, patch, text } or null
  596. static parseVersion = (t) => {
  597. if (t == null || t.length > 32) { return null }
  598. let parts = t.split('.')
  599. if (parts.length < 3) { return null }
  600. if (!isDigits(parts[0]) || !isDigits(parts[1])) { return null }
  601. let rest = parts.slice(2).join('.')
  602. let n = 0
  603. while (n < rest.length && rest.charCodeAt(n) >= 48 && rest.charCodeAt(n) <= 57) { n = n + 1 }
  604. if (n == 0 || n > 9 || !suffixOk(rest.slice(n))) { return null }
  605. return { major = toNumber(parts[0]) minor = toNumber(parts[1]) patch = toNumber(rest.slice(0, n)) text = t }
  606. }
  607. // a commit subject → null (not a release) or { kind: 'patch' | 'med' | 'mj' | 'set', version, title }
  608. static parseRelease = (subject) => {
  609. if (subject == null) { return null }
  610. if (subject == '|||RL') { return { kind = 'patch' version = null title = '' } }
  611. if (subject.startsWith('|||RL ')) { return { kind = 'patch' version = null title = subject.slice(6).trim() } }
  612. if (subject.startsWith('|||RL|')) {
  613. let body = subject.slice(6)
  614. let sp = body.indexOf(' ')
  615. let token = sp < 0 ? body : body.slice(0, sp)
  616. let title = sp < 0 ? '' : body.slice(sp + 1).trim()
  617. if (token == 'med') { return { kind = 'med' version = null title = title } }
  618. if (token == 'mj') { return { kind = 'mj' version = null title = title } }
  619. let v = parseVersion(token)
  620. if (v == null) { return null }
  621. return { kind = 'set' version = v title = title }
  622. }
  623. return null
  624. }
  625. static releaseShape = (state, message) => {
  626. return { state = state isMessage = message != '' message = message releases = [] hasReleases = false noReleases = false defaultName = '' latest = '' }
  627. }
  628. // every release of a repo → done(result). setting = the repo's saved main branch ('' = none)
  629. static releases = (slug, setting, done) => {
  630. branchNames(slug, (names) => {
  631. if (names.length == 0) {
  632. done(releaseShape('empty', 'This repository has no commits yet.'))
  633. return null
  634. }
  635. let dflt = pickDefault(names, setting)
  636. git(slug, ['log', '--topo-order', '--reverse', '--fixed-strings', '--grep=|||RL', '--format=%H%x09%ct%x09%an%x09%s', dflt, '--'], releaseScan, (lg) => {
  637. let out = releaseShape('ok', '')
  638. out.defaultName = dflt
  639. let major = 0
  640. let minor = 0
  641. let patch = 0
  642. let seen = []
  643. let oldest = []
  644. for (l of lg.ok ? lg.lines : []) {
  645. let f = tabs(l)
  646. if (f.length >= 4 && isHexId(f[0])) {
  647. let r = parseRelease(f.slice(3).join(' '))
  648. if (r != null) {
  649. let nmaj = major
  650. let nmin = minor
  651. let npat = patch
  652. let text = ''
  653. if (r.kind == 'patch') { npat = patch + 1 }
  654. if (r.kind == 'med') { nmin = minor + 1 npat = 0 }
  655. if (r.kind == 'mj') { nmaj = major + 1 nmin = 0 npat = 0 }
  656. if (r.kind == 'set') { nmaj = r.version.major nmin = r.version.minor npat = r.version.patch text = r.version.text }
  657. if (text == '') { text = nmaj + '.' + nmin + '.' + npat }
  658. if (!seen.includes(text)) {
  659. seen.push(text)
  660. major = nmaj
  661. minor = nmin
  662. patch = npat
  663. oldest.push({ version = text sha = f[0] short = f[0].slice(0, 8) href = '/commit/' + f[0] title = r.title hasTitle = r.title != '' noTitle = r.title == '' author = f[2] date = localStamp(toNumber(f[1]) * 1000) isLatest = false })
  664. }
  665. }
  666. }
  667. }
  668. let i = oldest.length - 1
  669. while (i >= 0 && out.releases.length < maxReleases) {
  670. let rel = oldest[i]
  671. rel.isLatest = i == oldest.length - 1
  672. out.releases.push(rel)
  673. i = i - 1
  674. }
  675. if (out.releases.length > 0) { out.latest = out.releases[0].version }
  676. out.hasReleases = out.releases.length > 0
  677. out.noReleases = out.releases.length == 0
  678. done(out)
  679. return null
  680. })
  681. })
  682. }
  683. // a new empty bare repository for a repo (a failure is not fatal: a repo without one just has no files)
  684. static initRepo = (slug) => {
  685. return run(['git', 'init', '--bare', '-q', '-b', 'main', repoDir(slug)], { timeout = seconds })
  686. }
  687. // does the repo have no branch yet (nothing pushed)? — the "how to add code" box opens itself then (gitoria#7)
  688. static isEmptyNow = (slug) => {
  689. let empty = true
  690. git(slug, ['for-each-ref', '--count=1', 'refs/heads'], 1, (r) => {
  691. empty = !r.ok || r.lines.length == 0
  692. return null
  693. })
  694. return empty
  695. }

Branches

Latest commits

  • 8d9450fdantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • 205d5fe4gitoria: Hybriel master ff51cf46; ssh keys/tokens no double rows (session sync); gates follow #20mre
  • 9b27cb26gitoria#21: installable app (manifest, service worker, offline start page), own iconmre
  • 68dcb603deploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • e2deed6dgitoria#20: "Add code" only on the Code page of an empty repository, no collapsiblemre
  • 8bb97ffddeploy.sh: never send .git or .gitignore to Byrodinmre
  • fd981932State of 2026-09-27; bin/ no longer tracked (Hybriel commit is in README)mre
  • 4a2d7125initial commitmre