Skip to content

Commit

Permalink
feat(git): Implement git cherry-pick
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
AtkinsSJ authored and KernelDeimos committed Jun 28, 2024
1 parent bab5204 commit 2e4259d
Show file tree
Hide file tree
Showing 3 changed files with 166 additions and 0 deletions.
13 changes: 13 additions & 0 deletions packages/git/src/git-helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>}
*/
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);
}
2 changes: 2 additions & 0 deletions packages/git/src/subcommands/__exports__.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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,
Expand Down
151 changes: 151 additions & 0 deletions packages/git/src/subcommands/cherry-pick.js
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/
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 <commit>...',
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}`);
}
}
}

0 comments on commit 2e4259d

Please sign in to comment.