Skip to content

Commit 2e4259d

Browse files
AtkinsSJKernelDeimos
authored andcommitted
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.
1 parent bab5204 commit 2e4259d

File tree

3 files changed

+166
-0
lines changed

3 files changed

+166
-0
lines changed

packages/git/src/git-helpers.js

+13
Original file line numberDiff line numberDiff line change
@@ -178,3 +178,16 @@ export const resolve_to_commit = async (git_context, ref) => {
178178
throw new Error(`bad revision '${ref}'`);
179179
}
180180
}
181+
182+
/**
183+
* Determine if the index has any staged changes.
184+
* @param git_context {{ fs, dir, gitdir, cache }} as taken by most isomorphic-git methods.
185+
* @returns {Promise<boolean>}
186+
*/
187+
export const has_staged_changes = async (git_context) => {
188+
const file_status = await git.statusMatrix({
189+
...git_context,
190+
ignored: false,
191+
});
192+
return file_status.some(([filepath, head, workdir, index]) => index !== head);
193+
}

packages/git/src/subcommands/__exports__.js

+2
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import module_add from './add.js'
2121
import module_branch from './branch.js'
2222
import module_checkout from './checkout.js'
23+
import module_cherry_pick from './cherry-pick.js'
2324
import module_clone from './clone.js'
2425
import module_commit from './commit.js'
2526
import module_config from './config.js'
@@ -40,6 +41,7 @@ export default {
4041
"add": module_add,
4142
"branch": module_branch,
4243
"checkout": module_checkout,
44+
"cherry-pick": module_cherry_pick,
4345
"clone": module_clone,
4446
"commit": module_commit,
4547
"config": module_config,
+151
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/*
2+
* Copyright (C) 2024 Puter Technologies Inc.
3+
*
4+
* This file is part of Puter's Git client.
5+
*
6+
* Puter's Git client is free software: you can redistribute it and/or modify
7+
* it under the terms of the GNU Affero General Public License as published
8+
* by the Free Software Foundation, either version 3 of the License, or
9+
* (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
* GNU Affero General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Affero General Public License
17+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
18+
*/
19+
import git, { TREE } from 'isomorphic-git';
20+
import { find_repo_root, has_staged_changes, resolve_to_commit, shorten_hash } from '../git-helpers.js';
21+
import { SHOW_USAGE } from '../help.js';
22+
import chalk from 'chalk';
23+
import { diff_git_trees } from '../diff.js';
24+
import * as Diff from 'diff';
25+
import path from 'path-browserify';
26+
27+
// TODO: cherry-pick is a multi-stage process. Any issue that occurs should pause it, print a message,
28+
// and return to the prompt, letting the user decide how to proceed.
29+
export default {
30+
name: 'cherry-pick',
31+
usage: 'git cherry-pick <commit>...',
32+
description: 'Apply changes from existing commits.',
33+
args: {
34+
allowPositionals: true,
35+
options: {
36+
},
37+
},
38+
execute: async (ctx) => {
39+
const { io, fs, env, args } = ctx;
40+
const { stdout, stderr } = io;
41+
const { options, positionals } = args;
42+
const cache = {};
43+
44+
if (positionals.length < 1) {
45+
stderr('error: Must specify commits to cherry-pick.');
46+
throw SHOW_USAGE;
47+
}
48+
49+
const { dir, gitdir } = await find_repo_root(fs, env.PWD);
50+
51+
// Ensure nothing is staged, as it would be overwritten
52+
if (await has_staged_changes({ fs, dir, gitdir, cache })) {
53+
stderr('error: your local changes would be overwritten by cherry-pick.');
54+
stderr(chalk.yellow('hint: commit your changes or stash them to proceed.'));
55+
stderr('fatal: cherry-pick failed');
56+
return 1;
57+
}
58+
59+
const branch = await git.currentBranch({ fs, dir, gitdir });
60+
61+
const commits = await Promise.all(positionals.map(commit_ref => resolve_to_commit({ fs, dir, gitdir, cache }, commit_ref)));
62+
let head_oid = await git.resolveRef({ fs, dir, gitdir, ref: 'HEAD' });
63+
const original_head_oid = head_oid;
64+
65+
const read_tree = walker => walker?.content()?.then(it => new TextDecoder().decode(it));
66+
67+
for (const commit_data of commits) {
68+
const commit = commit_data.commit;
69+
const commit_title = commit.message.split('\n')[0];
70+
71+
// We can't just add the old commit directly:
72+
// - Its parent is wrong
73+
// - Its tree is a snapshot of the files then. We intead need a new snapshot applying its changes
74+
// to the current HEAD.
75+
// So, we instead stage its changes one at a time, then commit() as if this was a new commit.
76+
77+
const diffs = await diff_git_trees({
78+
fs, dir, gitdir, cache, env,
79+
a_tree: TREE({ ref: commit.parent[0] }),
80+
b_tree: TREE({ ref: commit_data.oid }),
81+
read_a: read_tree,
82+
read_b: read_tree,
83+
});
84+
for (const { a, b, diff } of diffs) {
85+
// If the file was deleted, just remove it.
86+
if (diff.newFileName === '/dev/null') {
87+
await git.remove({
88+
fs, dir, gitdir, cache,
89+
filepath: diff.oldFileName,
90+
});
91+
continue;
92+
}
93+
94+
// If the file was created, just add it.
95+
if (diff.oldFileName === '/dev/null') {
96+
await git.updateIndex({
97+
fs, dir, gitdir, cache,
98+
filepath: diff.newFileName,
99+
add: true,
100+
oid: b.oid,
101+
});
102+
continue;
103+
}
104+
105+
// Otherwise, the file was modified. Calculate and then apply the patch.
106+
const existing_file_contents = await fs.promises.readFile(path.resolve(env.PWD, diff.newFileName), { encoding: 'utf8' });
107+
const new_file_contents = Diff.applyPatch(existing_file_contents, diff);
108+
if (!new_file_contents) {
109+
// TODO: We should insert merge conflict markers and wait for the user resolve the conflict.
110+
throw new Error(`Merge conflict: Unable to apply commit ${shorten_hash(commit_data.oid)} ${commit_title}`);
111+
}
112+
// Now, stage the new file contents
113+
const file_oid = await git.writeBlob({
114+
fs, dir, gitdir,
115+
blob: new TextEncoder().encode(new_file_contents),
116+
});
117+
await git.updateIndex({
118+
fs, dir, gitdir, cache,
119+
filepath: diff.newFileName,
120+
oid: file_oid,
121+
add: true,
122+
});
123+
}
124+
125+
// Reject empty commits
126+
// TODO: The --keep option controls what to do about these.
127+
const file_status = await git.statusMatrix({
128+
fs, dir, gitdir, cache,
129+
ignored: false,
130+
});
131+
if (! await has_staged_changes({ fs, dir, gitdir, cache })) {
132+
// For now, just skip empty commits.
133+
// TODO: cherry-picking should be a multi-step process.
134+
stderr(`Skipping empty commit ${shorten_hash(commit_data.oid)} ${commit_title}`);
135+
continue;
136+
}
137+
138+
// Make the commit!
139+
head_oid = await git.commit({
140+
fs, dir, gitdir, cache,
141+
message: commit.message,
142+
author: commit.author,
143+
committer: commit.committer,
144+
});
145+
146+
// Print out information about the new commit.
147+
// TODO: Should be a lot more output. See commit.js for a similar list of TODOs.
148+
stdout(`[${branch} ${shorten_hash(head_oid)}] ${commit_title}`);
149+
}
150+
}
151+
}

0 commit comments

Comments
 (0)