├── LICENSE ├── README.md ├── examples └── readme-caps.js ├── lib ├── xhr-node.js └── xhr.js ├── mixins └── github-db.js └── package.json /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Tim Caswell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | js-github 2 | ========= 3 | 4 | A [js-git][] mixin that uses github as the data storage backend. 5 | 6 | This allows live mounting of github repos without cloning or pushing. 7 | 8 | It's implemented as a [js-git][] mixin that implements the storage backend 9 | using Github's [Git Data][] API using REST calls. 10 | 11 | This will work in the browser or in node.js. Technically an access token isn't 12 | required to read public repositories, but you will be rate-limited to a very 13 | small amount of requests per hour. With an auth token, you will be able to do 14 | more, and depending on the access of the token you can read private repos or 15 | write to repos. 16 | 17 | I highly reccommend using a local cache in IndexedDB or LevelDB or something 18 | available on your platform. This way you never request resources you've asked 19 | for before and can do more work without hitting the rate limit. 20 | 21 | Here is a sample config for a chrome app that uses IDB for a local cache: 22 | 23 | ```js 24 | // Start out the normal way with a plain object. 25 | var repo = {}; 26 | 27 | // This only works for normal repos. Github doesn't allow access to gists as 28 | // far as I can tell. 29 | var githubName = "creationix/js-github"; 30 | 31 | // Your user can generate these manually at https://github.com/settings/tokens/new 32 | // Or you can use an oauth flow to get a token for the user. 33 | var githubToken = "8fe7e5ad65814ea315daad99b6b65f2fd0e4c5aa"; 34 | 35 | // Mixin the main library using github to provide the following: 36 | // - repo.loadAs(type, hash) => value 37 | // - repo.saveAs(type, value) => hash 38 | // - repo.listRefs(filter='') => [ refs ] 39 | // - repo.readRef(ref) => hash 40 | // - repo.updateRef(ref, hash) => hash 41 | // - repo.deleteRef(ref) => null 42 | // - repo.createTree(entries) => hash 43 | // - repo.hasHash(hash) => has 44 | require('js-github/mixins/github-db')(repo, githubName, githubToken); 45 | 46 | // Github has this built-in, but it's currently very buggy so we replace with 47 | // the manual implementation in js-git. 48 | require('js-git/mixins/create-tree')(repo); 49 | 50 | // Cache github objects locally in indexeddb 51 | var db = require('js-git/mixins/indexed-db') 52 | require('js-git/mixins/add-cache')(repo, db); 53 | 54 | // Cache everything except blobs over 100 bytes in memory. 55 | // This makes path-to-hash lookup a sync operation in most cases. 56 | require('js-git/mixins/mem-cache')(repo); 57 | 58 | // Combine concurrent read requests for the same hash 59 | require('js-git/mixins/read-combiner')(repo); 60 | 61 | // Add in value formatting niceties. Also adds text and array types. 62 | require('js-git/mixins/formats')(repo); 63 | 64 | // Browser only: we need to initialize the indexeddb 65 | db.init(function(err) { 66 | if (err) throw err; 67 | }); 68 | ``` 69 | 70 | Note that this backend does not provide `loadRaw` or `saveRaw` and can't be used 71 | with the `pack-ops` mixin required for clone, push, and pull. The good news is 72 | you don't need those since all changes are happening on github directly. If you 73 | want to "push" a new commit, simply update the ref on the repo and it will be 74 | live. 75 | 76 | So, here is an example to load `README.md` from an existing repo, change it to 77 | all uppercase the save it back as a new commit. 78 | 79 | ```js 80 | // I'm using generator syntax, but callback style also works. 81 | // See js-git main docs for more details. 82 | var run = require('gen-run'); 83 | run(function* () { 84 | var headHash = yield repo.readRef("refs/heads/master"); 85 | var commit = yield repo.loadAs("commit", headHash); 86 | var tree = yield repo.loadAs("tree", commit.tree); 87 | var entry = tree["README.md"]; 88 | var readme = yield repo.loadAs("text", entry.hash); 89 | 90 | // Build the updates array 91 | var updates = [ 92 | { 93 | path: "README.md", // Update the existing entry 94 | mode: entry.mode, // Preserve the mode (it might have been executible) 95 | content: readme.toUpperCase() // Write the new content 96 | } 97 | ]; 98 | // Based on the existing tree, we only want to update, not replace. 99 | updates.base = commit.tree; 100 | 101 | // Create the new file and the updated tree. 102 | var treeHash = yield repo.createTree(updates); 103 | ``` 104 | 105 | At this point, the new data is live on github, but not visible as it if wasn't 106 | pushed. If we want to make the change permanent, we need to create a new commit 107 | and move the master ref to point to it. 108 | 109 | ```js 110 | var commitHash = yield repo.saveAs("commit", { 111 | tree: treeHash, 112 | author: { 113 | name: "Tim Caswell", 114 | email: "tim@creationix.com" 115 | }, 116 | parent: headHash, 117 | message: "Change README.md to be all uppercase using js-github" 118 | }); 119 | 120 | // Now we can browse to this commit by hash, but it's still not in master. 121 | // We need to update the ref to point to this new commit. 122 | 123 | yield repo.updateRef("refs/heads/master", commitHash); 124 | }); 125 | ``` 126 | 127 | I tested this on this repo. Here is the [commit](https://github.com/creationix/js-github/commit/b75c1114cdb5bc85b485b7f6d4cb830595c6cfc1) 128 | 129 | [js-git]: https://github.com/creationix/js-git.git 130 | [Git Data]: https://developer.github.com/v3/git/ 131 | -------------------------------------------------------------------------------- /examples/readme-caps.js: -------------------------------------------------------------------------------- 1 | var repo = {}; 2 | 3 | // This only works for normal repos. Github doesn't allow access to gists as 4 | // far as I can tell. 5 | var githubName = "creationix/js-github"; 6 | 7 | // Your user can generate these manually at https://github.com/settings/tokens/new 8 | // Or you can use an oauth flow to get a token for the user. 9 | var githubToken = "8fe7e5ad65814ea315daad99b6b65f2fd0e4c5aa"; 10 | 11 | // Mixin the main library using github to provide the following: 12 | // - repo.loadAs(type, hash) => value 13 | // - repo.saveAs(type, value) => hash 14 | // - repo.readRef(ref) => hash 15 | // - repo.updateRef(ref, hash) => hash 16 | // - repo.createTree(entries) => hash 17 | // - repo.hasHash(hash) => has 18 | require('../mixins/github-db')(repo, githubName, githubToken); 19 | 20 | 21 | // Github has this built-in, but it's currently very buggy so we replace with 22 | // the manual implementation in js-git. 23 | require('js-git/mixins/create-tree')(repo); 24 | 25 | // Cache everything except blobs over 100 bytes in memory. 26 | // This makes path-to-hash lookup a sync operation in most cases. 27 | require('js-git/mixins/mem-cache')(repo); 28 | 29 | // Combine concurrent read requests for the same hash 30 | require('js-git/mixins/read-combiner')(repo); 31 | 32 | // Add in value formatting niceties. Also adds text and array types. 33 | require('js-git/mixins/formats')(repo); 34 | 35 | // I'm using generator syntax, but callback style also works. 36 | // See js-git main docs for more details. 37 | var run = require('gen-run'); 38 | run(function* () { 39 | var headHash = yield repo.readRef("refs/heads/master"); 40 | var commit = yield repo.loadAs("commit", headHash); 41 | var tree = yield repo.loadAs("tree", commit.tree); 42 | var entry = tree["README.md"]; 43 | var readme = yield repo.loadAs("text", entry.hash); 44 | 45 | // Build the updates array 46 | var updates = [ 47 | { 48 | path: "README.md", // Update the existing entry 49 | mode: entry.mode, // Preserve the mode (it might have been executible) 50 | content: readme.toUpperCase() // Write the new content 51 | } 52 | ]; 53 | // Based on the existing tree, we only want to update, not replace. 54 | updates.base = commit.tree; 55 | 56 | // Create the new file and the updated tree. 57 | var treeHash = yield repo.createTree(updates); 58 | 59 | var commitHash = yield repo.saveAs("commit", { 60 | tree: treeHash, 61 | author: { 62 | name: "Tim Caswell", 63 | email: "tim@creationix.com" 64 | }, 65 | parent: headHash, 66 | message: "Change README.md to be all uppercase using js-github" 67 | }); 68 | 69 | // Now we can browse to this commit by hash, but it's still not in master. 70 | // We need to update the ref to point to this new commit. 71 | console.log("COMMIT", commitHash) 72 | 73 | // Save it to a new branch (Or update existing one) 74 | var new_hash = yield repo.updateRef("refs/heads/new-branch", commitHash); 75 | 76 | // And delete this new branch: 77 | yield repo.deleteRef("refs/heads/new-branch"); 78 | }); 79 | -------------------------------------------------------------------------------- /lib/xhr-node.js: -------------------------------------------------------------------------------- 1 | var https = require('https'); 2 | var statusCodes = require('http').STATUS_CODES; 3 | var urlParse = require('url').parse; 4 | var path = require('path'); 5 | 6 | module.exports = function (root, accessToken, githubHostname) { 7 | var cache = {}; 8 | githubHostname = (githubHostname || "https://api.github.com/"); 9 | return function request(method, url, body, callback) { 10 | if (typeof body === "function" && callback === undefined) { 11 | callback = body; 12 | body = undefined; 13 | } 14 | if (!callback) return request.bind(this, accessToken, method, url, body); 15 | url = url.replace(":root", root); 16 | 17 | var json; 18 | var headers = { 19 | "User-Agent": "node.js" 20 | }; 21 | if (accessToken) { 22 | headers["Authorization"] = "token " + accessToken; 23 | } 24 | if (body) { 25 | headers["Content-Type"] = "application/json"; 26 | try { json = new Buffer(JSON.stringify(body)); } 27 | catch (err) { return callback(err); } 28 | headers["Content-Length"] = json.length; 29 | } 30 | if (method === "GET") { 31 | var cached = cache[url]; 32 | if (cached) { 33 | headers["If-None-Match"] = cached.etag; 34 | } 35 | } 36 | 37 | var urlparts = urlParse(githubHostname); 38 | var options = { 39 | hostname: urlparts.hostname, 40 | path: path.join(urlparts.pathname, url).replace(/\\/g, '/'), 41 | method: method, 42 | headers: headers 43 | }; 44 | 45 | var req = https.request(options, function (res) { 46 | var body = []; 47 | res.on("data", function (chunk) { 48 | body.push(chunk); 49 | }); 50 | res.on("end", function () { 51 | body = Buffer.concat(body).toString(); 52 | console.log(method, url, res.statusCode); 53 | console.log("Rate limit %s/%s left", res.headers['x-ratelimit-remaining'], res.headers['x-ratelimit-limit']); 54 | //console.log(body); 55 | if (res.statusCode === 200 && method === "GET" && /\/refs\//.test(url)) { 56 | cache[url] = { 57 | body: body, 58 | etag: res.headers.etag 59 | }; 60 | } 61 | else if (res.statusCode === 304) { 62 | body = cache[url].body; 63 | res.statusCode = 200; 64 | } 65 | // Fake parts of the xhr object using node APIs 66 | var xhr = { 67 | status: res.statusCode, 68 | statusText: res.statusCode + " " + statusCodes[res.statusCode] 69 | }; 70 | var response = {message:body}; 71 | if (body){ 72 | try { response = JSON.parse(body); } 73 | catch (err) {} 74 | } 75 | return callback(null, xhr, response); 76 | }); 77 | }); 78 | req.end(json); 79 | req.on("error", callback); 80 | }; 81 | }; 82 | -------------------------------------------------------------------------------- /lib/xhr.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var isNode = typeof process === 'object' && 4 | typeof process.versions === 'object' && 5 | process.versions.node && 6 | process.__atom_type !== "renderer"; 7 | 8 | // Node.js https module 9 | if (isNode) { 10 | var nodeRequire = require; // Prevent mine.js from seeing this require 11 | module.exports = nodeRequire('./xhr-node.js'); 12 | } 13 | 14 | // Browser XHR 15 | else { 16 | module.exports = function (root, accessToken, githubHostname) { 17 | var timeout = 2000; 18 | githubHostname = (githubHostname || 'https://api.github.com'); 19 | return request; 20 | 21 | function request(method, url, body, callback) { 22 | if (typeof body === "function") { 23 | callback = body; 24 | body = undefined; 25 | } 26 | else if (!callback) return request.bind(null, method, url, body); 27 | url = url.replace(":root", root); 28 | var done = false; 29 | var json; 30 | var xhr = new XMLHttpRequest(); 31 | xhr.timeout = timeout; 32 | xhr.open(method, githubHostname + url, true); 33 | if (accessToken) { 34 | xhr.setRequestHeader("Authorization", "token " + accessToken); 35 | } 36 | if (body) { 37 | try { json = JSON.stringify(body); } 38 | catch (err) { return callback(err); } 39 | } 40 | xhr.ontimeout = onTimeout; 41 | xhr.onerror = function() { 42 | callback(new Error("Error requesting " + url)); 43 | }; 44 | xhr.onreadystatechange = onReadyStateChange; 45 | xhr.send(json); 46 | 47 | function onReadyStateChange() { 48 | if (done) return; 49 | if (xhr.readyState !== 4) return; 50 | // Give onTimeout a chance to run first if that's the reason status is 0. 51 | if (!xhr.status) return setTimeout(onReadyStateChange, 0); 52 | done = true; 53 | var response = {message:xhr.responseText}; 54 | if (xhr.responseText){ 55 | try { response = JSON.parse(xhr.responseText); } 56 | catch (err) {} 57 | } 58 | xhr.body = response; 59 | return callback(null, xhr, response); 60 | } 61 | 62 | function onTimeout() { 63 | if (done) return; 64 | if (timeout < 8000) { 65 | timeout *= 2; 66 | return request(method, url, body, callback); 67 | } 68 | done = true; 69 | callback(new Error("Timeout requesting " + url)); 70 | } 71 | } 72 | }; 73 | } 74 | -------------------------------------------------------------------------------- /mixins/github-db.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var modes = require('js-git/lib/modes'); 4 | var xhr = require('../lib/xhr'); 5 | var bodec = require('bodec'); 6 | var sha1 = require('git-sha1'); 7 | var frame = require('js-git/lib/object-codec').frame; 8 | 9 | var modeToType = { 10 | "040000": "tree", 11 | "100644": "blob", // normal file 12 | "100755": "blob", // executable file 13 | "120000": "blob", // symlink 14 | "160000": "commit" // gitlink 15 | }; 16 | 17 | var encoders = { 18 | commit: encodeCommit, 19 | tag: encodeTag, 20 | tree: encodeTree, 21 | blob: encodeBlob 22 | }; 23 | 24 | var decoders = { 25 | commit: decodeCommit, 26 | tag: decodeTag, 27 | tree: decodeTree, 28 | blob: decodeBlob, 29 | }; 30 | 31 | var typeCache = {}; 32 | 33 | // Precompute hashes for empty blob and empty tree since github won't 34 | var empty = bodec.create(0); 35 | var emptyBlob = sha1(frame({ type: "blob", body: empty })); 36 | var emptyTree = sha1(frame({ type: "tree", body: empty })); 37 | 38 | // Implement the js-git object interface using github APIs 39 | module.exports = function (repo, root, accessToken, githubHostname) { 40 | 41 | var apiRequest = xhr(root, accessToken, githubHostname); 42 | 43 | repo.loadAs = loadAs; // (type, hash) -> value, hash 44 | repo.saveAs = saveAs; // (type, value) -> hash, value 45 | repo.listRefs = listRefs; // (filter='') -> [ refs ] 46 | repo.readRef = readRef; // (ref) -> hash 47 | repo.updateRef = updateRef; // (ref, hash) -> hash 48 | repo.deleteRef = deleteRef // (ref) -> null 49 | repo.createTree = createTree; // (entries) -> hash, tree 50 | repo.hasHash = hasHash; 51 | 52 | function loadAs(type, hash, callback) { 53 | if (!callback) return loadAs.bind(repo, type, hash); 54 | // Github doesn't like empty trees, but we know them already. 55 | if (type === "tree" && hash === emptyTree) return callback(null, {}, hash); 56 | apiRequest("GET", "/repos/:root/git/" + type + "s/" + hash, onValue); 57 | 58 | function onValue(err, xhr, result) { 59 | if (err) return callback(err); 60 | if (xhr.status < 200 || xhr.status >= 500) { 61 | return callback(new Error("Invalid HTTP response: " + xhr.statusCode + " " + result.message)); 62 | } 63 | if (xhr.status >= 300 && xhr.status < 500) return callback(); 64 | var body; 65 | try { body = decoders[type].call(repo, result); } 66 | catch (err) { return callback(err); } 67 | if (hashAs(type, body) !== hash) { 68 | if (fixDate(type, body, hash)) console.log(type + " repaired", hash); 69 | else console.warn("Unable to repair " + type, hash); 70 | } 71 | typeCache[hash] = type; 72 | return callback(null, body, hash); 73 | } 74 | } 75 | 76 | function hasHash(hash, callback) { 77 | if (!callback) return hasHash.bind(repo, hash); 78 | var type = typeCache[hash]; 79 | var types = type ? [type] : ["tag", "commit", "tree", "blob"]; 80 | start(); 81 | function start() { 82 | type = types.pop(); 83 | if (!type) return callback(null, false); 84 | apiRequest("GET", "/repos/:root/git/" + type + "s/" + hash, onValue); 85 | } 86 | 87 | function onValue(err, xhr, result) { 88 | if (err) return callback(err); 89 | if (xhr.status < 200 || xhr.status >= 500) { 90 | return callback(new Error("Invalid HTTP response: " + xhr.statusCode + " " + result.message)); 91 | } 92 | if (xhr.status >= 300 && xhr.status < 500) return start(); 93 | typeCache[hash] = type; 94 | callback(null, true); 95 | } 96 | } 97 | 98 | function saveAs(type, body, callback) { 99 | if (!callback) return saveAs.bind(repo, type, body); 100 | var hash; 101 | try { 102 | hash = hashAs(type, body); 103 | } 104 | catch (err) { 105 | return callback(err); 106 | } 107 | typeCache[hash] = type; 108 | repo.hasHash(hash, function (err, has) { 109 | if (err) return callback(err); 110 | if (has) return callback(null, hash, body); 111 | 112 | var request; 113 | try { 114 | request = encoders[type](body); 115 | } 116 | catch (err) { 117 | return callback(err); 118 | } 119 | 120 | // Github doesn't allow creating empty trees. 121 | if (type === "tree" && request.tree.length === 0) { 122 | return callback(null, emptyTree, body); 123 | } 124 | return apiRequest("POST", "/repos/:root/git/" + type + "s", request, onWrite); 125 | 126 | }); 127 | 128 | function onWrite(err, xhr, result) { 129 | if (err) return callback(err); 130 | if (xhr.status < 200 || xhr.status >= 300) { 131 | return callback(new Error("Invalid HTTP response: " + xhr.status + " " + result.message)); 132 | } 133 | return callback(null, result.sha, body); 134 | } 135 | } 136 | 137 | // Create a tree with optional deep paths and create new blobs. 138 | // Entries is an array of {mode, path, hash|content} 139 | // Also deltas can be specified by setting entries.base to the hash of a tree 140 | // in delta mode, entries can be removed by specifying just {path} 141 | function createTree(entries, callback) { 142 | if (!callback) return createTree.bind(repo, entries); 143 | var toDelete = entries.base && entries.filter(function (entry) { 144 | return !entry.mode; 145 | }).map(function (entry) { 146 | return entry.path; 147 | }); 148 | var toCreate = entries.filter(function (entry) { 149 | return bodec.isBinary(entry.content); 150 | }); 151 | 152 | if (!toCreate.length) return next(); 153 | var done = false; 154 | var left = entries.length; 155 | toCreate.forEach(function (entry) { 156 | repo.saveAs("blob", entry.content, function (err, hash) { 157 | if (done) return; 158 | if (err) { 159 | done = true; 160 | return callback(err); 161 | } 162 | delete entry.content; 163 | entry.hash = hash; 164 | left--; 165 | if (!left) next(); 166 | }); 167 | }); 168 | 169 | function next(err) { 170 | if (err) return callback(err); 171 | if (toDelete && toDelete.length) { 172 | return slowUpdateTree(entries, toDelete, callback); 173 | } 174 | return fastUpdateTree(entries, callback); 175 | } 176 | } 177 | 178 | function fastUpdateTree(entries, callback) { 179 | var request = { tree: entries.map(mapTreeEntry) }; 180 | if (entries.base) request.base_tree = entries.base; 181 | 182 | apiRequest("POST", "/repos/:root/git/trees", request, onWrite); 183 | 184 | function onWrite(err, xhr, result) { 185 | if (err) return callback(err); 186 | if (xhr.status < 200 || xhr.status >= 300) { 187 | return callback(new Error("Invalid HTTP response: " + xhr.status + " " + result.message)); 188 | } 189 | return callback(null, result.sha, decoders.tree(result)); 190 | } 191 | } 192 | 193 | // Github doesn't support deleting entries via the createTree API, so we 194 | // need to manually create those affected trees and modify the request. 195 | function slowUpdateTree(entries, toDelete, callback) { 196 | callback = singleCall(callback); 197 | var root = entries.base; 198 | 199 | var left = 0; 200 | 201 | // Calculate trees that need to be re-built and save any provided content. 202 | var parents = {}; 203 | toDelete.forEach(function (path) { 204 | var parentPath = path.substr(0, path.lastIndexOf("/")); 205 | var parent = parents[parentPath] || (parents[parentPath] = { 206 | add: {}, del: [] 207 | }); 208 | var name = path.substr(path.lastIndexOf("/") + 1); 209 | parent.del.push(name); 210 | }); 211 | var other = entries.filter(function (entry) { 212 | if (!entry.mode) return false; 213 | var parentPath = entry.path.substr(0, entry.path.lastIndexOf("/")); 214 | var parent = parents[parentPath]; 215 | if (!parent) return true; 216 | var name = entry.path.substr(entry.path.lastIndexOf("/") + 1); 217 | if (entry.hash) { 218 | parent.add[name] = { 219 | mode: entry.mode, 220 | hash: entry.hash 221 | }; 222 | return false; 223 | } 224 | left++; 225 | repo.saveAs("blob", entry.content, function(err, hash) { 226 | if (err) return callback(err); 227 | parent.add[name] = { 228 | mode: entry.mode, 229 | hash: hash 230 | }; 231 | if (!--left) onParents(); 232 | }); 233 | return false; 234 | }); 235 | if (!left) onParents(); 236 | 237 | function onParents() { 238 | Object.keys(parents).forEach(function (parentPath) { 239 | left++; 240 | // TODO: remove this dependency on pathToEntry 241 | repo.pathToEntry(root, parentPath, function (err, entry) { 242 | if (err) return callback(err); 243 | var tree = entry.tree; 244 | var commands = parents[parentPath]; 245 | commands.del.forEach(function (name) { 246 | delete tree[name]; 247 | }); 248 | for (var name in commands.add) { 249 | tree[name] = commands.add[name]; 250 | } 251 | repo.saveAs("tree", tree, function (err, hash, tree) { 252 | if (err) return callback(err); 253 | other.push({ 254 | path: parentPath, 255 | hash: hash, 256 | mode: modes.tree 257 | }); 258 | if (!--left) { 259 | other.base = entries.base; 260 | if (other.length === 1 && other[0].path === "") { 261 | return callback(null, hash, tree); 262 | } 263 | fastUpdateTree(other, callback); 264 | } 265 | }); 266 | }); 267 | }); 268 | } 269 | } 270 | 271 | 272 | function readRef(ref, callback) { 273 | if (!callback) return readRef.bind(repo, ref); 274 | if (ref === "HEAD") ref = "refs/heads/master"; 275 | if (!(/^refs\//).test(ref)) { 276 | return callback(new TypeError("Invalid ref: " + ref)); 277 | } 278 | return apiRequest("GET", "/repos/:root/git/" + ref, onRef); 279 | 280 | function onRef(err, xhr, result) { 281 | if (err) return callback(err); 282 | if (xhr.status === 404) return callback(); 283 | if (xhr.status < 200 || xhr.status >= 300) { 284 | return callback(new Error("Invalid HTTP response: " + xhr.status + " " + result.message)); 285 | } 286 | return callback(null, result.object.sha); 287 | } 288 | } 289 | 290 | function deleteRef(ref, callback) { 291 | if (!callback) return deleteRef.bind(repo, ref); 292 | if (ref === "HEAD") ref = "refs/heads/master"; 293 | if (!(/^refs\//).test(ref)) { 294 | return callback(new TypeError("Invalid ref: " + ref)); 295 | } 296 | return apiRequest("DELETE", "/repos/:root/git/" + ref, onRef); 297 | 298 | function onRef(err, xhr, result) { 299 | if (err) return callback(err); 300 | if (xhr.status === 404) return callback(); 301 | if (xhr.status < 200 || xhr.status >= 300) { 302 | return callback(new Error("Invalid HTTP response: " + xhr.status + " " + result.message)); 303 | } 304 | return callback(null, null); 305 | } 306 | } 307 | 308 | function listRefs(filter, callback) { 309 | if (!callback) return listRefs.bind(repo, filter); 310 | filter = filter ? '/' + filter : ''; 311 | return apiRequest("GET", "/repos/:root/git/refs" + filter, onResult); 312 | 313 | function onResult(err, xhr, result) { 314 | if (err) return callback(err); 315 | if (xhr.status === 404) return callback(); 316 | if (xhr.status < 200 || xhr.status >= 300) { 317 | return callback(new Error("Invalid HTTP response: " + xhr.status + " " + result.message)); 318 | } 319 | 320 | callback(null, result.map(function(entry) { return entry.ref })); 321 | } 322 | } 323 | 324 | function updateRef(ref, hash, callback, force) { 325 | if (!callback) return updateRef.bind(repo, ref, hash); 326 | if (ref === "HEAD") ref = "refs/heads/master"; 327 | if (!(/^refs\//).test(ref)) { 328 | return callback(new Error("Invalid ref: " + ref)); 329 | } 330 | return apiRequest("PATCH", "/repos/:root/git/" + ref, { 331 | sha: hash, 332 | force: !!force 333 | }, onResult); 334 | 335 | function onResult(err, xhr, result) { 336 | if (err) return callback(err); 337 | if (xhr.status === 422 && result.message === "Reference does not exist") { 338 | return apiRequest("POST", "/repos/:root/git/refs", { 339 | ref: ref, 340 | sha: hash 341 | }, onResult); 342 | } 343 | if (xhr.status < 200 || xhr.status >= 300) { 344 | return callback(new Error("Invalid HTTP response: " + xhr.status + " " + result.message)); 345 | } 346 | if (err) return callback(err); 347 | callback(null, hash); 348 | } 349 | 350 | } 351 | 352 | }; 353 | 354 | // GitHub has a nasty habit of stripping whitespace from messages and losing 355 | // the timezone. This information is required to make our hashes match up, so 356 | // we guess it by mutating the value till the hash matches. 357 | // If we're unable to match, we will just force the hash when saving to the cache. 358 | function fixDate(type, value, hash) { 359 | if (type !== "commit" && type !== "tag") return; 360 | // Add up to 3 extra newlines and try all 30-minutes timezone offsets. 361 | var clone = JSON.parse(JSON.stringify(value)); 362 | for (var x = 0; x < 3; x++) { 363 | for (var i = -720; i < 720; i += 30) { 364 | if (type === "commit") { 365 | clone.author.date.offset = i; 366 | clone.committer.date.offset = i; 367 | } 368 | else if (type === "tag") { 369 | clone.tagger.date.offset = i; 370 | } 371 | if (hash !== hashAs(type, clone)) continue; 372 | // Apply the changes and return. 373 | value.message = clone.message; 374 | if (type === "commit") { 375 | value.author.date.offset = clone.author.date.offset; 376 | value.committer.date.offset = clone.committer.date.offset; 377 | } 378 | else if (type === "tag") { 379 | value.tagger.date.offset = clone.tagger.date.offset; 380 | } 381 | return true; 382 | } 383 | clone.message += "\n"; 384 | } 385 | return false; 386 | } 387 | 388 | function mapTreeEntry(entry) { 389 | if (!entry.mode) throw new TypeError("Invalid entry"); 390 | var mode = modeToString(entry.mode); 391 | var item = { 392 | path: entry.path, 393 | mode: mode, 394 | type: modeToType[mode] 395 | }; 396 | // Magic hash for empty file since github rejects empty contents. 397 | if (entry.content === "") entry.hash = emptyBlob; 398 | 399 | if (entry.hash) item.sha = entry.hash; 400 | else item.content = entry.content; 401 | return item; 402 | } 403 | 404 | function encodeCommit(commit) { 405 | var out = {}; 406 | out.message = commit.message; 407 | out.tree = commit.tree; 408 | if (commit.parents) out.parents = commit.parents; 409 | else if (commit.parent) out.parents = [commit.parent]; 410 | else commit.parents = []; 411 | if (commit.author) out.author = encodePerson(commit.author); 412 | if (commit.committer) out.committer = encodePerson(commit.committer); 413 | return out; 414 | } 415 | 416 | function encodeTag(tag) { 417 | return { 418 | tag: tag.tag, 419 | message: tag.message, 420 | object: tag.object, 421 | tagger: encodePerson(tag.tagger) 422 | }; 423 | } 424 | 425 | function encodePerson(person) { 426 | return { 427 | name: person.name, 428 | email: person.email, 429 | date: encodeDate(person.date) 430 | }; 431 | } 432 | 433 | function encodeTree(tree) { 434 | return { 435 | tree: Object.keys(tree).map(function (name) { 436 | var entry = tree[name]; 437 | var mode = modeToString(entry.mode); 438 | return { 439 | path: name, 440 | mode: mode, 441 | type: modeToType[mode], 442 | sha: entry.hash 443 | }; 444 | }) 445 | }; 446 | } 447 | 448 | function encodeBlob(blob) { 449 | if (typeof blob === "string") return { 450 | content: bodec.encodeUtf8(blob), 451 | encoding: "utf-8" 452 | }; 453 | if (bodec.isBinary(blob)) return { 454 | content: bodec.toBase64(blob), 455 | encoding: "base64" 456 | }; 457 | throw new TypeError("Invalid blob type, must be binary or string"); 458 | } 459 | 460 | function modeToString(mode) { 461 | var string = mode.toString(8); 462 | // Github likes all modes to be 6 chars long 463 | if (string.length === 5) string = "0" + string; 464 | return string; 465 | } 466 | 467 | function decodeCommit(result) { 468 | return { 469 | tree: result.tree.sha, 470 | parents: result.parents.map(function (object) { 471 | return object.sha; 472 | }), 473 | author: pickPerson(result.author), 474 | committer: pickPerson(result.committer), 475 | message: result.message 476 | }; 477 | } 478 | 479 | function decodeTag(result) { 480 | return { 481 | object: result.object.sha, 482 | type: result.object.type, 483 | tag: result.tag, 484 | tagger: pickPerson(result.tagger), 485 | message: result.message 486 | }; 487 | } 488 | 489 | function decodeTree(result) { 490 | var tree = {}; 491 | result.tree.forEach(function (entry) { 492 | tree[entry.path] = { 493 | mode: parseInt(entry.mode, 8), 494 | hash: entry.sha 495 | }; 496 | }); 497 | return tree; 498 | } 499 | 500 | function decodeBlob(result) { 501 | if (result.encoding === 'base64') { 502 | return bodec.fromBase64(result.content.replace(/\n/g, '')); 503 | } 504 | if (result.encoding === 'utf-8') { 505 | return bodec.fromUtf8(result.content); 506 | } 507 | throw new Error("Unknown blob encoding: " + result.encoding); 508 | } 509 | 510 | function pickPerson(person) { 511 | return { 512 | name: person.name, 513 | email: person.email, 514 | date: parseDate(person.date) 515 | }; 516 | } 517 | 518 | function parseDate(string) { 519 | // TODO: test this once GitHub adds timezone information 520 | var match = string.match(/(-?)([0-9]{2}):([0-9]{2})$/); 521 | var date = new Date(string); 522 | var timezoneOffset = 0; 523 | if (match) { 524 | timezoneOffset = (match[1] === "-" ? 1 : -1) * ( 525 | parseInt(match[2], 10) * 60 + parseInt(match[3], 10) 526 | ); 527 | } 528 | return { 529 | seconds: date.valueOf() / 1000, 530 | offset: timezoneOffset 531 | }; 532 | } 533 | 534 | function encodeDate(date) { 535 | var seconds = date.seconds - (date.offset) * 60; 536 | var d = new Date(seconds * 1000); 537 | var string = d.toISOString(); 538 | var hours = Math.abs(date.offset / 60) | 0; 539 | var minutes = Math.abs(date.offset % 60); 540 | string = string.substring(0, string.lastIndexOf(".")) + 541 | (date.offset > 0 ? "-" : "+") + 542 | twoDigit(hours) + ":" + twoDigit(minutes); 543 | return string; 544 | } 545 | 546 | // Run some quick unit tests to make sure date encoding works. 547 | [ 548 | { offset: -500, seconds: 1401938626 }, 549 | { offset: 300, seconds: 1401938626 }, 550 | { offset: 400, seconds: 1401938626 } 551 | ].forEach(function (date) { 552 | var verify = parseDate(encodeDate(date)); 553 | if (verify.seconds !== date.seconds || verify.offset !== date.offset) { 554 | throw new Error("Verification failure testing date encoding"); 555 | } 556 | }); 557 | 558 | function twoDigit(num) { 559 | if (num < 10) return "0" + num; 560 | return "" + num; 561 | } 562 | 563 | function singleCall(callback) { 564 | var done = false; 565 | return function () { 566 | if (done) return console.warn("Discarding extra callback"); 567 | done = true; 568 | return callback.apply(this, arguments); 569 | }; 570 | } 571 | 572 | function hashAs(type, body) { 573 | var buffer = frame({type: type, body: body}); 574 | return sha1(buffer); 575 | } 576 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "js-github", 3 | "version": "1.1.0", 4 | "description": "An implementation of the js-git interface that mounts a live github repo.", 5 | "main": "src/repo.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "git://github.com/creationix/js-github.git" 9 | }, 10 | "keywords": [ 11 | "js-git", 12 | "github" 13 | ], 14 | "author": "Tim Caswell ", 15 | "license": "MIT", 16 | "bugs": { 17 | "url": "https://github.com/creationix/js-github/issues" 18 | }, 19 | "dependencies": { 20 | "bodec": "^0.1.0", 21 | "git-sha1": "^0.1.2", 22 | "js-git": "^0.7.7" 23 | } 24 | } 25 | --------------------------------------------------------------------------------