From db1ce4e462580894b3ef952be5745a074cddef11 Mon Sep 17 00:00:00 2001 From: Jan Malakhovski Date: Sat, 13 Feb 2021 22:01:31 +0000 Subject: [PATCH] Add `setup-fork` command, reimplement `clone` command as its child This command greatly simplifies setup for a previously clonned repository. A somewhat unforturate consequence is that the name of the only `hub.hookscript` had to be changed since invoking that hook is part `setup-fork` now. Since I had to rename it anyway I also renamed it to match git's own hook naming convention of using "-"es to separate words. --- git-hub | 201 ++++++++++++++++++++++++++++++++++++++++---------------- man.rst | 169 ++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 282 insertions(+), 88 deletions(-) diff --git a/git-hub b/git-hub index e54d3ac..1599429 100755 --- a/git-hub +++ b/git-hub @@ -782,24 +782,21 @@ class SetupCmd (object): ', '.join(users)) return users[0].encode('UTF8') +# `git hub setup-fork` command implementation +class SetupForkCmd (object): -# `git hub clone` command implementation -class CloneCmd (object): - + cmd_name = "setup-fork" cmd_required_config = ['username', 'oauthtoken'] - cmd_help = 'clone a GitHub repository (and fork as needed)' - cmd_usage = '%(prog)s [OPTIONS] [GIT CLONE OPTIONS] REPO [DEST]' + cmd_help = 'fork a GitHub repository' + cmd_usage = '%(prog)s [OPTIONS] [REPO]' @classmethod def setup_parser(cls, parser): - parser.add_argument('repository', metavar='REPO', + parser.add_argument('repository', metavar='REPO', nargs='?', help="name of the repository to fork; in " "/ format is the upstream repository, " "if only is specified, the part is " "taken from hub.username") - parser.add_argument('dest', metavar='DEST', nargs='?', - help="destination directory where to put the new " - "cloned repository") parser.add_argument('-U', '--upstreamremote', metavar='NAME', default=config.upstreamremote, help="use NAME as the upstream remote repository name " @@ -816,61 +813,113 @@ class CloneCmd (object): parser.add_argument('--no-triangular', action="store_false", dest='triangular', help="do not use Git 'triangular workflow' setup") - return True # we need to get unknown arguments @classmethod def run(cls, parser, args): - (urltype, proj) = cls.parse_repo(args.repository) - (repo, upstream, forked) = cls.setup_repo(proj) - dest = args.dest or repo['name'] + if args.repository is not None: + (urltype, proj) = cls.parse_repo(args.repository) + else: + git_remotes = git('remote', 'show', '-n').split('\n') + remote = args.upstreamremote + if remote not in git_remotes: + die("No REPO specified, nor does `{}` remote exist", remote) + git_url = git('remote', 'get-url', '--', remote) + (urltype, proj) = cls.parse_repo(git_url) + (repo, upstream, forked) = cls.maybe_fork(proj) + cls.setup_dest(parser, args, urltype, proj, repo, upstream, forked, None) + + @classmethod + def setup_dest(cls, parser, args, urltype, proj, repo, upstream, forked, dest): + personal = False + if upstream is None: + if args.upstreamremote != args.forkremote: + die('You are setting up with a personal repository as upstream, ' + '`--upstreamremote` and `--forkremote` must match') + upstream = proj + personal = True triangular = cls.check_triangular(args.triangular if args.triangular is not None else config.triangular) - if triangular and not upstream: - parser.error("Can't use triangular workflow without " - "an upstream repo") - url = repo['parent'][urltype] if triangular else repo[urltype] + if personal and triangular: + warnf('You are setting up with a personal repository as upstream, ' + 'forcing `--no-triangular` mode') + triangular = False + if triangular: + url = repo['parent'][urltype] + remote = args.upstreamremote + else: + url = repo[urltype] + remote = args.forkremote validate_url(url, urltype) - remote = args.upstreamremote if triangular else args.forkremote - # It's complicated to allow the user to use the --origin option, there - # is enough complexity with --upstreamremote and --forkremote, so ask - # the user to use those instead - for a in args.unknown_args: - if a in ('-o', '--origin') or a.startswith('-o', '--origin='): - die("Please use --forkremote or --upstreamremote to name your " - "remotes instead of using Git's `{}` option!", a) - # If we just forked the repo, GitHub might still be doing the actual - # fork, so cloning could fail temporarily. See - # https://github.com/sociomantic-tsunami/git-hub/issues/214 - cls.git_retry_if(not args.triangular and forked, - 'clone', - args.unknown_args + ['--origin', remote, '--', url, dest], - 'Cloning {} to {}'.format(url, dest)) - if not upstream: - # Not a forked repository, nothing else to do - return - # Complete the repository setup - os.chdir(dest) - fetchremote = args.forkremote if triangular else args.upstreamremote - remote_url = repo['parent'][urltype] + if dest is not None: + # Cloning to dest + # + # It's complicated to allow the user to use the --origin option, there + # is enough complexity with --upstreamremote and --forkremote, so ask + # the user to use those instead + for a in args.unknown_args: + if a in ('-o', '--origin') or a.startswith('-o', '--origin='): + die("Please use --forkremote or --upstreamremote to name your " + "remotes instead of using Git's `{}` option!", a) + # If we just forked the repo, GitHub might still be doing the actual + # fork, so cloning could fail temporarily. See + # https://github.com/sociomantic-tsunami/git-hub/issues/214 + cls.git_retry_if(not args.triangular and forked, + 'clone', + args.unknown_args + ['--origin', remote, '--', url, dest], + 'Cloning {} to {}'.format(url, dest)) + # Complete the repository setup + os.chdir(dest) + else: + # We are inside working directory of a pre-cloned repository, just + # setup a remote and fetch it + added=cls.git_add_remote(remote, url) + if added: + cls.git_retry_if(not args.triangular and forked, + 'fetch', ['--', remote], + 'Fetching from {} ({})'.format(remote, url)) + if triangular: + fetchremote = args.forkremote remote_url = repo[urltype] git_config('remote.pushdefault', prefix='', value=fetchremote) - git_config('upstreamremote', value=args.upstreamremote) - git_config('forkremote', value=args.forkremote) + else: + fetchremote = args.upstreamremote + remote_url = repo['parent'][urltype] if not personal else repo[urltype] + validate_url(remote_url, urltype) + added=cls.git_add_remote(fetchremote, remote_url) + if added: + # We also need to retry in here, although is less likely since we + # already spent some time doing the previous clone + cls.git_retry_if(args.triangular and forked, + 'fetch', ['--', fetchremote], + 'Fetching from {} ({})'.format(fetchremote, remote_url)) git_config('urltype', value=urltype) + git_config('forkremote', value=args.forkremote) + git_config('upstreamremote', value=args.upstreamremote) git_config('upstream', value=upstream) - validate_url(remote_url, urltype) - git('remote', 'add', '--', fetchremote, remote_url) - # We also need to retry in here, although is less likely since we - # already spent some time doing the previous clone - cls.git_retry_if(args.triangular and forked, - 'fetch', ['--', fetchremote], - 'Fetching from {} ({})'.format(fetchremote, remote_url)) - run_hookscript('postclone', env=dict( + git_config('triangular', value='true' if triangular else 'false') + run_hookscript('post-setup-fork', env=dict( fetchremote=fetchremote, triangular=triangular, )) + @classmethod + def git_add_remote(cls, remote, url): + git_remotes = git('remote', 'show', '-n').split('\n') + if remote in git_remotes: + git_url = git('remote', 'get-url', '--', remote) + + repo = cls.parse_repo(url) + git_repo = cls.parse_repo(git_url) + if repo[1] != git_repo[1]: + die("Remote {} already exists and is set to {} instead of {}", remote, git_url, url) + else: + infof("Nothing to do, remote {} already exists and is already set to {}", remote, git_url) + return False + else: + git('remote', 'add', '--', remote, url) + return True + @classmethod def git_retry_if(cls, condition, cmd, args, progress_msg): # If we are not retrying, just do it once @@ -924,18 +973,15 @@ class CloneCmd (object): return (urltype, proj) @classmethod - def setup_repo(cls, proj): + def maybe_fork(cls, proj): forked = False - # Own repo if proj.split('/')[0] == config.username: + # Our own repository repo = req.get('/repos/' + proj) if repo['fork']: upstream = repo['parent']['full_name'] else: upstream = None - warnf('Repository {} is not a fork, just ' - 'cloning, upstream will not be set', - repo['full_name']) else: upstream = proj # Try to fork, if a fork already exists, we'll get the @@ -944,9 +990,9 @@ class CloneCmd (object): # API docs, but it seems to work as of Sep 2016. # See https://github.com/sociomantic-tsunami/git-hub/pull/193 # for more details. - infof('Checking for existing fork / forking...') + infof('Checking for existing fork / forking {}...', upstream) repo = req.post('/repos/' + upstream + '/forks') - infof('Fork at {}', repo['html_url']) + infof('Fork exists / created at {}', repo['html_url']) forked = True return (repo, upstream, forked) @@ -974,6 +1020,48 @@ class CloneCmd (object): return False return True +# `git hub clone` command implementation +class CloneCmd (SetupForkCmd): + + cmd_name = "clone" + cmd_required_config = ['username', 'oauthtoken'] + cmd_help = 'clone a GitHub repository (and fork as needed)' + cmd_usage = '%(prog)s [OPTIONS] [GIT CLONE OPTIONS] REPO [DEST]' + + @classmethod + def setup_parser(cls, parser): + parser.add_argument('repository', metavar='REPO', + help="name of the repository to fork; in " + "/ format is the upstream repository, " + "if only is specified, the part is " + "taken from hub.username") + parser.add_argument('dest', metavar='DEST', nargs='?', + help="destination directory where to put the new " + "cloned repository") + parser.add_argument('-U', '--upstreamremote', metavar='NAME', + default=config.upstreamremote, + help="use NAME as the upstream remote repository name " + "instead of the default '{}'".format(config.upstreamremote)) + parser.add_argument('-F', '--forkremote', metavar='NAME', + default=config.forkremote, + help="use NAME as the fork remote repository name " + "instead of the default '{}'".format(config.forkremote)) + parser.add_argument('-t', '--triangular', action="store_true", + default=None, + help="use Git 'triangular workflow' setup, so you can " + "push by default to your fork but pull by default " + "from 'upstream'") + parser.add_argument('--no-triangular', action="store_false", + dest='triangular', + help="do not use Git 'triangular workflow' setup") + return True # we need to get unknown arguments + + @classmethod + def run(cls, parser, args): + (urltype, proj) = cls.parse_repo(args.repository) + (repo, upstream, forked) = cls.maybe_fork(proj) + dest = args.dest or repo['name'] + cls.setup_dest(parser, args, urltype, proj, repo, upstream, forked, dest) # Utility class that groups common functionality used by the multiple # `git hub issue` (and `git hub pull`) subcommands. @@ -2205,6 +2293,7 @@ class HubCmd (CmdGroup): cmd_title = "subcommands" cmd_help = "git command line interface to GitHub" SetupCmd = SetupCmd + SetupForkCmd = SetupForkCmd CloneCmd = CloneCmd IssueCmd = IssueCmd PullCmd = PullCmd diff --git a/man.rst b/man.rst index 90dd6c7..a4fb732 100644 --- a/man.rst +++ b/man.rst @@ -99,30 +99,58 @@ __ https://github.com/settings/tokens/new __ https://docs.github.com/en/free-pro-team@latest/github/authenticating-to-github/creating-a-personal-access-token `clone` REPO [DEST] - This command is used to clone **REPO**, a GitHub repository, to a **DEST** - directory (defaults to the name of the project being cloned). If the - repository is specified in */* form, the **REPO** will be - used as upstream and a personal fork will be looked up. If none is found, - a new fork will be created. In both cases, the fork will be cloned instead of - the upstream repository. The **REPO** can be specified as a regular *clone* - URL too (http, ssh, git), in that case the URL will be inspected and the - `hub.urltype` will be set as appropriate. + This command is used to fork **REPO** on GitHub (the same way `setup-fork` + command does), then clone the resulting fork to **DEST** directory (defaults + to the name of the project being cloned), and then setup the resulting local + copy (the same way `setup-fork` command does). + + This command has all the same options as `setup-fork` but any unknown options + are passed to **git clone** instead of causing errors. Not all of them might + make sense when cloning a GitHub repo to be used with this tool though. + + This command will run the `hub.hookscript` exactly as `setup-fork` command + does, see that command for more details. + +`setup-fork` [REPO] + This command is used to fork **REPO**, a GitHub repository, on GitHub and + setup the current working directory as the local copy of the resulting GitHub + fork. (That is, "set the current working directory up as a local copy of my + GitHub fork of **REPO**.) + + Normally, this command is used internally by the `clone` command, but you + might want to call it explicitly in case you have a local copy of **REPO** (or + some related repository) and now you want to fork **REPO** on GitHub and setup + the current working directory as its local copy. + + If **REPO** is not specified then the URL of `--upstreamremote` (or + `hub.upstreamremote`, `upstream` by default) remote will be used. + + If **REPO** is specified in */* or a regular *clone* URL + (http, ssh, git) form, the **REPO** will be used as upstream. + + In both cases, if a regular URL is available then the `hub.urltype` will be + set as appropriate. + + In both cases a personal fork will be looked up at GitHub. If none is found, a + new fork will be created. If only ** is specified as **REPO**, then the configuration - `hub.username` is used as **, and the parent repository is looked up - at GitHub to determine the real upstream repository. - - The upstream repository is cloned as `--upstreamremote` (or - `hub.upstreamremote`, `upstream` by default), the remote for the fork is - added as `--forkremote` (or `hub.forkremote`, `fork` by default) and the fork - is set as the git `remote.pushdefault` (so pushing will hit the fork by - default), unless `--no-triangular` is used (please see the option for more - details). - - After cloning and fetching, the git configuration variables `hub.upstream`, - `hub.upstreamremote` and `hub.forkremote` are set in the new cloned repo (see + `hub.username` is used as **, and the parent repository is looked up at + GitHub to determine the real upstream repository. + + The upstream repository is fetched as `--upstreamremote` (or + `hub.upstreamremote`, `upstream` by default), the remote for the fork is added + as `--forkremote` (or `hub.forkremote`, `fork` by default) and the fork is set + as the git `remote.pushdefault` (so pushing will hit the fork by default), + unless `--no-triangular` is used (please see the option for more details). + + After fetching, the git configuration variables `hub.upstream`, + `hub.upstreamremote` and `hub.forkremote` are set in the new repository (see CONFIGURATION_). + This command will run the `hub.hookscript` on some events, please have a look + at `HOOK SCRIPT`_ for more details. + \-U NAME, --upstreamremote=NAME Use `NAME` as the upstream remote repository name instead of the default 'upstream'). @@ -148,20 +176,15 @@ __ https://docs.github.com/en/free-pro-team@latest/github/authenticating-to-gith CONFIGURATION_ for details. \--no-triangular - Don't use Git's *triangular workflow* configuration (this is only available - for backwards compatibility but is not recommended). This option clones + Don't use Git's *triangular workflow* configuration. This option clones from the forked repository instead of cloning the upstream/parent repo, so both pulls and pushes will be done with the fork by default. - This option could be also used to clone a GitHub repository without forking - it, but some functionality of the tool will be lost. + For upstream+fork setups this is only available for backwards compatibility + and is not recommended. - GIT CLONE OPTIONS - Any standard **git clone** option can be passed. Not all of them might make - sense when cloning a GitHub repo to be used with this tool though. - - This command will run the `hub.hookscript` on some events, please have a look - at `HOOK SCRIPT`_ for more details. + This option is implied when you are setting up git-hub to work with a + personal GitHub repository (that is not a fork of any other). `issue` This command is used to manage GitHub issues through a set of subcommands. @@ -486,7 +509,7 @@ only so far. Available hooks (events): -`postclone` +`post-setup-fork` Executed after a `clone` command was done succesfully. The script will be run with the freshly cloned repository directory as the current working directory, so the git configuration just done by the `clone` command is @@ -508,7 +531,7 @@ Available hooks (events): the `fork` remote when it is updated, but only when *triangular* was used in the clone you can use: - `git config --global hub.hookscript 'if test "$HUB_HOOK" = postclone && + `git config --global hub.hookscript 'if test "$HUB_HOOK" = post-setup-fork && $HUB_TRIANGULAR ; then git config remote.fork.prune true; fi'` @@ -581,6 +604,88 @@ from. These are the git config keys used: [1] https://developer.github.com/v3/pulls/#get-a-single-pull-request +EXAMPLES +======== + +1. Fork a project on GitHub and clone a local copy linked to it in a single command:: + + git-hub clone https://github.com/sociomantic-tsunami/git-hub + cd git-hub + git remote show -n + #fork + #upstream + +2. Fork a project on GitHub and link the fork to a previously created local copy:: + + git clone --origin upstream https://github.com/sociomantic-tsunami/git-hub + cd git-hub + git-hub setup-fork + git remote show -n + #fork + #upstream + + alternatively:: + + git clone https://github.com/sociomantic-tsunami/git-hub + cd git-hub + git remote rename origin upstream + git-hub setup-fork + git remote show -n + #fork + #upstream + +3. The same, but with custom upstream remote name:: + + git clone https://github.com/sociomantic-tsunami/git-hub + cd git-hub + git-hub setup-fork -U origin + git remote show -n + #fork + #origin + +4. The same, but starting from a related repository that is neither upstream, nor fork:: + + git clone https://github.com//git-hub + cd git-hub + git-hub setup-fork https://github.com/sociomantic-tsunami/git-hub + git remote show -n + #fork + #origin + #upstream + +5. Similarly, but with your own personal project (no upstream):: + + git clone https://github.com// + cd + git-hub setup-fork --no-triangular -U origin -F origin + git remote show -n + #origin + +6. The same, but in a single command:: + + git-hub clone --no-triangular -U origin -F origin https://github.com// + cd + git remote show -n + #origin + +7. Look at some issues:: + + cd repo + git-hub issue show 1 2 3 5 + +8. Create a new issue:: + + cd repo + git-hub issue new + +7. Create a new PR:: + + cd repo + git checkout -b feature-branch + git status # did I commit everything? + git log -p upstream/master..HEAD # does it look good? + git-hub pull new + FILES =====