-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
For now, the authentication is by --username and --password options, which obviously is not good because the password is visible. But doing it in a nicer way requires features that Phoenix or Puter are currently missing.
- Loading branch information
1 parent
3cad1ec
commit 8c70229
Showing
3 changed files
with
357 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
/* | ||
* 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/>. | ||
*/ | ||
/** | ||
* Authentication manager | ||
* Eventually this will want to retrieve stored credentials from somewhere, but for now | ||
* it simply uses whatever username and password are passed to the constructor. | ||
*/ | ||
export class Authenticator { | ||
// Map of url string -> { username, password } | ||
#saved_auth_for_url = new Map(); | ||
|
||
// { username, password } provided in the constructor | ||
#provided_auth; | ||
|
||
constructor({ username, password } = {}) { | ||
if (username && password) { | ||
this.#provided_auth = { username, password }; | ||
} | ||
} | ||
|
||
/** | ||
* Gives you an object that can be included in the parameters to any isomorphic-git | ||
* function that wants authentication. | ||
* eg, `await git.push({ ...get_auth_callbacks(stderr), etc });` | ||
* @param stderr | ||
* @returns {{onAuth: ((function(*, *): Promise<*|undefined>)|*), onAuthFailure: *, onAuthSuccess: *}} | ||
*/ | ||
get_auth_callbacks(stderr) { | ||
return { | ||
onAuth: async (url, auth) => { | ||
if (this.#provided_auth) | ||
return this.#provided_auth; | ||
if (this.#saved_auth_for_url.has(url)) | ||
return this.#saved_auth_for_url.get(url); | ||
// TODO: Look up saved authentication data from somewhere, based on the url. | ||
// TODO: Finally, request auth details from the user. | ||
stderr('Authentication required. Please specify --username and --password.'); | ||
}, | ||
onAuthSuccess: (url, auth) => { | ||
// TODO: Save this somewhere? | ||
this.#saved_auth_for_url.set(url, auth); | ||
}, | ||
onAuthFailure: (url, auth) => { | ||
stderr(`Failed authentication for '${url}'`); | ||
}, | ||
}; | ||
} | ||
} | ||
|
||
export const authentication_options = { | ||
// FIXME: --username and --password are a horrible way of doing authentication, | ||
// but we don't have other options right now. Remove them ASAP! | ||
username: { | ||
description: 'TEMPORARY: Username to authenticate with.', | ||
type: 'string', | ||
short: 'u', | ||
}, | ||
password: { | ||
description: 'TEMPORARY: Password to authenticate with. For github.com, this needs to be a "Personal Access Token", created at https://github.com/settings/tokens with access to the repository.', | ||
type: 'string', | ||
short: 'p', | ||
}, | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,276 @@ | ||
/* | ||
* 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 from 'isomorphic-git'; | ||
import http from 'isomorphic-git/http/web'; | ||
import { determine_fetch_remote, find_repo_root, shorten_hash } from '../git-helpers.js'; | ||
import { SHOW_USAGE } from '../help.js'; | ||
import { authentication_options, Authenticator } from '../auth.js'; | ||
|
||
export default { | ||
name: 'push', | ||
usage: [ | ||
'git push [<repository> [<refspec>...]]', | ||
], | ||
description: `Send local changes to a remote repository.`, | ||
args: { | ||
allowPositionals: true, | ||
options: { | ||
force: { | ||
description: 'Force the changes, even if a fast-forward is not possible.', | ||
type: 'boolean', | ||
short: 'f', | ||
}, | ||
...authentication_options, | ||
}, | ||
}, | ||
execute: async (ctx) => { | ||
const { io, fs, env, args } = ctx; | ||
const { stdout, stderr } = io; | ||
const { options, positionals } = args; | ||
const cache = {}; | ||
|
||
const { dir, gitdir } = await find_repo_root(fs, env.PWD); | ||
|
||
const remotes = await git.listRemotes({ | ||
fs, | ||
dir, | ||
gitdir, | ||
}); | ||
|
||
const remote = positionals.shift(); | ||
const input_refspecs = [...positionals]; | ||
|
||
if (!options.username !== !options.password) { | ||
stderr('Please specify both --username and --password, or neither'); | ||
return 1; | ||
} | ||
const authenticator = new Authenticator({ | ||
username: options.username, | ||
password: options.password, | ||
}); | ||
|
||
// Possible inputs: | ||
// - Remote and refspecs: Look up remote normally | ||
// - Remote only: Use current branch as refspec | ||
// - Neither: Use current branch as refspec, then use its default remote | ||
let remote_url; | ||
if (input_refspecs.length === 0) { | ||
const branch = await git.currentBranch({ fs, dir, gitdir, test: true }); | ||
if (!branch) | ||
throw new Error('You are not currently on a branch.'); | ||
input_refspecs.push(branch); | ||
|
||
if (!remote) { | ||
// "When the command line does not specify where to push with the <repository> argument, | ||
// branch.*.remote configuration for the current branch is consulted to determine where to push. | ||
// If the configuration is missing, it defaults to origin." | ||
const default_remote = await git.getConfig({ fs, dir, gitdir, path: `branch.${branch}.remote` }); | ||
if (default_remote) { | ||
remote_url = default_remote; | ||
} else { | ||
const origin_url = remotes.find(it => it.remote === 'origin'); | ||
if (origin_url) { | ||
remote_url = origin_url.url; | ||
} else { | ||
throw new Error(`Unable to determine remote for branch '${branch}'`); | ||
} | ||
} | ||
} | ||
} | ||
if (!remote_url) { | ||
// NOTE: By definition, we know that `remote` has a value here. | ||
remote_url = await determine_fetch_remote(remote, remotes).url; | ||
if (!remote_url) { | ||
throw new Error(`Unable to determine remote`); | ||
} | ||
} | ||
|
||
const [ local_branches, remote_refs ] = await Promise.all([ | ||
git.listBranches({ fs, dir, gitdir }), | ||
git.listServerRefs({ | ||
http, | ||
corsProxy: globalThis.__CONFIG__.proxy_url, | ||
url: remote_url, | ||
forPush: true, | ||
...authenticator.get_auth_callbacks(stderr), | ||
}), | ||
]); | ||
|
||
// Parse the refspecs into a more useful format | ||
const refspecs = []; | ||
const add_refspec = (refspec) => { | ||
// Only add each src:dest pair once. | ||
for (let i = 0; i < refspecs.length; i++) { | ||
const existing = refspecs[i]; | ||
if (existing.source === refspec.source && existing.dest === refspec.dest) { | ||
// If this spec already exists, then ensure its `force` flag is set if the new one has it. | ||
existing.force |= refspec.force; | ||
return; | ||
} | ||
} | ||
refspecs.push(refspec); | ||
}; | ||
let branches; | ||
for (let refspec of input_refspecs) { | ||
const original_refspec = refspec; | ||
|
||
// Format is: | ||
// - Optional '+' | ||
// - Source | ||
// - ':' | ||
// - Dest | ||
// | ||
// Source and/or Dest may be omitted: | ||
// - If both are omitted, that's a special "push all branches that exist locally and on the remote". | ||
// - If only Dest is provided, delete it on the remote. | ||
// - If only Source is provided, use its default destination. (There's nuance here we can worry about later.) | ||
|
||
let force = options.force; | ||
|
||
if (refspec.startsWith('+')) { | ||
force = true; | ||
refspec = refspec.slice(1); | ||
} | ||
|
||
if (refspec === ':') { | ||
// "The special refspec : (or +: to allow non-fast-forward updates) directs Git to push "matching" | ||
// branches: for every branch that exists on the local side, the remote side is updated if a branch of | ||
// the same name already exists on the remote side." | ||
for (const local_branch of local_branches) { | ||
if (remote_refs.find(it => it.ref === `refs/heads/${local_branch}`)) { | ||
add_refspec({ | ||
source: local_branch, | ||
dest: local_branch, | ||
force, | ||
}); | ||
} | ||
} | ||
continue; | ||
} | ||
|
||
if (refspec.includes(':')) { | ||
const parts = refspec.split(':'); | ||
if (parts.length > 2) | ||
throw new Error(`Invalid refspec '${original_refspec}': Too many colons`); | ||
if (parts[1].length === 0) | ||
throw new Error(`Invalid refspec '${original_refspec}': Colon present but dest is empty`); | ||
|
||
add_refspec({ | ||
source: parts[0].length ? parts[0] : null, | ||
dest: parts[1], | ||
force, | ||
}); | ||
continue; | ||
} | ||
|
||
// Just a source present. So determine what the dest is from the config. | ||
// Default to using the same name. | ||
// TODO: Canonical git behaves a bit differently! | ||
const tracking_branch = await git.getConfig({ fs, dir, gitdir, path: `branch.${refspec}.merge` }) ?? refspec; | ||
add_refspec({ | ||
source: refspec, | ||
dest: tracking_branch, | ||
force, | ||
}); | ||
} | ||
|
||
const push_ref = async (refspec) => { | ||
const { source, dest, force } = refspec; | ||
// At this point, source or Dest may be null: | ||
// - If no source, delete dest on the remote. | ||
// - If no dest, use the default dest for the source. (This is handled by `git.push()` I think.) | ||
const delete_ = source === null; | ||
|
||
// TODO: This assumes the dest is a branch not a tag, is that always true? | ||
// TODO: What if the source or dest already has the refs/foo/ prefix? | ||
const remote_ref = remote_refs.find(it => it.ref === `refs/heads/${dest}`); | ||
const is_new = !remote_ref; | ||
// TODO: Canonical git only pushes "new" branches to the remote when configured to do so, or with --set-upstream. | ||
// So, we should show some kind of warning and stop, if that's not the case. | ||
|
||
const source_oid = await git.resolveRef({ fs, dir, gitdir, ref: source }); | ||
const old_dest_oid = remote_ref?.oid; | ||
|
||
const is_up_to_date = source_oid === old_dest_oid; | ||
|
||
try { | ||
const result = await git.push({ | ||
fs, | ||
http, | ||
corsProxy: globalThis.__CONFIG__.proxy_url, | ||
dir, | ||
gitdir, | ||
cache, | ||
url: remote_url, | ||
ref: source, | ||
remoteRef: dest, | ||
force, | ||
delete: delete_, | ||
onMessage: (message) => { | ||
stdout(message); | ||
}, | ||
...authenticator.get_auth_callbacks(stderr), | ||
}); | ||
let flag = ' '; | ||
let summary = `${shorten_hash(old_dest_oid)}..${shorten_hash(source_oid)}`; | ||
if (delete_) { | ||
flag = '-'; | ||
summary = '[deleted]'; | ||
} else if (is_new) { | ||
flag = '*'; | ||
summary = '[new branch]'; | ||
} else if (force) { | ||
flag = '+'; | ||
summary = `${shorten_hash(old_dest_oid)}...${shorten_hash(source_oid)}`; | ||
} else if (is_up_to_date) { | ||
flag = '='; | ||
summary = `[up to date]`; | ||
} | ||
return { | ||
flag, | ||
summary, | ||
source, | ||
dest, | ||
reason: null, | ||
}; | ||
} catch (e) { | ||
return { | ||
flag: '!', | ||
summary: '[rejected]', | ||
source, | ||
dest, | ||
reason: e.data.reason, | ||
}; | ||
}; | ||
}; | ||
|
||
const results = await Promise.all(refspecs.map((refspec) => push_ref(refspec))); | ||
|
||
stdout(`To ${remote_url}`); | ||
let any_failed = false; | ||
for (const { flag, summary, source, dest, reason } of results) { | ||
stdout(`${flag === '!' ? '\x1b[31;1m' : ''} ${flag} ${summary.padEnd(19, ' ')}\x1b[0m ${source} -> ${dest}${reason ? ` (${reason})` : ''}`); | ||
if (reason) | ||
any_failed = true; | ||
} | ||
if (any_failed) { | ||
stderr(`\x1b[31;1merror: Failed to push some refs to '${remote_url}'\x1b[0m`); | ||
} | ||
}, | ||
}; |