From 4ba8a32b45d395f28433572db5644d630776789e Mon Sep 17 00:00:00 2001 From: Sam Atkins Date: Thu, 20 Jun 2024 20:54:59 +0100 Subject: [PATCH] feat(git): Implement `git restore` I tried implementing --source=foo too, but was bumping into weird behaviour with isomorphic-git, so I've removed that for now. Eventually we'll want it, but it seems fairly niche. --- packages/git/src/subcommands/__exports__.js | 2 + packages/git/src/subcommands/restore.js | 157 ++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 packages/git/src/subcommands/restore.js diff --git a/packages/git/src/subcommands/__exports__.js b/packages/git/src/subcommands/__exports__.js index 067e00b25..c40020ec6 100644 --- a/packages/git/src/subcommands/__exports__.js +++ b/packages/git/src/subcommands/__exports__.js @@ -31,6 +31,7 @@ import module_log from './log.js' import module_pull from './pull.js' import module_push from './push.js' import module_remote from './remote.js' +import module_restore from './restore.js' import module_show from './show.js' import module_status from './status.js' import module_version from './version.js' @@ -50,6 +51,7 @@ export default { "pull": module_pull, "push": module_push, "remote": module_remote, + "restore": module_restore, "show": module_show, "status": module_status, "version": module_version, diff --git a/packages/git/src/subcommands/restore.js b/packages/git/src/subcommands/restore.js new file mode 100644 index 000000000..686488c39 --- /dev/null +++ b/packages/git/src/subcommands/restore.js @@ -0,0 +1,157 @@ +/* + * Copyright (C) 2024 Puter Technologies Inc. + * + * This file is part of Puter's Git client. + * + * Puter's Git client is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import git, { STAGE, TREE, WORKDIR } from 'isomorphic-git'; +import { find_repo_root } from '../git-helpers.js'; +import path from 'path-browserify'; + +export default { + name: 'restore', + usage: 'git restore [--staged] [--worktree] [--] [...]', + description: 'Add file contents to the index.', + args: { + allowPositionals: true, + options: { + 'staged': { + description: 'Restore the file in the index.', + type: 'boolean', + short: 'S', + }, + 'worktree': { + description: 'Restore the file in the working tree.', + type: 'boolean', + short: 'W', + }, + 'overlay': { + description: 'Enable overlay mode. In overlay mode, files that do not exist in the source are not deleted.', + type: 'boolean', + value: false, + }, + 'no-overlay': { + description: 'Disable overlay mode. Any files not in the source will be deleted.', + type: 'boolean', + }, + }, + }, + execute: async (ctx) => { + const { io, fs, env, args } = ctx; + const { stdout, stderr } = io; + const { options, positionals } = args; + const cache = {}; + + if (!options.staged && !options.worktree) + options.worktree = true; + + if (options['no-overlay']) + options.overlay = false; + + const FROM_INDEX = Symbol('FROM_INDEX'); + const source_ref = options.staged ? 'HEAD' : FROM_INDEX; + + const pathspecs = positionals.map(it => path.resolve(env.PWD, it)); + if (pathspecs.length === 0) + throw new Error(`you must specify path(s) to restore`); + + const { dir, gitdir } = await find_repo_root(fs, env.PWD); + + const operations = await git.walk({ + fs, dir, gitdir, cache, + trees: [ + source_ref === FROM_INDEX ? STAGE() : TREE({ ref: source_ref }), + TREE({ ref: 'HEAD' }), // Only required to check if a file is tracked. + STAGE(), + WORKDIR(), + ], + map: async (filepath, [ source, head, staged, workdir]) => { + // Reject paths that don't match pathspecs. + const abs_filepath = path.resolve(env.PWD, filepath); + if (!pathspecs.some(abs_path => + (filepath === '.') || (abs_filepath.startsWith(abs_path)) || (path.dirname(abs_filepath) === abs_path), + )) { + return null; + } + + if (await git.isIgnored({ fs, dir, gitdir, filepath })) + return null; + + const [ + source_type, staged_type, workdir_type + ] = await Promise.all([ + source?.type(), staged?.type(), workdir?.type() + ]); + + // Exclude directories from results, but still iterate them. + if ((!source_type || source_type === 'tree') + && (!staged_type || staged_type === 'tree') + && (!workdir_type || workdir_type === 'tree')) { + return; + } + + // We need to modify the index or working tree if their oid doesn't match the source's. + const [ + source_oid, staged_oid, workdir_oid + ] = await Promise.all([ + source_type === 'blob' ? source.oid() : undefined, + staged_type === 'blob' ? staged.oid() : undefined, + workdir_type === 'blob' ? workdir.oid() : undefined, + ]); + const something_changed = (options.staged && staged_oid !== source_oid) || (options.worktree && workdir_oid !== source_oid); + if (!something_changed) + return null; + + return Promise.all([ + // Update the index + (async () => { + if (!options.staged || staged_oid === source_oid) + return; + + await git.resetIndex({ + fs, dir, gitdir, cache, + filepath, + ref: source_ref, + }); + })(), + // Update the working tree + (async () => { + if (!options.worktree || workdir_oid === source_oid) + return; + + // If the file isn't in source, it needs to be deleted if it is tracked by git. + // For now, I'll consider a file tracked if it exists in HEAD. This may not be correct though. + // TODO: Add an isTracked(file) method to isomorphic-git + if (!source && !head) + return null; + + if (source_oid) { + // Write the file + // Unfortunately, reading the source's file data is done differently depending on if it's the index or not. + const source_content = source_ref === FROM_INDEX + ? (await git.readBlob({ fs, dir, gitdir, cache, oid: source_oid })).blob + : await source.content(); + await fs.promises.writeFile(abs_filepath, source_content); + } else if (!options.overlay) { + // Delete the file + await fs.promises.unlink(abs_filepath); + } + })(), + ]); + }, + }); + await Promise.all(operations); + } +}