From 2b709e0b55b1638d73b93f7673a3490da8d3309a Mon Sep 17 00:00:00 2001 From: dzdidi Date: Mon, 7 Aug 2023 18:19:41 +0200 Subject: [PATCH] initial commit Signed-off-by: dzdidi --- .gitignore | 5 ++ Readme.md | 25 ++++++ bin/preinstall.sh | 5 ++ package.json | 51 ++++++++++++ src/appHome.js | 118 +++++++++++++++++++++++++++ src/cli.js | 140 +++++++++++++++++++++++++++++++ src/git-remote-pear.js | 112 +++++++++++++++++++++++++ src/git.js | 181 +++++++++++++++++++++++++++++++++++++++++ src/gitpeard.js | 57 +++++++++++++ src/rpc.js | 38 +++++++++ src/state.js | 32 ++++++++ test/appHome.test.js | 68 ++++++++++++++++ test/git.test.js | 65 +++++++++++++++ test/rpc.test.js | 80 ++++++++++++++++++ test/state.test.js | 23 ++++++ test_home.tar.gz | Bin 0 -> 39108 bytes 16 files changed, 1000 insertions(+) create mode 100644 .gitignore create mode 100644 Readme.md create mode 100755 bin/preinstall.sh create mode 100644 package.json create mode 100644 src/appHome.js create mode 100755 src/cli.js create mode 100755 src/git-remote-pear.js create mode 100644 src/git.js create mode 100755 src/gitpeard.js create mode 100644 src/rpc.js create mode 100644 src/state.js create mode 100644 test/appHome.test.js create mode 100644 test/git.test.js create mode 100644 test/rpc.test.js create mode 100644 test/state.test.js create mode 100644 test_home.tar.gz diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e53c948 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +coverage/ +package-lock.json +.test_home + diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..831e6c1 --- /dev/null +++ b/Readme.md @@ -0,0 +1,25 @@ +# gitpear - 🍐2🍐 transport for git + +CLI, Daemon and [Remote helper](https://www.git-scm.com/docs/gitremote-helpers) for git. It is based on [holepunch](https://docs.holepunch.to/) for networking and data sharing. + +## + +gitpear creates local [bare repository](https://git-scm.com/docs/git-init#Documentation/git-init.txt---bare) in application directory (default `~/.gitpear/`), adds it as a [git remote](https://git-scm.com/docs/git-remote) in corresponding repository with name `pear`. So just like in traditional flow doing `git push origin`, here we do `git push pear`. Upon each push gitpear regenerates [pack files](https://git-scm.com/book/en/v2/Git-Internals-Packfiles) that are shared in ephemeral [hyperdrive](https://docs.holepunch.to/building-blocks/hyperdrive). + +To enable clone or fetch or pull using `git pear:/`. It implements [git remote helper](https://www.git-scm.com/docs/gitremote-helpers) that uses [hyperswarm](https://docs.holepunch.to/building-blocks/hyperswarm) for networking in order to directly connect to peer. After connection is initialized it sends RPC request to retrieve list of repositories, clone corresponding pack files and unpack them locally. + +## + +All data will be persisted in application directory (default `~/.gitpear`). To change it. Provide environment variable `GIT_PEAR` + +* `gitpear daemon <-s, --start | -k, --stop>` - start or stop daemon + +* `gitpear key` - print out public key. Share it with your peers so that they can do `git pull pear:/` + +* `gitpear init [-s, --share] ` - It will create [bare repository](https://git-scm.com/docs/git-init#Documentation/git-init.txt---bare) of the same name in application directory (default ~/.gitpear/). It will add [git remote](https://git-scm.com/docs/git-remote) in current repository with name `pear`. So just like in traditional flow doing `git push orign`, here we do `git push pear`. By default repository will not be shared. To enable sharing provide `-s` or call `gitpear share ` later + +* `gitpear share ` - makes repository sharable + +* `gitpear unshare ` - stop sharing repository + +* `gitpear list [-s, --shared]` - list all or (only shared) repositories diff --git a/bin/preinstall.sh b/bin/preinstall.sh new file mode 100755 index 0000000..43107c7 --- /dev/null +++ b/bin/preinstall.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +if [ ! -d $HOME/.gitpear ]; then + mkdir -p $HOME/.gitpear; +fi diff --git a/package.json b/package.json new file mode 100644 index 0000000..bb5e504 --- /dev/null +++ b/package.json @@ -0,0 +1,51 @@ +{ + "name": "gitpear", + "version": "1.0.0", + "description": "p2p transport helpers, daemon and cli for git based on holepunch/hypercore stack", + "scripts": { + "test": "GIT_PEAR=./.test_home brittle ./test/**/*.test.js --coverage --bail", + "types": "tsc src/*.js --declaration --allowJs --emitDeclarationOnly --outDir types --target es2015", + "lint": "standard --fix", + "preinstall": "./bin/preinstall.sh", + "set-test": "tar xzf test_home.tar.gz -C . " + }, + "bin": { + "gitpeard": "./src/gitpeard.js", + "git-remote-pear": "./src/git-remote-pear.js", + "gitpear": "./src/cli.js" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/dzdidi/gitpear.git" + }, + "keywords": [ + "p2p", + "pear2pear", + "peer2peer", + "git", + "transport", + "holepunch", + "hypercore" + ], + "author": "dzdidi", + "license": "MIT", + "bugs": { + "url": "https://github.com/dzdidi/gitpear/issues" + }, + "homepage": "https://github.com/dzdidi/gitpear#readme", + "devDependencies": { + "@hyperswarm/testnet": "^3.1.4", + "brittle": "^3.3.2", + "standard": "^17.1.0", + "typescript": "^5.1.3" + }, + "dependencies": { + "chokidar": "^3.5.3", + "commander": "^11.0.0", + "corestore": "^6.10.1", + "hyperdrive": "^11.5.3", + "hyperswarm": "^4.5.1", + "protomux-rpc": "^1.4.1", + "random-access-memory": "^6.2.0" + } +} diff --git a/src/appHome.js b/src/appHome.js new file mode 100644 index 0000000..e5541fc --- /dev/null +++ b/src/appHome.js @@ -0,0 +1,118 @@ +const homedir = require('os').homedir() +const crypto = require('hypercore-crypto') +const chokidar = require('chokidar') + +const fs = require('fs') + +const APP_HOME = process.env.GIT_PEAR || `${homedir}/.gitpear` + +function createAppFolder (name) { + fs.mkdirSync(`${APP_HOME}/${name}/code`, { recursive: true }) +} + +function shareAppFolder (name) { + fs.openSync(`${APP_HOME}/${name}/.git-daemon-export-ok`, 'w') +} + +function unshareAppFolder (name) { + fs.unlinkSync(`${APP_HOME}/${name}/.git-daemon-export-ok`) +} + +function isInitialized (name) { + return fs.existsSync(`${APP_HOME}/${name}/code/HEAD`) +} + +function isShared (name) { + return fs.existsSync(`${APP_HOME}/${name}/.git-daemon-export-ok`) +} + +function list (sharedOnly) { + const repos = fs.readdirSync(APP_HOME) + if (!sharedOnly) return repos.filter(r => !r.startsWith('.')) + + return repos.filter(repo => isShared(repo)) +} + +function getCodePath (name) { + return `${APP_HOME}/${name}/code` +} + +function readPk () { + try { + const seed = fs.readFileSync(`${APP_HOME}/.seed`) + const keyPair = crypto.keyPair(seed) + return keyPair.publicKey.toString('hex') + } catch (e) { + if (e.code !== 'ENOENT') throw e + + console.error('Seed will be generated after first start of daemon') + } +} + +function getKeyPair () { + let seed + try { + seed = fs.readFileSync(`${APP_HOME}/.seed`) + } catch (e) { + if (e.code !== 'ENOENT') throw e + + seed = crypto.randomBytes(32) + fs.writeFileSync(`${APP_HOME}/.seed`, seed) + } + return crypto.keyPair(seed) +} + +function watch (cb) { + chokidar.watch(APP_HOME).on('all', (event, path) => { + if (!['add', 'change', 'unlink'].includes(event)) return + + return cb(event, path) + }) +} + +function getOutStream () { + return fs.openSync(`${APP_HOME}/out.log`, 'a') +} + +function getErrStream () { + return fs.openSync(`${APP_HOME}/err.log`, 'a') +} + +function storeDaemonPid (pid) { + fs.writeFileSync(`${APP_HOME}/.daemon.pid`, Buffer.from(pid.toString())) +} + +function getDaemonPid () { + try { + return parseInt(fs.readFileSync(`${APP_HOME}/.daemon.pid`).toString()) + } catch (e) { + if (e.code !== 'ENOENT') throw e + } +} + +function removeDaemonPid () { + try { + fs.unlinkSync(`${APP_HOME}/.daemon.pid`) + } catch (e) { + if (e.code !== 'ENOENT') throw e + } +} + +module.exports = { + createAppFolder, + shareAppFolder, + unshareAppFolder, + isInitialized, + isShared, + list, + readPk, + getKeyPair, + watch, + getCodePath, + APP_HOME, + getOutStream, + getErrStream, + storeDaemonPid, + getDaemonPid, + removeDaemonPid +} diff --git a/src/cli.js b/src/cli.js new file mode 100755 index 0000000..d9cc587 --- /dev/null +++ b/src/cli.js @@ -0,0 +1,140 @@ +#!/usr/bin/env node + +const { spawn } = require('child_process') + +const commander = require('commander') +const program = new commander.Command() + +const path = require('path') +const fs = require('fs') + +const appHome = require('./appHome') +const git = require('./git') + +const pkg = require('../package.json') +program + .name('gitpear-cli') + .description('CLI to gitpear') + .version(pkg.version) + +program + .command('init') + .description('initialize a gitpear repo') + .addArgument(new commander.Argument('[p]', 'path to the repo').default('.')) + .option('-s, --share', 'share the repo, default false') + .action(async (p, options) => { + const fullPath = path.resolve(p) + if (!fs.existsSync(path.join(fullPath, '.git'))) { + console.error('Not a git repo') + process.exit(1) + } + + const name = fullPath.split(path.sep).pop() + if ((appHome.isInitialized(name))) { + console.error(`${name} is already initialized`) + process.exit(1) + } + + appHome.createAppFolder(name) + console.log(`Added project "${name}" to gitpear`) + await git.createBareRepo(name) + console.log(`Created bare repo for "${name}"`) + await git.addRemote(name) + console.log(`Added git remote for "${name}" as "pear"`) + + if (options.share) { + appHome.shareAppFolder(name) + console.log(`Shared "${name}" project`) + // push? + } + }) + +program + .command('share') + .description('share a gitpear repo') + .addArgument(new commander.Argument('[p]', 'path to the repo').default('.')) + .action(async (p, options) => { + const name = path.resolve(p).split(path.sep).pop() + if ((appHome.isInitialized(name))) { + appHome.shareAppFolder(name) + console.log(`Shared "${name}" project`) + return + } + + console.error(`${name} is not initialized`) + process.exit(1) + }) + +program + .command('unshare') + .description('unshare a gitpear repo') + .addArgument(new commander.Argument('[p]', 'path to the repo').default('.')) + .action((p, options) => { + const name = path.resolve(p).split(path.sep).pop() + if ((appHome.isInitialized(name))) { + appHome.unshareAppFolder(name) + console.log(`Unshared "${name}" project`) + + return + } + + console.error(`${name} is not initialized`) + process.exit(1) + }) + +program + .command('list') + .description('list all gitpear repos') + .option('-s, --shared', 'list only shared repos') + .action((p, options) => { + appHome.list(options.opts().shared).forEach(name => console.log(name)) + }) + +program + .command('key') + .description('get a public key of gitpear') + .action((p, options) => { + console.log('Public key:', appHome.readPk()) + }) + +program + .command('daemon') + .description('start/stop gitpear daemon') + .option('-s, --start', 'start daemon') + .option('-k, --stop', 'stop daemon') + .action((p, options) => { + if (options.opts().start) { + if (appHome.getDaemonPid()) { + console.error('Daemon already running with PID:', appHome.getDaemonPid()) + process.exit(1) + } + + const daemon = spawn('gitpeard', { + detached: true, + stdio: [ + 'ignore', + appHome.getOutStream(), + appHome.getErrStream() + ] + }) + console.log('Daemon started. Process ID:', daemon.pid) + appHome.storeDaemonPid(daemon.pid) + daemon.unref() + } else if (options.opts().stop) { + if (!appHome.getDaemonPid()) { + console.error('Daemon not running') + process.exit(1) + } + + const pid = appHome.getDaemonPid() + process.kill(pid) + + appHome.removeDaemonPid() + console.log('Daemon stopped. Process ID:', pid) + } else { + console.error('No option provided') + process.exit(1) + } + }) + +program.parse() diff --git a/src/git-remote-pear.js b/src/git-remote-pear.js new file mode 100755 index 0000000..ec375c3 --- /dev/null +++ b/src/git-remote-pear.js @@ -0,0 +1,112 @@ +#!/usr/bin/env node + +const ProtomuxRPC = require('protomux-rpc') + +const RAM = require('random-access-memory') +const Corestore = require('corestore') +const Hyperswarm = require('hyperswarm') +const Hyperdrive = require('hyperdrive') +const crypto = require('hypercore-crypto') + +const git = require('./git.js') + +const url = process.argv[3] +const matches = url.match(/pear:\/\/([a-f0-9]{64})\/(.*)/) + +if (!matches || matches.length < 3) { + console.error('Invalid URL') + process.exit(1) +} + +const targetKey = matches[1] +const repoName = matches[2] + +const store = new Corestore(RAM) +const swarm = new Hyperswarm() + +swarm.join(crypto.discoveryKey(Buffer.from(targetKey, 'hex')), { server: false }) + +swarm.on('connection', async (socket) => { + store.replicate(socket) + const rpc = new ProtomuxRPC(socket) + + const reposRes = await rpc.request('get-repos') + const repositories = JSON.parse(reposRes.toString()) + if (!repositories) process.exit(1) + + const driveKey = Buffer.from(repositories[repoName], 'hex') + if (!driveKey) { + console.error('Failed to retrieve pack key') + process.exit(1) + } + + const packStore = store.namespace(repoName) + const drive = new Hyperdrive(packStore, driveKey) + await drive.ready() + swarm.join(drive.discoveryKey, { server: false, client: true }) + await swarm.flush() + + await drive.core.update({ wait: true }) + + const refsRes = await rpc.request('get-refs', Buffer.from(repoName)) + + await talkToGit(JSON.parse(refsRes.toString()), drive) +}) + +async function talkToGit (refs, drive) { + for (const ref in refs) { + console.warn(refs[ref] + '\t' + ref) + } + process.stdin.setEncoding('utf8') + const didFetch = false + process.stdin.on('readable', async function () { + const chunk = process.stdin.read() + if (chunk === 'capabilities\n') { + process.stdout.write('fetch\n\n') + } else if (chunk === 'list\n') { + Object.keys(refs).forEach(function (branch, i) { + process.stdout.write(refs[branch] + ' ' + branch + '\n') + }) + process.stdout.write('\n') + } else if (chunk && chunk.search(/^fetch/) !== -1) { + const lines = chunk.split(/\n/).filter(l => l !== '') + + const targets = [] + await lines.forEach(async function (line) { + if (line === '') return + + line = line.split(/\s/) + + if (targets.includes(line[1])) return + + targets.push(line[1]) + }) + + for (let i = 0; i < targets.length; i++) { + const sha = targets[i] + + const exist = await drive.exists(`/packs/${sha}.pack`) + if (!exist) process.exit(1) + + const driveStream = drive.createReadStream(`/packs/${sha}.pack`, { start: 0 }) + await git.unpackStream(driveStream) + } + + process.stdout.write('\n\n') + process.exit(0) + } else if (chunk && chunk !== '' && chunk !== '\n') { + console.warn('unhandled command: "' + chunk + '"') + } + + if (chunk === '\n') { + process.stdout.write('\n') + if (!didFetch) { + // If git already has all the refs it needs, we should exit now. + process.exit() + } + } + }) + process.stdout.on('error', function () { + // stdout was closed + }) +} diff --git a/src/git.js b/src/git.js new file mode 100644 index 0000000..1b847b4 --- /dev/null +++ b/src/git.js @@ -0,0 +1,181 @@ +const { getCodePath } = require('./appHome') +const { spawn } = require('child_process') + +async function lsPromise (url) { + const ls = spawn('git', ['ls-remote', url]) + const res = {} + + ls.stdout.on('data', lines => lines.toString().split('\n').forEach((line) => { + if (!line) return + + const [sha, branch] = line.split('\t') + res[branch] = sha + })) + + return new Promise((resolve, reject) => { + ls.on('close', (code) => { + if (!code) return resolve(res) + + reject(new Error(`git ls-remote exited with code ${code}`)) + }) + }) +} + +async function createBareRepo (name) { + const init = spawn('git', ['init', '--bare'], { env: { GIT_DIR: getCodePath(name) } }) + init.stderr.pipe(process.stderr) + return new Promise((resolve, reject) => { + init.on('close', (code) => { + if (code) return reject(new Error(`git init exited with code ${code}`)) + + resolve() + }) + }) +} + +async function addRemote (name) { + const init = spawn('git', ['remote', 'add', 'pear', getCodePath(name)]) + init.stderr.pipe(process.stderr) + return new Promise((resolve, reject) => { + init.on('close', (code) => { + if (code) { + return reject(new Error(`git remote add exited with code ${code}`)) + } + + return resolve() + }) + }) +} + +function pad4 (num) { + num = num.toString(16) + while (num.length < 4) { + num = '0' + num + } + return num +} + +function uploadPack (dir, want, have) { + // reference: + // https://github.com/git/git/blob/b594c975c7e865be23477989d7f36157ad437dc7/Documentation/technical/pack-protocol.txt#L346-L393 + const upload = spawn('git-upload-pack', [dir]) + writeln('want ' + want) + writeln() + if (have) { + writeln('have ' + have) + writeln() + } + writeln('done') + + // We want to read git's output one line at a time, and not read any more + // than we have to. That way, when we finish discussing wants and haves, we + // can pipe the rest of the output to a stream. + // + // We use `mode` to keep track of state and formulate responses. It returns + // `false` when we should stop reading. + let mode = list + upload.stdout.on('readable', function () { + while (true) { + const line = getline() + if (line === null) { + return // to wait for more output + } + if (!mode(line)) { + upload.stdout.removeAllListeners('readable') + upload.emit('ready') + return + } + } + }) + + let getLineLen = null + // Extracts exactly one line from the stream. Uses `getLineLen` in case the + // whole line could not be read. + function getline () { + // Format: '####line' where '####' represents the length of 'line' in hex. + if (!getLineLen) { + getLineLen = upload.stdout.read(4) + if (getLineLen === null) { + return null + } + getLineLen = parseInt(getLineLen, 16) + } + + if (getLineLen === 0) { + return '' + } + + // Subtract by the four we just read, and the terminating newline. + const line = upload.stdout.read(getLineLen - 4 - 1) + if (!line) { + return null + } + getLineLen = null + upload.stdout.read(1) // And discard the newline. + return line.toString() + } + + // First, the server lists the refs it has, but we already know from + // `git ls-remote`, so wait for it to signal the end. + function list (line) { + if (line === '') { + mode = have ? ackObjectsContinue : waitForNak + } + return true + } + + // If we only gave wants, git should respond with 'NAK', then the pack file. + function waitForNak (line) { + return line !== 'NAK' + } + + // With haves, we wait for 'ACK', but only if not ending in 'continue'. + function ackObjectsContinue (line) { + return !(line.search(/^ACK/) !== -1 && line.search(/continue$/) === -1) + } + + // Writes one line to stdin so git-upload-pack can understand. + function writeln (line) { + if (line) { + const len = pad4(line.length + 4 + 1) // Add one for the newline. + upload.stdin.write(len + line + '\n') + } else { + upload.stdin.write('0000') + } + } + + return upload +} + +async function unpackFile (file, path) { + const unpack = spawn('git', ['index-pack', '-v', file, '-o', path]) + unpack.stderr.pipe(process.stderr) + + return new Promise((resolve, reject) => { + unpack.on('exit', (code) => { + // These writes are actually necessary for git to finish checkout. + process.stdout.write('\n\n') + if (code) return reject(code) + + return resolve() + }) + }) +} + +async function unpackStream (packStream) { + const unpack = spawn('git', ['index-pack', '--stdin', '-v', '--fix-thin']) + unpack.stderr.pipe(process.stderr) + + packStream.pipe(unpack.stdin) + + return new Promise((resolve, reject) => { + unpack.on('exit', (code) => { + // These writes are actually necessary for git to finish checkout. + if (code) return reject(code) + + return resolve() + }) + }) +} + +module.exports = { lsPromise, uploadPack, unpackFile, unpackStream, createBareRepo, addRemote } diff --git a/src/gitpeard.js b/src/gitpeard.js new file mode 100755 index 0000000..ca84554 --- /dev/null +++ b/src/gitpeard.js @@ -0,0 +1,57 @@ +#!/usr/bin/env node +const RAM = require('random-access-memory') +const Hyperswarm = require('hyperswarm') +const crypto = require('hypercore-crypto') + +const RPC = require('./rpc.js') +const setState = require('./state.js') +const appHome = require('./appHome.js') + +const Corestore = require('corestore') + +;(async () => { + const keyPair = appHome.getKeyPair() + const swarm = new Hyperswarm({ keyPair }) + + const store = new Corestore(RAM) + + swarm.join(crypto.discoveryKey(keyPair.publicKey)) + await swarm.flush() + + console.log('Public key:', appHome.readPk()) + + let state = await setState(store) + let { announcedRefs, repositories, drives } = state + let oldAnnouncedRefs = Object.keys({ ...announcedRefs }).sort().join(',') + + logRepos(repositories) + + let rpc = new RPC(announcedRefs, repositories, drives) + + appHome.watch(async (event, path) => { + state = await setState(store, drives) + announcedRefs = state.announcedRefs + repositories = state.repositories + drives = state.drives + + const newAnnouncedRefs = Object.keys({ ...announcedRefs }).sort().join(',') + if (oldAnnouncedRefs === newAnnouncedRefs) return + oldAnnouncedRefs = newAnnouncedRefs + + logRepos(repositories) + + rpc = new RPC(announcedRefs, repositories, drives) + }) + + swarm.on('connection', (socket, peerInfo) => { + socket.on('error', console.error) + store.replicate(socket) + rpc.setHandlers(socket, peerInfo) + }) +})() + +function logRepos (repositories) { + for (const repo in repositories) { + for (const ref in repositories[repo]) console.log(repositories[repo][ref], '\t', ref, '\t', repo) + } +} diff --git a/src/rpc.js b/src/rpc.js new file mode 100644 index 0000000..34ad1f2 --- /dev/null +++ b/src/rpc.js @@ -0,0 +1,38 @@ +const ProtomuxRPC = require('protomux-rpc') + +module.exports = class RPC { + constructor (announcedRefs, repositories, drives) { + this.connections = {} + this.announcedRefs = announcedRefs + this.repositories = repositories + this.drives = drives + } + + async setHandlers (socket, peerInfo) { + if (this.connections[peerInfo.publicKey]) return this.connections[peerInfo.publicKey] + + const rpc = new ProtomuxRPC(socket) + // XXX: handshaking can be used for access and permission management + // for example check of peerInfo.publicKey is in a list of allowed keys + // which can in turn be stored in a .git-daemon-export-ok file + + rpc.respond('get-repos', req => this.getReposHandler(req)) + rpc.respond('get-refs', async req => await this.getRefsHandler(req)) + + this.connections[peerInfo.publicKey] = rpc + } + + getReposHandler (_req) { + const res = {} + for (const repo in this.repositories) { + res[repo] = this.drives[repo].key.toString('hex') + } + return Buffer.from(JSON.stringify(res)) + } + + getRefsHandler (req) { + const res = this.repositories[req.toString()] + + return Buffer.from(JSON.stringify(res)) + } +} diff --git a/src/state.js b/src/state.js new file mode 100644 index 0000000..e79b15e --- /dev/null +++ b/src/state.js @@ -0,0 +1,32 @@ +const Hyperdrive = require('hyperdrive') + +const git = require('./git.js') +const appHome = require('./appHome.js') + +module.exports = async function setState (store, drives = {}) { + const repos = appHome.list(true) + + const announcedRefs = {} + const repositories = {} + + for (const repo of repos) { + if (!drives[repo]) { + drives[repo] = new Hyperdrive(store.namespace(repo)) + await drives[repo].ready() + } + + const ls = await git.lsPromise(appHome.getCodePath(repo)) + + repositories[repo] = {} + for (const ref in ls) { + repositories[repo][ref] = ls[ref] + announcedRefs[ls[ref]] = repo + + const localPackStream = git.uploadPack(appHome.getCodePath(repo), ls[ref]) + const driveStream = drives[repo].createWriteStream(`/packs/${ls[ref]}.pack`) + localPackStream.on('ready', () => localPackStream.stdout.pipe(driveStream)) + } + } + + return { announcedRefs, repositories, drives } +} diff --git a/test/appHome.test.js b/test/appHome.test.js new file mode 100644 index 0000000..06b39a9 --- /dev/null +++ b/test/appHome.test.js @@ -0,0 +1,68 @@ +const { test } = require('brittle') +const fs = require('fs') +const path = require('path') + +const appHome = require('../src/appHome') + +test('getAppHome', t => { + t.ok(appHome.APP_HOME) +}) + +test('createAppFolder, share, is shared, unshare, isInitialized, list, getCodePath', t => { + appHome.createAppFolder('appHome-test') + + t.ok(fs.existsSync(path.join(appHome.APP_HOME, 'appHome-test', 'code'))) + + t.absent(appHome.isShared('appHome-test')) + t.absent(fs.existsSync(path.join(appHome.APP_HOME, 'appHome-test', '.git-daemon-export-ok'))) + + appHome.shareAppFolder('appHome-test') + + t.ok(appHome.isShared('appHome-test')) + t.ok(fs.existsSync(path.join(appHome.APP_HOME, 'appHome-test', '.git-daemon-export-ok'))) + + appHome.unshareAppFolder('appHome-test') + + t.absent(appHome.isShared('appHome-test')) + t.absent(fs.existsSync(path.join(appHome.APP_HOME, 'appHome-test', '.git-daemon-export-ok'))) + + t.absent(appHome.isInitialized('appHome-test')) + t.ok(appHome.isInitialized('foo')) + + t.alike(new Set(appHome.list()), new Set(['foo', 'bar', 'zar', 'appHome-test'])) + t.alike(new Set(appHome.list(true)), new Set(['foo', 'bar', 'zar'])) + + t.alike(path.resolve(appHome.getCodePath('appHome-test')), path.resolve(path.join(appHome.APP_HOME, 'appHome-test', 'code'))) + + t.teardown(() => { + fs.rmdirSync(path.join(appHome.APP_HOME, 'appHome-test', 'code'), { recursive: true }) + }) +}) + +test('readPk, getKeyPair', t => { + t.ok(appHome.readPk()) + t.ok(appHome.getKeyPair()) +}) + +test('getOutStream, getErrStream', t => { + t.absent(fs.existsSync(path.join(appHome.APP_HOME, 'out.log'))) + t.ok(appHome.getOutStream()) + t.ok(fs.existsSync(path.join(appHome.APP_HOME, 'out.log'))) + + t.absent(fs.existsSync(path.join(appHome.APP_HOME, 'err.log'))) + t.ok(appHome.getErrStream()) + t.ok(fs.existsSync(path.join(appHome.APP_HOME, 'err.log'))) + + t.teardown(() => { + fs.unlinkSync(path.join(appHome.APP_HOME, 'out.log')) + fs.unlinkSync(path.join(appHome.APP_HOME, 'err.log')) + }) +}) + +test('getDaemonPid, removeDaemonPid', t => { + t.absent(appHome.getDaemonPid()) + appHome.storeDaemonPid(123) + t.alike(appHome.getDaemonPid(), 123) + appHome.removeDaemonPid() + t.absent(appHome.getDaemonPid()) +}) diff --git a/test/git.test.js b/test/git.test.js new file mode 100644 index 0000000..9217953 --- /dev/null +++ b/test/git.test.js @@ -0,0 +1,65 @@ +const test = require('brittle') +const fs = require('fs') +const path = require('path') + +const appHome = require('../src/appHome.js') + +const git = require('../src/git.js') + +test('git - lsPromise', async t => { + const res = await git.lsPromise('./') + + t.ok(res) + t.ok(res.HEAD) + t.is(Buffer.from(res.HEAD, 'hex').length, 20) + for (const key in res) { + if (key === 'HEAD') continue + + t.ok(key.startsWith('refs/')) + t.is(Buffer.from(res[key], 'hex').length, 20) + } +}) + +test('git - uploadPack (wo have)', async t => { + t.plan(3) + const { HEAD } = await git.lsPromise('./') + t.ok(HEAD) + + const res = git.uploadPack('./', HEAD) + res.on('exit', (code) => t.ok(code === 0)) + res.on('ready', () => { + const stream = fs.createWriteStream('/dev/null') + res.stdout.pipe(stream) + stream.on('close', () => t.pass()) + }) +}) + +test('git - uploadPack (w have)', { skip: true }, async t => { + t.plan(3) + const SECOND_COMMIT = '' + const { HEAD } = await git.lsPromise('./') + t.ok(HEAD) + + const res = git.uploadPack('./', HEAD, SECOND_COMMIT) + + res.on('exit', (code) => t.ok(code === 0)) + res.on('ready', () => { + const stream = fs.createWriteStream('/dev/null') + res.stdout.pipe(stream) + stream.on('close', () => t.pass()) + }) +}) + +test('git - createBareRepo', async t => { + t.absent(fs.existsSync(path.join(appHome.APP_HOME, 'test-git', 'code'))) + appHome.createAppFolder('test-git') + + t.absent(fs.existsSync(path.join(appHome.APP_HOME, 'test-git', 'code', 'HEAD'))) + await git.createBareRepo('test-git') + + t.ok(fs.existsSync(path.join(appHome.APP_HOME, 'test-git', 'code', 'HEAD'))) + + t.teardown(() => { + fs.rmdirSync(path.join(appHome.APP_HOME, 'test-git'), { recursive: true }) + }) +}) diff --git a/test/rpc.test.js b/test/rpc.test.js new file mode 100644 index 0000000..9b9a7d2 --- /dev/null +++ b/test/rpc.test.js @@ -0,0 +1,80 @@ +const test = require('brittle') +const RAM = require('random-access-memory') +const createTestnet = require('@hyperswarm/testnet') +const Corestore = require('corestore') +const Hyperswarm = require('hyperswarm') +const Hyperdrive = require('hyperdrive') +const ProtomuxRPC = require('protomux-rpc') + +const RPC = require('../src/rpc.js') +const setState = require('../src/state.js') + +test('constructor', async t => { + const rpc = new RPC('announcedRefs', 'repositories', 'drives') + t.ok(rpc) + + t.is(rpc.announcedRefs, 'announcedRefs') + t.is(rpc.repositories, 'repositories') + t.is(rpc.drives, 'drives') + t.alike(rpc.connections, {}) +}) + +test('e2e', async t => { + t.plan(3) + const testnet = await createTestnet(3, t) + + const { rpc, store } = await getRPC() + const clientStore = new Corestore(RAM) + const topic = Buffer.alloc(32).fill('pear 2 pear') + + const serverSwarm = new Hyperswarm(testnet) + serverSwarm.on('connection', (socket, details) => { + store.replicate(socket) + rpc.setHandlers(socket, details) + }) + serverSwarm.join(topic) + await serverSwarm.flush() + + const clientSwarm = new Hyperswarm(testnet) + clientSwarm.on('connection', async (socket) => { + clientStore.replicate(socket) + const rpc = new ProtomuxRPC(socket) + + const reposRes = await rpc.request('get-repos') + const reposJSON = JSON.parse(reposRes.toString()) + + const driveKey = Buffer.from(reposJSON.foo, 'hex') + t.ok(driveKey) + + const drive = new Hyperdrive(clientStore.namespace('foo'), driveKey) + await drive.ready() + clientSwarm.join(drive.discoveryKey, { server: false, client: true }) + await clientSwarm.flush() + + await drive.core.update({ wait: true }) + + const refsRes = await rpc.request('get-refs', Buffer.from('foo')) + t.ok(refsRes) + + const want = Object.values(JSON.parse(refsRes.toString()))[0] + + const exists = await drive.exists(`/packs/${want}.pack`) + t.ok(exists) + }) + + clientSwarm.join(topic, { server: false, client: true }) + + t.teardown(async () => { + await serverSwarm.destroy() + await clientSwarm.destroy() + }) +}) + +async function getRPC () { + const store = new Corestore(RAM) + const { announcedRefs, repositories, drives } = await setState(store) + return { + rpc: new RPC(announcedRefs, repositories, drives), + store + } +} diff --git a/test/state.test.js b/test/state.test.js new file mode 100644 index 0000000..9390826 --- /dev/null +++ b/test/state.test.js @@ -0,0 +1,23 @@ +const test = require('brittle') +const setState = require('../src/state.js') +const Corestore = require('corestore') +const RAM = require('random-access-memory') + +const repoNames = ['foo', 'bar', 'zar'] + +test('setState', async t => { + const res = await setState(new Corestore(RAM)) + + t.ok(res.announcedRefs) + t.alike(new Set(Object.values(res.announcedRefs)), new Set(repoNames)) + + t.ok(res.repositories) + t.alike(new Set(Object.keys(res.repositories)), new Set(repoNames)) + + t.ok(res.drives) + + for (const repo in res.repositories) { + t.ok(res.repositories[repo]) + t.ok(res.drives[repo].key) + } +}) diff --git a/test_home.tar.gz b/test_home.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..57046b3e96b72fa4e0f9e098177841f5d8fcab32 GIT binary patch literal 39108 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 H0MG*f?!Bmf literal 0 HcmV?d00001