From 2e4259d267b3cfafd5cefc57a02643c6432fec4d Mon Sep 17 00:00:00 2001 From: Sam Atkins Date: Wed, 26 Jun 2024 12:06:03 +0100 Subject: [PATCH] feat(git): Implement `git cherry-pick` This is quite manual, and only handles the simple cases where no merge conflicts occur. Should be useful for basing a `rebase` command off of though. --- packages/git/src/git-helpers.js | 13 ++ packages/git/src/subcommands/__exports__.js | 2 + packages/git/src/subcommands/cherry-pick.js | 151 ++++++++++++++++++++ 3 files changed, 166 insertions(+) create mode 100644 packages/git/src/subcommands/cherry-pick.js diff --git a/packages/git/src/git-helpers.js b/packages/git/src/git-helpers.js index 28ac74a41..f5ef50a2f 100644 --- a/packages/git/src/git-helpers.js +++ b/packages/git/src/git-helpers.js @@ -178,3 +178,16 @@ export const resolve_to_commit = async (git_context, ref) => { throw new Error(`bad revision '${ref}'`); } } + +/** + * Determine if the index has any staged changes. + * @param git_context {{ fs, dir, gitdir, cache }} as taken by most isomorphic-git methods. + * @returns {Promise} + */ +export const has_staged_changes = async (git_context) => { + const file_status = await git.statusMatrix({ + ...git_context, + ignored: false, + }); + return file_status.some(([filepath, head, workdir, index]) => index !== head); +} diff --git a/packages/git/src/subcommands/__exports__.js b/packages/git/src/subcommands/__exports__.js index c40020ec6..9bcfd7dce 100644 --- a/packages/git/src/subcommands/__exports__.js +++ b/packages/git/src/subcommands/__exports__.js @@ -20,6 +20,7 @@ import module_add from './add.js' import module_branch from './branch.js' import module_checkout from './checkout.js' +import module_cherry_pick from './cherry-pick.js' import module_clone from './clone.js' import module_commit from './commit.js' import module_config from './config.js' @@ -40,6 +41,7 @@ export default { "add": module_add, "branch": module_branch, "checkout": module_checkout, + "cherry-pick": module_cherry_pick, "clone": module_clone, "commit": module_commit, "config": module_config, diff --git a/packages/git/src/subcommands/cherry-pick.js b/packages/git/src/subcommands/cherry-pick.js new file mode 100644 index 000000000..7766b2902 --- /dev/null +++ b/packages/git/src/subcommands/cherry-pick.js @@ -0,0 +1,151 @@ +/* + * 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, { TREE } from 'isomorphic-git'; +import { find_repo_root, has_staged_changes, resolve_to_commit, shorten_hash } from '../git-helpers.js'; +import { SHOW_USAGE } from '../help.js'; +import chalk from 'chalk'; +import { diff_git_trees } from '../diff.js'; +import * as Diff from 'diff'; +import path from 'path-browserify'; + +// TODO: cherry-pick is a multi-stage process. Any issue that occurs should pause it, print a message, +// and return to the prompt, letting the user decide how to proceed. +export default { + name: 'cherry-pick', + usage: 'git cherry-pick ...', + description: 'Apply changes from existing commits.', + args: { + allowPositionals: true, + options: { + }, + }, + execute: async (ctx) => { + const { io, fs, env, args } = ctx; + const { stdout, stderr } = io; + const { options, positionals } = args; + const cache = {}; + + if (positionals.length < 1) { + stderr('error: Must specify commits to cherry-pick.'); + throw SHOW_USAGE; + } + + const { dir, gitdir } = await find_repo_root(fs, env.PWD); + + // Ensure nothing is staged, as it would be overwritten + if (await has_staged_changes({ fs, dir, gitdir, cache })) { + stderr('error: your local changes would be overwritten by cherry-pick.'); + stderr(chalk.yellow('hint: commit your changes or stash them to proceed.')); + stderr('fatal: cherry-pick failed'); + return 1; + } + + const branch = await git.currentBranch({ fs, dir, gitdir }); + + const commits = await Promise.all(positionals.map(commit_ref => resolve_to_commit({ fs, dir, gitdir, cache }, commit_ref))); + let head_oid = await git.resolveRef({ fs, dir, gitdir, ref: 'HEAD' }); + const original_head_oid = head_oid; + + const read_tree = walker => walker?.content()?.then(it => new TextDecoder().decode(it)); + + for (const commit_data of commits) { + const commit = commit_data.commit; + const commit_title = commit.message.split('\n')[0]; + + // We can't just add the old commit directly: + // - Its parent is wrong + // - Its tree is a snapshot of the files then. We intead need a new snapshot applying its changes + // to the current HEAD. + // So, we instead stage its changes one at a time, then commit() as if this was a new commit. + + const diffs = await diff_git_trees({ + fs, dir, gitdir, cache, env, + a_tree: TREE({ ref: commit.parent[0] }), + b_tree: TREE({ ref: commit_data.oid }), + read_a: read_tree, + read_b: read_tree, + }); + for (const { a, b, diff } of diffs) { + // If the file was deleted, just remove it. + if (diff.newFileName === '/dev/null') { + await git.remove({ + fs, dir, gitdir, cache, + filepath: diff.oldFileName, + }); + continue; + } + + // If the file was created, just add it. + if (diff.oldFileName === '/dev/null') { + await git.updateIndex({ + fs, dir, gitdir, cache, + filepath: diff.newFileName, + add: true, + oid: b.oid, + }); + continue; + } + + // Otherwise, the file was modified. Calculate and then apply the patch. + const existing_file_contents = await fs.promises.readFile(path.resolve(env.PWD, diff.newFileName), { encoding: 'utf8' }); + const new_file_contents = Diff.applyPatch(existing_file_contents, diff); + if (!new_file_contents) { + // TODO: We should insert merge conflict markers and wait for the user resolve the conflict. + throw new Error(`Merge conflict: Unable to apply commit ${shorten_hash(commit_data.oid)} ${commit_title}`); + } + // Now, stage the new file contents + const file_oid = await git.writeBlob({ + fs, dir, gitdir, + blob: new TextEncoder().encode(new_file_contents), + }); + await git.updateIndex({ + fs, dir, gitdir, cache, + filepath: diff.newFileName, + oid: file_oid, + add: true, + }); + } + + // Reject empty commits + // TODO: The --keep option controls what to do about these. + const file_status = await git.statusMatrix({ + fs, dir, gitdir, cache, + ignored: false, + }); + if (! await has_staged_changes({ fs, dir, gitdir, cache })) { + // For now, just skip empty commits. + // TODO: cherry-picking should be a multi-step process. + stderr(`Skipping empty commit ${shorten_hash(commit_data.oid)} ${commit_title}`); + continue; + } + + // Make the commit! + head_oid = await git.commit({ + fs, dir, gitdir, cache, + message: commit.message, + author: commit.author, + committer: commit.committer, + }); + + // Print out information about the new commit. + // TODO: Should be a lot more output. See commit.js for a similar list of TODOs. + stdout(`[${branch} ${shorten_hash(head_oid)}] ${commit_title}`); + } + } +}