diff --git a/packages/git/src/git-helpers.js b/packages/git/src/git-helpers.js
index 87e0086227..ba4b62ba6e 100644
--- a/packages/git/src/git-helpers.js
+++ b/packages/git/src/git-helpers.js
@@ -18,6 +18,8 @@
*/
import path from 'path-browserify';
+export const PROXY_URL = 'https://cors.isomorphic-git.org';
+
/**
* Attempt to locate the git repository directory.
* @throws Error If no git repository could be found, or another error occurred.
diff --git a/packages/git/src/main.js b/packages/git/src/main.js
index adb1f744bc..dabbca422b 100644
--- a/packages/git/src/main.js
+++ b/packages/git/src/main.js
@@ -141,6 +141,7 @@ window.main = async () => {
args: {
options: parsed_args.values,
positionals: parsed_args.positionals,
+ tokens: parsed_args.tokens,
},
env,
};
diff --git a/packages/git/src/subcommands/__exports__.js b/packages/git/src/subcommands/__exports__.js
index 789c592a6c..0502434776 100644
--- a/packages/git/src/subcommands/__exports__.js
+++ b/packages/git/src/subcommands/__exports__.js
@@ -18,22 +18,32 @@
*/
// Generated by /tools/gen.js
import module_add from './add.js'
+import module_branch from './branch.js'
+import module_checkout from './checkout.js'
+import module_clone from './clone.js'
import module_commit from './commit.js'
import module_config from './config.js'
+import module_fetch from './fetch.js'
import module_help from './help.js'
import module_init from './init.js'
import module_log from './log.js'
+import module_remote from './remote.js'
import module_show from './show.js'
import module_status from './status.js'
import module_version from './version.js'
export default {
"add": module_add,
+ "branch": module_branch,
+ "checkout": module_checkout,
+ "clone": module_clone,
"commit": module_commit,
"config": module_config,
+ "fetch": module_fetch,
"help": module_help,
"init": module_init,
"log": module_log,
+ "remote": module_remote,
"show": module_show,
"status": module_status,
"version": module_version,
diff --git a/packages/git/src/subcommands/add.js b/packages/git/src/subcommands/add.js
index 441576d097..4fc93446d1 100644
--- a/packages/git/src/subcommands/add.js
+++ b/packages/git/src/subcommands/add.js
@@ -17,8 +17,6 @@
* along with this program. If not, see .
*/
import git from 'isomorphic-git';
-import path from 'path-browserify';
-import { ErrorCodes } from '@heyputer/puter-js-common/src/PosixError.js';
import { find_repo_root } from '../git-helpers.js';
export default {
diff --git a/packages/git/src/subcommands/branch.js b/packages/git/src/subcommands/branch.js
new file mode 100644
index 0000000000..beab91e6b2
--- /dev/null
+++ b/packages/git/src/subcommands/branch.js
@@ -0,0 +1,298 @@
+/*
+ * 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 from 'isomorphic-git';
+import { find_repo_root, shorten_hash } from '../git-helpers.js';
+import { SHOW_USAGE } from '../help.js';
+
+const BRANCH = {
+ name: 'branch',
+ usage: [
+ 'git branch [--list]',
+ 'git branch [--force] []',
+ 'git branch --show-current',
+ 'git branch --delete [--force] ...',
+ 'git branch --move [--force] [] ',
+ 'git branch --copy [--force] [] ',
+ ],
+ description: `Manage git branches.`,
+ args: {
+ allowPositionals: true,
+ tokens: true,
+ strict: false,
+ options: {
+ 'delete': {
+ description: 'Delete the named branch.',
+ type: 'boolean',
+ short: 'd',
+ },
+ 'list': {
+ description: 'List branches.',
+ type: 'boolean',
+ short: 'l',
+ },
+ 'move': {
+ description: 'Rename a branch. Defaults to renaming the current branch if only 1 argument is given.',
+ type: 'boolean',
+ short: 'm',
+ },
+ 'copy': {
+ description: 'Create a copy of a branch. Defaults to copying the current branch if only 1 argument is given.',
+ type: 'boolean',
+ short: 'c',
+ },
+ 'show-current': {
+ description: 'Print out the name of the current branch. Prints nothing in a detached HEAD state.',
+ type: 'boolean',
+ },
+ 'force': {
+ 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.',
+ type: 'boolean',
+ short: 'f',
+ }
+ },
+ },
+ execute: async (ctx) => {
+ const { io, fs, env, args } = ctx;
+ const { stdout, stderr } = io;
+ const { options, positionals, tokens } = args;
+
+ for (const token of tokens) {
+ if (token.kind !== 'option') continue;
+
+ if (token.name === 'C') {
+ options.copy = true;
+ options.force = true;
+ delete options['C'];
+ continue;
+ }
+ if (token.name === 'D') {
+ options.delete = true;
+ options.force = true;
+ delete options['D'];
+ continue;
+ }
+ if (token.name === 'M') {
+ options.move = true;
+ options.force = true;
+ delete options['M'];
+ continue;
+ }
+
+ // Report any options that we don't recognize
+ let option_recognized = false;
+ for (const [key, value] of Object.entries(BRANCH.args.options)) {
+ if (key === token.name || value.short === token.name) {
+ option_recognized = true;
+ break;
+ }
+ }
+ if (!option_recognized) {
+ stderr(`Unrecognized option: ${token.rawName}`);
+ throw SHOW_USAGE;
+ }
+ }
+
+ const { repository_dir, git_dir } = await find_repo_root(fs, env.PWD);
+
+ const get_current_branch = async () => git.currentBranch({
+ fs,
+ dir: repository_dir,
+ gitdir: git_dir,
+ test: true,
+ });
+ const get_all_branches = async () => git.listBranches({
+ fs,
+ dir: repository_dir,
+ gitdir: git_dir,
+ });
+ const get_branch_data = async () => {
+ const [branches, current_branch] = await Promise.all([
+ get_all_branches(),
+ get_current_branch(),
+ ]);
+ return { branches, current_branch };
+ }
+
+ if (options['copy']) {
+ const { branches, current_branch } = await get_branch_data();
+ if (positionals.length === 0 || positionals.length > 2) {
+ stderr('error: Expected 1 or 2 arguments, for [] .');
+ throw SHOW_USAGE;
+ }
+ const new_name = positionals.pop();
+ const old_name = positionals.pop() ?? current_branch;
+
+ if (new_name === old_name)
+ return;
+
+ if (!branches.includes(old_name))
+ throw new Error(`Branch '${old_name}' not found.`);
+
+ if (branches.includes(new_name) && !options.force)
+ throw new Error(`A branch named '${new_name}' already exists.`);
+
+ await git.branch({
+ fs,
+ dir: repository_dir,
+ gitdir: git_dir,
+ ref: new_name,
+ object: old_name,
+ checkout: false,
+ force: options.force,
+ });
+ return;
+ }
+
+ if (options['delete']) {
+ const { branches, current_branch } = await get_branch_data();
+ const branches_to_delete = [...positionals];
+ if (branches_to_delete.length === 0) {
+ stderr('error: Expected a list of branch names to delete.');
+ throw SHOW_USAGE;
+ }
+
+ // TODO: We should only allow non-merged branches to be deleted, unless --force is specified.
+
+ const results = await Promise.allSettled(branches_to_delete.map(async branch => {
+ if (branch === current_branch)
+ throw new Error(`Cannot delete branch '${branch}' while it is checked out.`);
+ if (!branches.includes(branch))
+ throw new Error(`Branch '${branch}' not found.`);
+ const oid = await git.resolveRef({
+ fs,
+ dir: repository_dir,
+ gitdir: git_dir,
+ ref: branch,
+ });
+ const result = await git.deleteBranch({
+ fs,
+ dir: repository_dir,
+ gitdir: git_dir,
+ ref: branch,
+ });
+ return oid;
+ }));
+
+ let any_failed = false;
+ for (let i = 0; i < results.length; i++) {
+ const result = results[i];
+ const branch = branches_to_delete[i];
+
+ if (result.status === 'rejected') {
+ any_failed = true;
+ stderr(`error: ${result.reason}`);
+ } else {
+ const oid = result.value;
+ const hash = shorten_hash(result.value);
+ stdout(`Deleted branch ${branch} (was ${hash}).`);
+ }
+ }
+
+ return any_failed ? 1 : 0;
+ }
+
+ if (options['move']) {
+ const { branches, current_branch } = await get_branch_data();
+ if (positionals.length === 0 || positionals.length > 2) {
+ stderr('error: Expected 1 or 2 arguments, for [] .');
+ throw SHOW_USAGE;
+ }
+ const new_name = positionals.pop();
+ const old_name = positionals.pop() ?? current_branch;
+
+ if (new_name === old_name)
+ return;
+
+ if (!branches.includes(old_name))
+ throw new Error(`Branch '${old_name}' not found.`);
+
+ if (branches.includes(new_name)) {
+ if (!options.force)
+ throw new Error(`A branch named '${new_name}' already exists.`);
+ await git.deleteBranch({
+ fs,
+ dir: repository_dir,
+ gitdir: git_dir,
+ ref: new_name,
+ });
+ }
+
+ await git.renameBranch({
+ fs,
+ dir: repository_dir,
+ gitdir: git_dir,
+ ref: new_name,
+ oldref: old_name,
+ checkout: old_name === current_branch,
+ });
+
+ return;
+ }
+
+ if (options['show-current']) {
+ if (positionals.length !== 0) {
+ stderr('error: Unexpected arguments.');
+ throw SHOW_USAGE;
+ }
+ const current_branch = await get_current_branch();
+ if (current_branch)
+ stdout(current_branch);
+ return;
+ }
+
+ if (options['list'] || positionals.length === 0) {
+ const { branches, current_branch } = await get_branch_data();
+ // TODO: Allow a pattern here for branch names to match.
+ if (positionals.length > 0) {
+ stderr('error: Unexpected arguments.');
+ throw SHOW_USAGE;
+ }
+
+ for (const branch of branches) {
+ if (branch === current_branch) {
+ stdout(`\x1b[32;1m* ${branch}\x1b[0m`);
+ } else {
+ stdout(` ${branch}`);
+ }
+ }
+ return;
+ }
+
+ // Finally, we have a positional argument, so we should create a branch
+ {
+ const { branches, current_branch } = await get_branch_data();
+ const branch_name = positionals.shift();
+ const starting_point = positionals.shift() ?? current_branch;
+
+ if (branches.includes(branch_name) && !options.force)
+ throw new Error(`A branch named '${branch_name}' already exists.`);
+
+ await git.branch({
+ fs,
+ dir: repository_dir,
+ gitdir: git_dir,
+ ref: branch_name,
+ object: starting_point,
+ checkout: false,
+ force: options.force,
+ });
+ }
+ }
+};
+export default BRANCH;
diff --git a/packages/git/src/subcommands/checkout.js b/packages/git/src/subcommands/checkout.js
new file mode 100644
index 0000000000..97829b4ec7
--- /dev/null
+++ b/packages/git/src/subcommands/checkout.js
@@ -0,0 +1,227 @@
+/*
+ * 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 from 'isomorphic-git';
+import http from 'isomorphic-git/http/web';
+import { find_repo_root, PROXY_URL } from '../git-helpers.js';
+import { SHOW_USAGE } from '../help.js';
+
+const CHECKOUT = {
+ name: 'checkout',
+ usage: [
+ 'git checkout [--force] ',
+ 'git checkout (-b | -B) [--force] []',
+ 'git checkout [--force] [] [--] ...',
+ ],
+ description: `Switch branches.`,
+ args: {
+ allowPositionals: true,
+ tokens: true,
+ strict: false,
+ options: {
+ 'new-branch': {
+ description: 'Create a new branch and then check it out.',
+ type: 'boolean',
+ short: 'b',
+ default: false,
+ },
+ 'force': {
+ description: 'Perform the checkout forcefully. For --new-branch, ignores whether the branch already exists. For checking out branches, ignores and overwrites any unstaged changes.',
+ type: 'boolean',
+ short: 'f',
+ },
+ },
+ },
+ execute: async (ctx) => {
+ const { io, fs, env, args } = ctx;
+ const { stdout, stderr } = io;
+ const { options, positionals, tokens } = args;
+ const cache = {};
+
+ const checkout_targets = {
+ from: null,
+ seen_separator: false,
+ pathspecs: [],
+ };
+ let reading_pathspecs = false;
+ for (const token of tokens) {
+
+ // Parse "[] [--] ..."
+ if (token.kind === 'option-terminator') {
+ checkout_targets.seen_separator = true;
+ reading_pathspecs = true;
+ continue;
+ }
+ if (token.kind === 'positional') {
+ if (reading_pathspecs) {
+ checkout_targets.pathspecs.push(token.value);
+ } else {
+ checkout_targets.from = token.value;
+ reading_pathspecs = true;
+ }
+ continue;
+ }
+
+ // Parse options
+ if (token.kind !== 'option') continue;
+
+ if (token.name === 'B') {
+ options['new-branch'] = true;
+ options.force = true;
+ delete options['B'];
+ continue;
+ }
+
+ // Report any options that we don't recognize
+ let option_recognized = false;
+ for (const [key, value] of Object.entries(CHECKOUT.args.options)) {
+ if (key === token.name || value.short === token.name) {
+ console.log('matches!');
+ option_recognized = true;
+ break;
+ }
+ }
+ if (!option_recognized) {
+ stderr(`Unrecognized option: ${token.rawName}`);
+ throw SHOW_USAGE;
+ }
+ }
+
+ const { repository_dir, git_dir } = await find_repo_root(fs, env.PWD);
+
+ // DRY: Copied from branch.js
+ const get_current_branch = async () => git.currentBranch({
+ fs,
+ dir: repository_dir,
+ gitdir: git_dir,
+ test: true,
+ });
+ const get_all_branches = async () => git.listBranches({
+ fs,
+ dir: repository_dir,
+ gitdir: git_dir,
+ });
+ const get_branch_data = async () => {
+ const [branches, current_branch] = await Promise.all([
+ get_all_branches(),
+ get_current_branch(),
+ ]);
+ return { branches, current_branch };
+ }
+
+ const { branches, current_branch } = await get_branch_data();
+
+ if (options['new-branch']) {
+ if (positionals.length === 0 || positionals.length > 2) {
+ stderr('error: Expected 1 or 2 arguments, for [].');
+ throw SHOW_USAGE;
+ }
+ const branch_name = positionals.shift();
+ const starting_point = positionals.shift() ?? current_branch;
+
+ if (branches.includes(branch_name) && !options.force)
+ throw new Error(`A branch named '${branch_name}' already exists.`);
+
+ await git.branch({
+ fs,
+ dir: repository_dir,
+ gitdir: git_dir,
+ ref: branch_name,
+ object: starting_point,
+ checkout: true,
+ force: options.force,
+ });
+ stdout(`Switched to a new branch '${branch_name}'`);
+ return;
+ }
+
+ // Check out a branch, or files
+ if (positionals.length === 0) {
+ stderr('error: Expected at least 1 argument, for either a branch name or some path specs.');
+ throw SHOW_USAGE;
+ }
+
+ if (checkout_targets.from) {
+ const branch_name = checkout_targets.from;
+
+ const specified_pathspecs = checkout_targets.pathspecs.length > 0;
+
+ if (branch_name === current_branch && !specified_pathspecs) {
+ stdout(`Already on '${branch_name}'`);
+ return;
+ }
+
+ if (branches.includes(branch_name)) {
+ await git.checkout({
+ fs,
+ dir: repository_dir,
+ gitdir: git_dir,
+ cache,
+ ref: branch_name,
+ ...(specified_pathspecs ? { filepaths: checkout_targets.pathspecs } : {}),
+ force: options.force,
+ onProgress: progress => {
+ console.log(progress.phase, progress.loaded, progress.total);
+ },
+ });
+ if (specified_pathspecs) {
+ // TODO: We should mention which files got updated!
+ stdout(`Updated files from '${branch_name}'`);
+ } else {
+ stdout(`Switched to branch '${branch_name}'`);
+ }
+ return;
+ } else if (checkout_targets.seen_separator) {
+ throw new Error(`Branch '${branch_name}' not found.`);
+ }
+ }
+
+ if (positionals.length === 1) {
+ const branch_name = positionals[0];
+
+ if (branch_name === current_branch) {
+ stdout(`Already on '${branch_name}'`);
+ return;
+ }
+
+ if (branches.includes(branch_name)) {
+ await git.checkout({
+ fs,
+ dir: repository_dir,
+ gitdir: git_dir,
+ cache,
+ ref: branch_name,
+ force: options.force,
+ });
+ stdout(`Switched to branch '${branch_name}'`);
+ return;
+ }
+ }
+
+ // Not a branch, so check out files
+ await git.checkout({
+ fs,
+ dir: repository_dir,
+ gitdir: git_dir,
+ cache,
+ filepaths: [positionals],
+ force: options.force,
+ });
+ }
+};
+export default CHECKOUT;
diff --git a/packages/git/src/subcommands/clone.js b/packages/git/src/subcommands/clone.js
new file mode 100644
index 0000000000..7a81b49454
--- /dev/null
+++ b/packages/git/src/subcommands/clone.js
@@ -0,0 +1,119 @@
+/*
+ * 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 from 'isomorphic-git';
+import http from 'isomorphic-git/http/web';
+import { PROXY_URL } from '../git-helpers.js';
+import { SHOW_USAGE } from '../help.js';
+import path from 'path-browserify';
+
+export default {
+ name: 'clone',
+ usage: 'git clone []',
+ description: 'Clone a repository into a new directory.',
+ args: {
+ allowPositionals: true,
+ options: {
+ depth: {
+ description: 'Only clone the specified number of commits. Implies --single-branch unless --no-single-branch is given.',
+ type: 'string',
+ },
+ 'single-branch': {
+ description: 'Only clone the history of the primary branch',
+ type: 'boolean',
+ default: false,
+ },
+ 'no-single-branch': {
+ description: 'Clone all history (default)',
+ type: 'boolean',
+ },
+ 'no-tags': {
+ description: 'Do not clone any tags from the remote',
+ type: 'boolean',
+ default: false,
+ },
+ },
+ },
+ execute: async (ctx) => {
+ const { io, fs, env, args } = ctx;
+ const { stdout, stderr } = io;
+ const { options, positionals } = args;
+
+ if (options.depth) {
+ const depth = Number.parseInt(options.depth);
+ if (!depth) {
+ stderr('Invalid --depth: Must be an integer greater than 0.');
+ return 1;
+ }
+ options.depth = depth;
+ options['single-branch'] = true;
+ }
+
+ if (options['no-single-branch']) {
+ options['single-branch'] = false;
+ delete options['no-single-branch'];
+ }
+
+ const [repository, directory] = positionals;
+ if (!repository) {
+ stderr('fatal: You must specify a repository to clone.');
+ throw SHOW_USAGE;
+ }
+
+ let repo_path;
+ if (directory) {
+ repo_path = path.resolve(env.PWD, directory);
+ } else {
+ // Try to extract directory from the repository url
+ let repo_name = repository.slice(repository.lastIndexOf('/') + 1);
+ if (repo_name.endsWith('.git')) {
+ repo_name = repo_name.slice(0, -4);
+ }
+
+ repo_path = path.resolve(env.PWD, repo_name);
+ }
+
+ // The path must either not exist, or be a directory that is empty
+ try {
+ const readdir = await fs.promises.readdir(repo_path);
+ if (readdir.length !== 0) {
+ stderr(`fatal: ${repo_path} is not empty.`);
+ return 1;
+ }
+ } catch (e) {
+ if (e.code !== 'ENOENT') {
+ stderr(`fatal: ${repo_path} is a file.`);
+ return 1;
+ }
+ }
+
+ stdout(`Cloning into '${path.relative(env.PWD, repo_path)}'...`);
+
+ await git.clone({
+ fs,
+ http,
+ corsProxy: PROXY_URL,
+ dir: repo_path,
+ url: repository,
+ depth: options.depth,
+ singleBranch: options['single-branch'],
+ noTags: options['no-tags'],
+ onMessage: (message) => { stdout(message); },
+ });
+ }
+}
diff --git a/packages/git/src/subcommands/commit.js b/packages/git/src/subcommands/commit.js
index 35ab9042db..485e247dc2 100644
--- a/packages/git/src/subcommands/commit.js
+++ b/packages/git/src/subcommands/commit.js
@@ -17,8 +17,6 @@
* along with this program. If not, see .
*/
import git from 'isomorphic-git';
-import path from 'path-browserify';
-import { ErrorCodes } from '@heyputer/puter-js-common/src/PosixError.js';
import { find_repo_root, shorten_hash } from '../git-helpers.js';
export default {
diff --git a/packages/git/src/subcommands/fetch.js b/packages/git/src/subcommands/fetch.js
new file mode 100644
index 0000000000..1e0799a54c
--- /dev/null
+++ b/packages/git/src/subcommands/fetch.js
@@ -0,0 +1,107 @@
+/*
+ * 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 from 'isomorphic-git';
+import http from 'isomorphic-git/http/web';
+import { find_repo_root, PROXY_URL } from '../git-helpers.js';
+import { SHOW_USAGE } from '../help.js';
+
+export default {
+ name: 'fetch',
+ usage: [
+ 'git fetch ',
+ 'git fetch --all',
+ ],
+ description: `Download objects and refs from another repository.`,
+ args: {
+ allowPositionals: true,
+ options: {
+ all: {
+ description: 'Fetch all remotes.',
+ type: 'boolean',
+ default: false,
+ }
+ },
+ },
+ execute: async (ctx) => {
+ const { io, fs, env, args } = ctx;
+ const { stdout, stderr } = io;
+ const { options, positionals } = args;
+ const cache = {};
+
+ const { repository_dir, git_dir } = await find_repo_root(fs, env.PWD);
+
+ const remotes = await git.listRemotes({
+ fs,
+ dir: repository_dir,
+ gitdir: git_dir,
+ });
+
+ if (options.all) {
+ for (const { remote, url } of remotes) {
+ stdout(`Fetching ${remote}\nFrom ${url}`);
+ await git.fetch({
+ fs,
+ http,
+ cache,
+ corsProxy: PROXY_URL,
+ dir: repository_dir,
+ gitdir: git_dir,
+ remote,
+ onMessage: (message) => { stdout(message); },
+ });
+ }
+ return;
+ }
+
+ const remote = positionals.shift();
+ // Three situations:
+ // 1) remote is an URL: Fetch it
+ // 2) remote is a remote name: Fetch it
+ // 3) remote is undefined: If there's an upstream for this branch, fetch that. Otherwise fetch the default origin.
+ // For simplicity, we'll leave 3) for later.
+ // TODO: Support `git fetch` with no positional arguments
+ if (!remote) {
+ stderr('Missing remote name to fetch.');
+ throw SHOW_USAGE;
+ }
+
+ const remote_id = {};
+ if (URL.canParse(remote)) {
+ remote_id.url = remote;
+ } else {
+ // Named remote. First, check if the remote exists. `git.fetch` reports non-existent remotes as:
+ // "The function requires a "remote OR url" parameter but none was provided."
+ // ...which is not helpful to the user.
+ if (!remotes.find(it => it.remote === remote))
+ throw new Error(`'${remote}' does not appear to be a git repository`);
+ remote_id.remote = remote;
+ }
+
+ await git.fetch({
+ fs,
+ http,
+ cache,
+ corsProxy: PROXY_URL,
+ dir: repository_dir,
+ gitdir: git_dir,
+ ...remote_id,
+ onMessage: (message) => { stdout(message); },
+ });
+ }
+}
diff --git a/packages/git/src/subcommands/help.js b/packages/git/src/subcommands/help.js
index 1a27f83159..22fdeae0dc 100644
--- a/packages/git/src/subcommands/help.js
+++ b/packages/git/src/subcommands/help.js
@@ -16,8 +16,6 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
-import git from 'isomorphic-git';
-import { ErrorCodes } from '@heyputer/puter-js-common/src/PosixError.js';
import subcommands from './__exports__.js';
import git_command from '../git-command-definition.js';
import { produce_help_string } from '../help.js';
diff --git a/packages/git/src/subcommands/remote.js b/packages/git/src/subcommands/remote.js
new file mode 100644
index 0000000000..4cc05125f2
--- /dev/null
+++ b/packages/git/src/subcommands/remote.js
@@ -0,0 +1,126 @@
+/*
+ * 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 from 'isomorphic-git';
+import { find_repo_root } from '../git-helpers.js';
+
+export default {
+ name: 'remote',
+ usage: 'git remote',
+ description: `Manage remote repositories.`,
+ args: {
+ allowPositionals: true,
+ tokens: true,
+ options: {
+ verbose: {
+ description: 'Verbose if the commit verbose was used',
+ type: 'boolean',
+ short: 'v',
+ default: false,
+ }
+ },
+ },
+ execute: async (ctx) => {
+ const { io, fs, env, args } = ctx;
+ const { stdout, stderr } = io;
+ const { options, positionals, tokens } = args;
+
+ const { repository_dir, git_dir } = await find_repo_root(fs, env.PWD);
+
+ // TODO: Other subcommands:
+ // - set-head
+ // - set-branches
+ // - get-url
+ // - set-url
+ // - show
+ // - prune
+ // - update
+ const subcommand = positionals.shift();
+ switch (subcommand) {
+ case undefined: {
+ // No subcommand, so list remotes
+ const remotes = await git.listRemotes({
+ fs,
+ dir: repository_dir,
+ gitdir: git_dir,
+ });
+ for (const remote of remotes) {
+ if (options.verbose) {
+ // TODO: fetch and push urls can be overridden per remote. That's what this is supposed to show.
+ stdout(`${remote.remote}\t${remote.url} (fetch)`);
+ stdout(`${remote.remote}\t${remote.url} (push)`);
+ } else {
+ stdout(remote.remote);
+ }
+ }
+ return;
+ }
+
+ case 'add': {
+ if (positionals.length !== 2) {
+ stderr(`error: Wrong number of arguments to 'git remote add'. Expected 2 but got ${positionals.length}`);
+ return 1;
+ }
+ const [ name, url ] = positionals;
+ await git.addRemote({
+ fs,
+ dir: repository_dir,
+ gitdir: git_dir,
+ remote: name,
+ url: url,
+ });
+ return;
+ }
+
+ case 'remove':
+ case 'rm': {
+ if (positionals.length !== 1) {
+ stderr(`error: Wrong number of arguments to 'git remote remove'. Expected 1 but got ${positionals.length}`);
+ return 1;
+ }
+ const [ name ] = positionals;
+
+ // First, check if the remote exists so we can show an error if it doesn't.
+ const remotes = await git.listRemotes({
+ fs,
+ dir: repository_dir,
+ gitdir: git_dir,
+ });
+ if (!remotes.find(it => it.remote === name)) {
+ stderr(`error: No such remote: '${name}'`);
+ return 1;
+ }
+
+ await git.deleteRemote({
+ fs,
+ dir: repository_dir,
+ gitdir: git_dir,
+ remote: name,
+ });
+ return;
+ }
+
+ default: {
+ stderr(`fatal: Unrecognized command 'git remote ${subcommand}'`);
+ return 1;
+ }
+ }
+
+
+ }
+}
diff --git a/packages/git/src/subcommands/status.js b/packages/git/src/subcommands/status.js
index 3ad136be26..65b839773d 100644
--- a/packages/git/src/subcommands/status.js
+++ b/packages/git/src/subcommands/status.js
@@ -18,7 +18,6 @@
*/
import git from 'isomorphic-git';
import path from 'path-browserify';
-import { ErrorCodes } from '@heyputer/puter-js-common/src/PosixError.js';
import { find_repo_root } from '../git-helpers.js';
export default {
diff --git a/packages/git/src/subcommands/version.js b/packages/git/src/subcommands/version.js
index 2e639f0d00..c55cb29fec 100644
--- a/packages/git/src/subcommands/version.js
+++ b/packages/git/src/subcommands/version.js
@@ -17,8 +17,6 @@
* along with this program. If not, see .
*/
import git from 'isomorphic-git';
-import path from 'path-browserify';
-import { ErrorCodes } from '@heyputer/puter-js-common/src/PosixError.js';
const VERSION = '1.0.0';