Skip to content

Commit ad4f132

Browse files
AtkinsSJKernelDeimos
authored andcommitted
feat(git): Implement git branch
Create, delete, copy, rename, and list branches.
1 parent 43ce0d5 commit ad4f132

File tree

2 files changed

+300
-0
lines changed

2 files changed

+300
-0
lines changed

packages/git/src/subcommands/__exports__.js

+2
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
*/
1919
// Generated by /tools/gen.js
2020
import module_add from './add.js'
21+
import module_branch from './branch.js'
2122
import module_clone from './clone.js'
2223
import module_commit from './commit.js'
2324
import module_config from './config.js'
@@ -31,6 +32,7 @@ import module_version from './version.js'
3132

3233
export default {
3334
"add": module_add,
35+
"branch": module_branch,
3436
"clone": module_clone,
3537
"commit": module_commit,
3638
"config": module_config,
+298
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
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 from 'isomorphic-git';
20+
import { find_repo_root, shorten_hash } from '../git-helpers.js';
21+
import { SHOW_USAGE } from '../help.js';
22+
23+
const BRANCH = {
24+
name: 'branch',
25+
usage: [
26+
'git branch [--list]',
27+
'git branch [--force] <branch-name> [<start-point>]',
28+
'git branch --show-current',
29+
'git branch --delete [--force] <branch-name>...',
30+
'git branch --move [--force] [<old-branch-name>] <new-branch-name>',
31+
'git branch --copy [--force] [<old-branch-name>] <new-branch-name>',
32+
],
33+
description: `Manage git branches.`,
34+
args: {
35+
allowPositionals: true,
36+
tokens: true,
37+
strict: false,
38+
options: {
39+
'delete': {
40+
description: 'Delete the named branch.',
41+
type: 'boolean',
42+
short: 'd',
43+
},
44+
'list': {
45+
description: 'List branches.',
46+
type: 'boolean',
47+
short: 'l',
48+
},
49+
'move': {
50+
description: 'Rename a branch. Defaults to renaming the current branch if only 1 argument is given.',
51+
type: 'boolean',
52+
short: 'm',
53+
},
54+
'copy': {
55+
description: 'Create a copy of a branch. Defaults to copying the current branch if only 1 argument is given.',
56+
type: 'boolean',
57+
short: 'c',
58+
},
59+
'show-current': {
60+
description: 'Print out the name of the current branch. Prints nothing in a detached HEAD state.',
61+
type: 'boolean',
62+
},
63+
'force': {
64+
description: 'Perform the action forcefully. For --delete, ignores whether the branches are fully merged. For --move, --copy, and creating new branches, ignores whether a branch already exists with that name.',
65+
type: 'boolean',
66+
short: 'f',
67+
}
68+
},
69+
},
70+
execute: async (ctx) => {
71+
const { io, fs, env, args } = ctx;
72+
const { stdout, stderr } = io;
73+
const { options, positionals, tokens } = args;
74+
75+
for (const token of tokens) {
76+
if (token.kind !== 'option') continue;
77+
78+
if (token.name === 'C') {
79+
options.copy = true;
80+
options.force = true;
81+
delete options['C'];
82+
continue;
83+
}
84+
if (token.name === 'D') {
85+
options.delete = true;
86+
options.force = true;
87+
delete options['D'];
88+
continue;
89+
}
90+
if (token.name === 'M') {
91+
options.move = true;
92+
options.force = true;
93+
delete options['M'];
94+
continue;
95+
}
96+
97+
// Report any options that we don't recognize
98+
let option_recognized = false;
99+
for (const [key, value] of Object.entries(BRANCH.args.options)) {
100+
if (key === token.name || value.short === token.name) {
101+
option_recognized = true;
102+
break;
103+
}
104+
}
105+
if (!option_recognized) {
106+
stderr(`Unrecognized option: ${token.rawName}`);
107+
throw SHOW_USAGE;
108+
}
109+
}
110+
111+
const { repository_dir, git_dir } = await find_repo_root(fs, env.PWD);
112+
113+
const get_current_branch = async () => git.currentBranch({
114+
fs,
115+
dir: repository_dir,
116+
gitdir: git_dir,
117+
test: true,
118+
});
119+
const get_all_branches = async () => git.listBranches({
120+
fs,
121+
dir: repository_dir,
122+
gitdir: git_dir,
123+
});
124+
const get_branch_data = async () => {
125+
const [branches, current_branch] = await Promise.all([
126+
get_all_branches(),
127+
get_current_branch(),
128+
]);
129+
return { branches, current_branch };
130+
}
131+
132+
if (options['copy']) {
133+
const { branches, current_branch } = await get_branch_data();
134+
if (positionals.length === 0 || positionals.length > 2) {
135+
stderr('error: Expected 1 or 2 arguments, for [<old-branch-name>] <new-branch-name>.');
136+
throw SHOW_USAGE;
137+
}
138+
const new_name = positionals.pop();
139+
const old_name = positionals.pop() ?? current_branch;
140+
141+
if (new_name === old_name)
142+
return;
143+
144+
if (!branches.includes(old_name))
145+
throw new Error(`Branch '${old_name}' not found.`);
146+
147+
if (branches.includes(new_name) && !options.force)
148+
throw new Error(`A branch named '${new_name}' already exists.`);
149+
150+
await git.branch({
151+
fs,
152+
dir: repository_dir,
153+
gitdir: git_dir,
154+
ref: new_name,
155+
object: old_name,
156+
checkout: false,
157+
force: options.force,
158+
});
159+
return;
160+
}
161+
162+
if (options['delete']) {
163+
const { branches, current_branch } = await get_branch_data();
164+
const branches_to_delete = [...positionals];
165+
if (branches_to_delete.length === 0) {
166+
stderr('error: Expected a list of branch names to delete.');
167+
throw SHOW_USAGE;
168+
}
169+
170+
// TODO: We should only allow non-merged branches to be deleted, unless --force is specified.
171+
172+
const results = await Promise.allSettled(branches_to_delete.map(async branch => {
173+
if (branch === current_branch)
174+
throw new Error(`Cannot delete branch '${branch}' while it is checked out.`);
175+
if (!branches.includes(branch))
176+
throw new Error(`Branch '${branch}' not found.`);
177+
const oid = await git.resolveRef({
178+
fs,
179+
dir: repository_dir,
180+
gitdir: git_dir,
181+
ref: branch,
182+
});
183+
const result = await git.deleteBranch({
184+
fs,
185+
dir: repository_dir,
186+
gitdir: git_dir,
187+
ref: branch,
188+
});
189+
return oid;
190+
}));
191+
192+
let any_failed = false;
193+
for (let i = 0; i < results.length; i++) {
194+
const result = results[i];
195+
const branch = branches_to_delete[i];
196+
197+
if (result.status === 'rejected') {
198+
any_failed = true;
199+
stderr(`error: ${result.reason}`);
200+
} else {
201+
const oid = result.value;
202+
const hash = shorten_hash(result.value);
203+
stdout(`Deleted branch ${branch} (was ${hash}).`);
204+
}
205+
}
206+
207+
return any_failed ? 1 : 0;
208+
}
209+
210+
if (options['move']) {
211+
const { branches, current_branch } = await get_branch_data();
212+
if (positionals.length === 0 || positionals.length > 2) {
213+
stderr('error: Expected 1 or 2 arguments, for [<old-branch-name>] <new-branch-name>.');
214+
throw SHOW_USAGE;
215+
}
216+
const new_name = positionals.pop();
217+
const old_name = positionals.pop() ?? current_branch;
218+
219+
if (new_name === old_name)
220+
return;
221+
222+
if (!branches.includes(old_name))
223+
throw new Error(`Branch '${old_name}' not found.`);
224+
225+
if (branches.includes(new_name)) {
226+
if (!options.force)
227+
throw new Error(`A branch named '${new_name}' already exists.`);
228+
await git.deleteBranch({
229+
fs,
230+
dir: repository_dir,
231+
gitdir: git_dir,
232+
ref: new_name,
233+
});
234+
}
235+
236+
await git.renameBranch({
237+
fs,
238+
dir: repository_dir,
239+
gitdir: git_dir,
240+
ref: new_name,
241+
oldref: old_name,
242+
checkout: old_name === current_branch,
243+
});
244+
245+
return;
246+
}
247+
248+
if (options['show-current']) {
249+
if (positionals.length !== 0) {
250+
stderr('error: Unexpected arguments.');
251+
throw SHOW_USAGE;
252+
}
253+
const current_branch = await get_current_branch();
254+
if (current_branch)
255+
stdout(current_branch);
256+
return;
257+
}
258+
259+
if (options['list'] || positionals.length === 0) {
260+
const { branches, current_branch } = await get_branch_data();
261+
// TODO: Allow a pattern here for branch names to match.
262+
if (positionals.length > 0) {
263+
stderr('error: Unexpected arguments.');
264+
throw SHOW_USAGE;
265+
}
266+
267+
for (const branch of branches) {
268+
if (branch === current_branch) {
269+
stdout(`\x1b[32;1m* ${branch}\x1b[0m`);
270+
} else {
271+
stdout(` ${branch}`);
272+
}
273+
}
274+
return;
275+
}
276+
277+
// Finally, we have a positional argument, so we should create a branch
278+
{
279+
const { branches, current_branch } = await get_branch_data();
280+
const branch_name = positionals.shift();
281+
const starting_point = positionals.shift() ?? current_branch;
282+
283+
if (branches.includes(branch_name) && !options.force)
284+
throw new Error(`A branch named '${branch_name}' already exists.`);
285+
286+
await git.branch({
287+
fs,
288+
dir: repository_dir,
289+
gitdir: git_dir,
290+
ref: branch_name,
291+
object: starting_point,
292+
checkout: false,
293+
force: options.force,
294+
});
295+
}
296+
}
297+
};
298+
export default BRANCH;

0 commit comments

Comments
 (0)