Skip to content

Commit 8c70229

Browse files
AtkinsSJKernelDeimos
authored andcommitted
feat(git): Implement git push
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.
1 parent 3cad1ec commit 8c70229

File tree

3 files changed

+357
-0
lines changed

3 files changed

+357
-0
lines changed

packages/git/src/auth.js

+79
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
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+
/**
20+
* Authentication manager
21+
* Eventually this will want to retrieve stored credentials from somewhere, but for now
22+
* it simply uses whatever username and password are passed to the constructor.
23+
*/
24+
export class Authenticator {
25+
// Map of url string -> { username, password }
26+
#saved_auth_for_url = new Map();
27+
28+
// { username, password } provided in the constructor
29+
#provided_auth;
30+
31+
constructor({ username, password } = {}) {
32+
if (username && password) {
33+
this.#provided_auth = { username, password };
34+
}
35+
}
36+
37+
/**
38+
* Gives you an object that can be included in the parameters to any isomorphic-git
39+
* function that wants authentication.
40+
* eg, `await git.push({ ...get_auth_callbacks(stderr), etc });`
41+
* @param stderr
42+
* @returns {{onAuth: ((function(*, *): Promise<*|undefined>)|*), onAuthFailure: *, onAuthSuccess: *}}
43+
*/
44+
get_auth_callbacks(stderr) {
45+
return {
46+
onAuth: async (url, auth) => {
47+
if (this.#provided_auth)
48+
return this.#provided_auth;
49+
if (this.#saved_auth_for_url.has(url))
50+
return this.#saved_auth_for_url.get(url);
51+
// TODO: Look up saved authentication data from somewhere, based on the url.
52+
// TODO: Finally, request auth details from the user.
53+
stderr('Authentication required. Please specify --username and --password.');
54+
},
55+
onAuthSuccess: (url, auth) => {
56+
// TODO: Save this somewhere?
57+
this.#saved_auth_for_url.set(url, auth);
58+
},
59+
onAuthFailure: (url, auth) => {
60+
stderr(`Failed authentication for '${url}'`);
61+
},
62+
};
63+
}
64+
}
65+
66+
export const authentication_options = {
67+
// FIXME: --username and --password are a horrible way of doing authentication,
68+
// but we don't have other options right now. Remove them ASAP!
69+
username: {
70+
description: 'TEMPORARY: Username to authenticate with.',
71+
type: 'string',
72+
short: 'u',
73+
},
74+
password: {
75+
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.',
76+
type: 'string',
77+
short: 'p',
78+
},
79+
}

packages/git/src/subcommands/__exports__.js

+2
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import module_help from './help.js'
2929
import module_init from './init.js'
3030
import module_log from './log.js'
3131
import module_pull from './pull.js'
32+
import module_push from './push.js'
3233
import module_remote from './remote.js'
3334
import module_show from './show.js'
3435
import module_status from './status.js'
@@ -47,6 +48,7 @@ export default {
4748
"init": module_init,
4849
"log": module_log,
4950
"pull": module_pull,
51+
"push": module_push,
5052
"remote": module_remote,
5153
"show": module_show,
5254
"status": module_status,

packages/git/src/subcommands/push.js

+276
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
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 http from 'isomorphic-git/http/web';
21+
import { determine_fetch_remote, find_repo_root, shorten_hash } from '../git-helpers.js';
22+
import { SHOW_USAGE } from '../help.js';
23+
import { authentication_options, Authenticator } from '../auth.js';
24+
25+
export default {
26+
name: 'push',
27+
usage: [
28+
'git push [<repository> [<refspec>...]]',
29+
],
30+
description: `Send local changes to a remote repository.`,
31+
args: {
32+
allowPositionals: true,
33+
options: {
34+
force: {
35+
description: 'Force the changes, even if a fast-forward is not possible.',
36+
type: 'boolean',
37+
short: 'f',
38+
},
39+
...authentication_options,
40+
},
41+
},
42+
execute: async (ctx) => {
43+
const { io, fs, env, args } = ctx;
44+
const { stdout, stderr } = io;
45+
const { options, positionals } = args;
46+
const cache = {};
47+
48+
const { dir, gitdir } = await find_repo_root(fs, env.PWD);
49+
50+
const remotes = await git.listRemotes({
51+
fs,
52+
dir,
53+
gitdir,
54+
});
55+
56+
const remote = positionals.shift();
57+
const input_refspecs = [...positionals];
58+
59+
if (!options.username !== !options.password) {
60+
stderr('Please specify both --username and --password, or neither');
61+
return 1;
62+
}
63+
const authenticator = new Authenticator({
64+
username: options.username,
65+
password: options.password,
66+
});
67+
68+
// Possible inputs:
69+
// - Remote and refspecs: Look up remote normally
70+
// - Remote only: Use current branch as refspec
71+
// - Neither: Use current branch as refspec, then use its default remote
72+
let remote_url;
73+
if (input_refspecs.length === 0) {
74+
const branch = await git.currentBranch({ fs, dir, gitdir, test: true });
75+
if (!branch)
76+
throw new Error('You are not currently on a branch.');
77+
input_refspecs.push(branch);
78+
79+
if (!remote) {
80+
// "When the command line does not specify where to push with the <repository> argument,
81+
// branch.*.remote configuration for the current branch is consulted to determine where to push.
82+
// If the configuration is missing, it defaults to origin."
83+
const default_remote = await git.getConfig({ fs, dir, gitdir, path: `branch.${branch}.remote` });
84+
if (default_remote) {
85+
remote_url = default_remote;
86+
} else {
87+
const origin_url = remotes.find(it => it.remote === 'origin');
88+
if (origin_url) {
89+
remote_url = origin_url.url;
90+
} else {
91+
throw new Error(`Unable to determine remote for branch '${branch}'`);
92+
}
93+
}
94+
}
95+
}
96+
if (!remote_url) {
97+
// NOTE: By definition, we know that `remote` has a value here.
98+
remote_url = await determine_fetch_remote(remote, remotes).url;
99+
if (!remote_url) {
100+
throw new Error(`Unable to determine remote`);
101+
}
102+
}
103+
104+
const [ local_branches, remote_refs ] = await Promise.all([
105+
git.listBranches({ fs, dir, gitdir }),
106+
git.listServerRefs({
107+
http,
108+
corsProxy: globalThis.__CONFIG__.proxy_url,
109+
url: remote_url,
110+
forPush: true,
111+
...authenticator.get_auth_callbacks(stderr),
112+
}),
113+
]);
114+
115+
// Parse the refspecs into a more useful format
116+
const refspecs = [];
117+
const add_refspec = (refspec) => {
118+
// Only add each src:dest pair once.
119+
for (let i = 0; i < refspecs.length; i++) {
120+
const existing = refspecs[i];
121+
if (existing.source === refspec.source && existing.dest === refspec.dest) {
122+
// If this spec already exists, then ensure its `force` flag is set if the new one has it.
123+
existing.force |= refspec.force;
124+
return;
125+
}
126+
}
127+
refspecs.push(refspec);
128+
};
129+
let branches;
130+
for (let refspec of input_refspecs) {
131+
const original_refspec = refspec;
132+
133+
// Format is:
134+
// - Optional '+'
135+
// - Source
136+
// - ':'
137+
// - Dest
138+
//
139+
// Source and/or Dest may be omitted:
140+
// - If both are omitted, that's a special "push all branches that exist locally and on the remote".
141+
// - If only Dest is provided, delete it on the remote.
142+
// - If only Source is provided, use its default destination. (There's nuance here we can worry about later.)
143+
144+
let force = options.force;
145+
146+
if (refspec.startsWith('+')) {
147+
force = true;
148+
refspec = refspec.slice(1);
149+
}
150+
151+
if (refspec === ':') {
152+
// "The special refspec : (or +: to allow non-fast-forward updates) directs Git to push "matching"
153+
// branches: for every branch that exists on the local side, the remote side is updated if a branch of
154+
// the same name already exists on the remote side."
155+
for (const local_branch of local_branches) {
156+
if (remote_refs.find(it => it.ref === `refs/heads/${local_branch}`)) {
157+
add_refspec({
158+
source: local_branch,
159+
dest: local_branch,
160+
force,
161+
});
162+
}
163+
}
164+
continue;
165+
}
166+
167+
if (refspec.includes(':')) {
168+
const parts = refspec.split(':');
169+
if (parts.length > 2)
170+
throw new Error(`Invalid refspec '${original_refspec}': Too many colons`);
171+
if (parts[1].length === 0)
172+
throw new Error(`Invalid refspec '${original_refspec}': Colon present but dest is empty`);
173+
174+
add_refspec({
175+
source: parts[0].length ? parts[0] : null,
176+
dest: parts[1],
177+
force,
178+
});
179+
continue;
180+
}
181+
182+
// Just a source present. So determine what the dest is from the config.
183+
// Default to using the same name.
184+
// TODO: Canonical git behaves a bit differently!
185+
const tracking_branch = await git.getConfig({ fs, dir, gitdir, path: `branch.${refspec}.merge` }) ?? refspec;
186+
add_refspec({
187+
source: refspec,
188+
dest: tracking_branch,
189+
force,
190+
});
191+
}
192+
193+
const push_ref = async (refspec) => {
194+
const { source, dest, force } = refspec;
195+
// At this point, source or Dest may be null:
196+
// - If no source, delete dest on the remote.
197+
// - If no dest, use the default dest for the source. (This is handled by `git.push()` I think.)
198+
const delete_ = source === null;
199+
200+
// TODO: This assumes the dest is a branch not a tag, is that always true?
201+
// TODO: What if the source or dest already has the refs/foo/ prefix?
202+
const remote_ref = remote_refs.find(it => it.ref === `refs/heads/${dest}`);
203+
const is_new = !remote_ref;
204+
// TODO: Canonical git only pushes "new" branches to the remote when configured to do so, or with --set-upstream.
205+
// So, we should show some kind of warning and stop, if that's not the case.
206+
207+
const source_oid = await git.resolveRef({ fs, dir, gitdir, ref: source });
208+
const old_dest_oid = remote_ref?.oid;
209+
210+
const is_up_to_date = source_oid === old_dest_oid;
211+
212+
try {
213+
const result = await git.push({
214+
fs,
215+
http,
216+
corsProxy: globalThis.__CONFIG__.proxy_url,
217+
dir,
218+
gitdir,
219+
cache,
220+
url: remote_url,
221+
ref: source,
222+
remoteRef: dest,
223+
force,
224+
delete: delete_,
225+
onMessage: (message) => {
226+
stdout(message);
227+
},
228+
...authenticator.get_auth_callbacks(stderr),
229+
});
230+
let flag = ' ';
231+
let summary = `${shorten_hash(old_dest_oid)}..${shorten_hash(source_oid)}`;
232+
if (delete_) {
233+
flag = '-';
234+
summary = '[deleted]';
235+
} else if (is_new) {
236+
flag = '*';
237+
summary = '[new branch]';
238+
} else if (force) {
239+
flag = '+';
240+
summary = `${shorten_hash(old_dest_oid)}...${shorten_hash(source_oid)}`;
241+
} else if (is_up_to_date) {
242+
flag = '=';
243+
summary = `[up to date]`;
244+
}
245+
return {
246+
flag,
247+
summary,
248+
source,
249+
dest,
250+
reason: null,
251+
};
252+
} catch (e) {
253+
return {
254+
flag: '!',
255+
summary: '[rejected]',
256+
source,
257+
dest,
258+
reason: e.data.reason,
259+
};
260+
};
261+
};
262+
263+
const results = await Promise.all(refspecs.map((refspec) => push_ref(refspec)));
264+
265+
stdout(`To ${remote_url}`);
266+
let any_failed = false;
267+
for (const { flag, summary, source, dest, reason } of results) {
268+
stdout(`${flag === '!' ? '\x1b[31;1m' : ''} ${flag} ${summary.padEnd(19, ' ')}\x1b[0m ${source} -> ${dest}${reason ? ` (${reason})` : ''}`);
269+
if (reason)
270+
any_failed = true;
271+
}
272+
if (any_failed) {
273+
stderr(`\x1b[31;1merror: Failed to push some refs to '${remote_url}'\x1b[0m`);
274+
}
275+
},
276+
};

0 commit comments

Comments
 (0)