mirror of
https://github.com/HeyPuter/puter.git
synced 2025-02-02 23:28:39 +08:00
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.
This commit is contained in:
parent
bab5204209
commit
2e4259d267
@ -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);
|
||||
}
|
||||
|
@ -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,
|
||||
|
151
packages/git/src/subcommands/cherry-pick.js
Normal file
151
packages/git/src/subcommands/cherry-pick.js
Normal file
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user