From 2f4882c3d7bbb5242b29e0c20239456640c1e724 Mon Sep 17 00:00:00 2001 From: Sam Atkins Date: Fri, 31 May 2024 17:49:19 +0100 Subject: [PATCH 1/7] feat(git): Implement `git clone` --- packages/git/src/git-helpers.js | 2 + packages/git/src/subcommands/__exports__.js | 2 + packages/git/src/subcommands/clone.js | 119 ++++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 packages/git/src/subcommands/clone.js 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/subcommands/__exports__.js b/packages/git/src/subcommands/__exports__.js index 789c592a6c..e9caea9448 100644 --- a/packages/git/src/subcommands/__exports__.js +++ b/packages/git/src/subcommands/__exports__.js @@ -18,6 +18,7 @@ */ // Generated by /tools/gen.js import module_add from './add.js' +import module_clone from './clone.js' import module_commit from './commit.js' import module_config from './config.js' import module_help from './help.js' @@ -29,6 +30,7 @@ import module_version from './version.js' export default { "add": module_add, + "clone": module_clone, "commit": module_commit, "config": module_config, "help": module_help, 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); }, + }); + } +} From 803ce0c7ddbe64d1698370c3a83bc3eca6e2691b Mon Sep 17 00:00:00 2001 From: Sam Atkins Date: Fri, 31 May 2024 20:51:31 +0100 Subject: [PATCH 2/7] feat(git): Implement `git remote` --- packages/git/src/main.js | 1 + packages/git/src/subcommands/__exports__.js | 2 + packages/git/src/subcommands/remote.js | 126 ++++++++++++++++++++ 3 files changed, 129 insertions(+) create mode 100644 packages/git/src/subcommands/remote.js 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 e9caea9448..2950292f0b 100644 --- a/packages/git/src/subcommands/__exports__.js +++ b/packages/git/src/subcommands/__exports__.js @@ -24,6 +24,7 @@ import module_config from './config.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' @@ -36,6 +37,7 @@ export default { "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/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; + } + } + + + } +} From 4d8b1b6f669c69ae61cad56b46279609df3e0f4e Mon Sep 17 00:00:00 2001 From: Sam Atkins Date: Mon, 3 Jun 2024 16:49:08 +0100 Subject: [PATCH 3/7] feat(git): Implement `git branch` Create, delete, copy, rename, and list branches. --- packages/git/src/subcommands/__exports__.js | 2 + packages/git/src/subcommands/branch.js | 298 ++++++++++++++++++++ 2 files changed, 300 insertions(+) create mode 100644 packages/git/src/subcommands/branch.js diff --git a/packages/git/src/subcommands/__exports__.js b/packages/git/src/subcommands/__exports__.js index 2950292f0b..84a03c594a 100644 --- a/packages/git/src/subcommands/__exports__.js +++ b/packages/git/src/subcommands/__exports__.js @@ -18,6 +18,7 @@ */ // Generated by /tools/gen.js import module_add from './add.js' +import module_branch from './branch.js' import module_clone from './clone.js' import module_commit from './commit.js' import module_config from './config.js' @@ -31,6 +32,7 @@ import module_version from './version.js' export default { "add": module_add, + "branch": module_branch, "clone": module_clone, "commit": module_commit, "config": module_config, 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; From 6def43923427c48e382b087abc8ead1d0046f443 Mon Sep 17 00:00:00 2001 From: Sam Atkins Date: Mon, 3 Jun 2024 16:44:44 +0100 Subject: [PATCH 4/7] Remove unused imports from git subcommands --- packages/git/src/subcommands/add.js | 2 -- packages/git/src/subcommands/commit.js | 2 -- packages/git/src/subcommands/help.js | 2 -- packages/git/src/subcommands/status.js | 1 - packages/git/src/subcommands/version.js | 2 -- 5 files changed, 9 deletions(-) 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/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/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/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'; From 2a039cd11a5149f2dbc07c2c77e36bcbb55edabf Mon Sep 17 00:00:00 2001 From: Sam Atkins Date: Tue, 4 Jun 2024 14:20:40 +0100 Subject: [PATCH 5/7] feat(git): Implement `git fetch` Sporadically we hang while trying to fetch. I haven't been able to identify why but it seems like a race condition in isomorphic-git somewhere. --- packages/git/src/subcommands/__exports__.js | 2 + packages/git/src/subcommands/fetch.js | 107 ++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 packages/git/src/subcommands/fetch.js diff --git a/packages/git/src/subcommands/__exports__.js b/packages/git/src/subcommands/__exports__.js index 84a03c594a..5082c2cf4e 100644 --- a/packages/git/src/subcommands/__exports__.js +++ b/packages/git/src/subcommands/__exports__.js @@ -22,6 +22,7 @@ import module_branch from './branch.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' @@ -36,6 +37,7 @@ export default { "clone": module_clone, "commit": module_commit, "config": module_config, + "fetch": module_fetch, "help": module_help, "init": module_init, "log": module_log, 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); }, + }); + } +} From b77c61e56bbf3d45aeae4bfb0a30ad8f1ecbfc26 Mon Sep 17 00:00:00 2001 From: Sam Atkins Date: Tue, 4 Jun 2024 15:12:02 +0100 Subject: [PATCH 6/7] feat(git): Implement `git checkout` For now this only lets you check out branches, not files. --- packages/git/src/subcommands/__exports__.js | 2 + packages/git/src/subcommands/checkout.js | 157 ++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 packages/git/src/subcommands/checkout.js diff --git a/packages/git/src/subcommands/__exports__.js b/packages/git/src/subcommands/__exports__.js index 5082c2cf4e..0502434776 100644 --- a/packages/git/src/subcommands/__exports__.js +++ b/packages/git/src/subcommands/__exports__.js @@ -19,6 +19,7 @@ // 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' @@ -34,6 +35,7 @@ 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, diff --git a/packages/git/src/subcommands/checkout.js b/packages/git/src/subcommands/checkout.js new file mode 100644 index 0000000000..8dbe4c869f --- /dev/null +++ b/packages/git/src/subcommands/checkout.js @@ -0,0 +1,157 @@ +/* + * 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] []', + ], + 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 = {}; + + for (const token of tokens) { + 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 }; + } + + if (options['new-branch']) { + 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 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 + // TODO: Check out files. + { + if (positionals.length === 0 || positionals.length > 1) { + stderr('error: Expected 1 argument, for .'); + throw SHOW_USAGE; + } + const { branches, current_branch } = await get_branch_data(); + const branch_name = positionals.shift(); + + if (branch_name === current_branch) { + stdout(`Already on '${branch_name}'`); + return; + } + + if (!branches.includes(branch_name)) + throw new Error(`Branch '${branch_name}' not found.`); + + await git.checkout({ + fs, + dir: repository_dir, + gitdir: git_dir, + cache, + ref: branch_name, + force: options.force, + }); + stdout(`Switched to branch '${branch_name}'`); + } + } +}; +export default CHECKOUT; From cf8c13bc7ac5d334117660969814142a09cff759 Mon Sep 17 00:00:00 2001 From: Sam Atkins Date: Wed, 5 Jun 2024 12:53:28 +0100 Subject: [PATCH 7/7] WIP: Implement `git checkout *files*` As far as I can tell, this is all correct, but I'm seeing really odd results: - Staging area gets overwritten as well. See https://github.com/isomorphic-git/isomorphic-git/issues/1741 - We report that the working directory is clean after, even though a file is now different than in HEAD. Possibly related to this? https://github.com/isomorphic-git/isomorphic-git/issues/608 Some of this could be bugs in `git status`, I don't know. But it's very odd nonetheless so I'll leave it unmerged for now. --- packages/git/src/subcommands/checkout.js | 112 ++++++++++++++++++----- 1 file changed, 91 insertions(+), 21 deletions(-) diff --git a/packages/git/src/subcommands/checkout.js b/packages/git/src/subcommands/checkout.js index 8dbe4c869f..97829b4ec7 100644 --- a/packages/git/src/subcommands/checkout.js +++ b/packages/git/src/subcommands/checkout.js @@ -26,6 +26,7 @@ const CHECKOUT = { usage: [ 'git checkout [--force] ', 'git checkout (-b | -B) [--force] []', + 'git checkout [--force] [] [--] ...', ], description: `Switch branches.`, args: { @@ -52,7 +53,31 @@ const CHECKOUT = { 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') { @@ -99,8 +124,9 @@ const CHECKOUT = { return { branches, current_branch }; } + const { branches, current_branch } = await get_branch_data(); + if (options['new-branch']) { - 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; @@ -124,34 +150,78 @@ const CHECKOUT = { return; } - // Check out a branch - // TODO: Check out files. - { - if (positionals.length === 0 || positionals.length > 1) { - stderr('error: Expected 1 argument, for .'); - throw SHOW_USAGE; - } - const { branches, current_branch } = await get_branch_data(); - const branch_name = positionals.shift(); + // 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 (branch_name === current_branch) { + 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)) + 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.`); + } + } - await git.checkout({ - fs, - dir: repository_dir, - gitdir: git_dir, - cache, - ref: branch_name, - force: options.force, - }); - stdout(`Switched to branch '${branch_name}'`); + 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;