diff --git a/README.md b/README.md index aca019d..99ce949 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,24 @@ git river ========= -`git river workspace` will manage a "workspace" path you configure, cloning -and managing repositories from configured GitHub and GitLab groups. +`git-river` is a tool designed to make it easier to work with large +numbers of GitHub and GitLab projects and "forking" workflow that involve +pulling changes from "upstream" repositories and pushing to "downstream" +repositories. -Repositories will be organized by the domain and path of the remote GitHub -repository or GitLab project. +`git-river` will manage a "workspace" path you configure, cloning repositories +into that directory with a tree-style structure organised by domain, project +namespace, and project name. ``` $ tree ~/workspace ~/workspace ├── github.com │ └── datto -│ └── example +│ └── git-river └── gitlab.com └── datto - └── example + └── git-river ``` Links @@ -39,37 +42,53 @@ Usage Run `git-river `. Git's builtin aliasing also allows you to run `git river` instead. -```bash -git-river --help -``` +Before you can use `git-river` you must configure a workspace path by running +`git-river init PATH` or setting the `GIT_RIVER_WORKSPACE` environment variable. +This should point to a directory `git-river` can use to clone git repositories +into. + +Several commands will attempt to discover various names, and usually have an +option flag to override discovery. + +- The "upstream" remote is the first of `upstream` or `origin` that exists. Override with `--upstream`. +- The "downstream" remote is the first of `downstream` that exists. Override with `--downstream`. +- The "mainline" branch is the first of `main` or `master` that exists. Override with `--mainline`. + +### Subcommands -- `git river config` displays the current configuration. +- `git river clone URL...` clones a repository into the workspace path. -- `git river workspace` manages the workspace path. +- `git river config` manages the configuration file. - Run without any subcommand, it runs all workspace subcommands except `list` - and `fetch`. + - `git river config display` prints the loaded configuration as JSON. Credentials are redacted. + - `git river config init` creates an initial config file. + - `git river config workspace` prints the workspace path. - - `git river workspace clone` clones repositories. - - `git river workspace configure` sets git config options. - - `git river workspace fetch` fetches each git remote. - - `git river workspace list` displays remote repos that will be cloned. - - `git river workspace remotes` sets `upstream` and `origin` remotes. - - `git river workspace tidy` deletes merged branches. +- `git river forge` manages repositories listed by GitHub and GitLab. -- `git river repo` manages the repository in the current directory. + - `git river forge` runs the `clone` + `archived` + `configure` + `remotes` subcommands. + - `git river forge clone` clones repositories. + - `git river forge configure` sets git config options. + - `git river forge fetch` fetches each git remote. + - `git river forge list` displays remote repositories that will be cloned. + - `git river forge remotes` sets `upstream`+`downstream` or `origin` remotes. + - `git river forge tidy` deletes branches merged into the mainline branch. + - `git river forge archived` lists archived repositories that exist locally. - This mostly matches the features from the `workspace` subcommand. +- `git river` also provides some "loose" subcommands that work on the repository + in the current directory, mostly matching the features from the `forge` + subcommand. - - `git river repo configure` sets git config options. - - `git river repo fetch` fetches each git remote. - - `git river repo remotes` sets `upstream` and `origin` remotes. - - `git river repo tidy` deletes merged branches. + - `git river fetch` fetches all git remotes. + - `git river merge` creates the merge result of all `feature/*` branches. + - `git river tidy` deletes branches merged into the mainline branch. + - `git river restart` rebases the current branch from the upstream remotes mainline branch. Configuration ------------- -Configuration is a JSON object read from `~/.config/git-river/config.json`. +Configuration is a JSON object read from `~/.config/git-river/config.json`. Run +`git-river config init` to create an example configuration file. - `path` - path to a directory to use as the "workspace". - `forges` - a map of forges. diff --git a/git_river/cli.py b/git_river/cli.py index 28a7a5c..76d9d10 100644 --- a/git_river/cli.py +++ b/git_river/cli.py @@ -39,12 +39,8 @@ def main(ctx: click.Context) -> None: main.add_command(git_river.commands.clone.main) -main.add_command(git_river.commands.config.display_config) -main.add_command(git_river.commands.config.display_workspace) -main.add_command(git_river.commands.config.init_config) +main.add_command(git_river.commands.config.main) main.add_command(git_river.commands.forge.main) -main.add_command(git_river.commands.repo.configure_options) -main.add_command(git_river.commands.repo.configure_remotes) main.add_command(git_river.commands.repo.fetch_remotes) main.add_command(git_river.commands.repo.merge_feature_branches) main.add_command(git_river.commands.repo.tidy_branches) diff --git a/git_river/commands/clone.py b/git_river/commands/clone.py index b7e6ddc..0ea8d24 100644 --- a/git_river/commands/clone.py +++ b/git_river/commands/clone.py @@ -29,13 +29,13 @@ @click.command(name="clone") @click.argument( - "repositories", - metavar="REPOSITORY", + "urls", + metavar="URL...", type=click.STRING, nargs=-1, ) @click.pass_obj -def main(config: git_river.config.Config, repositories: typing.Sequence[str]) -> None: - """Clone a repository to the workspace path.""" - for url in repositories: +def main(config: git_river.config.Config, urls: typing.Sequence[str]) -> None: + """Clone repositories to the workspace path.""" + for url in urls: config.repository_from_url(url).clone(verbose=True) diff --git a/git_river/commands/config.py b/git_river/commands/config.py index 4d401b2..1428e19 100644 --- a/git_river/commands/config.py +++ b/git_river/commands/config.py @@ -27,14 +27,20 @@ import git_river.config -@click.command(name="config") +@click.group(name="config") +def main(): + """Manage git-river's own configuration file.""" + pass + + +@main.command(name="display") @click.pass_obj def display_config(config: git_river.config.Config) -> None: """Dump the current configuration as JSON.""" print(config.json(indent=2, by_alias=True)) -@click.command(name="init") +@main.command(name="init") @click.argument( "workspace", type=click.Path( @@ -46,6 +52,8 @@ def display_config(config: git_river.config.Config) -> None: ) @click.pass_obj def init_config(config: git_river.config.Config, workspace: str) -> None: + """Create the configuration file.""" + config.workspace = pathlib.Path(workspace) if git_river.config.CONFIG_PATH.exists(): @@ -57,7 +65,7 @@ def init_config(config: git_river.config.Config, workspace: str) -> None: git_river.config.CONFIG_PATH.write_text(config.json(indent=2, by_alias=True)) -@click.command(name="workspace") +@main.command(name="workspace") @click.pass_obj def display_workspace(config: git_river.config.Config) -> None: """Print the workspace path.""" diff --git a/git_river/commands/forge.py b/git_river/commands/forge.py index c2bec1d..d408866 100644 --- a/git_river/commands/forge.py +++ b/git_river/commands/forge.py @@ -69,6 +69,7 @@ def value_or_default(value: typing.Optional[T], default: T) -> T: "--forge", "select_forges", default=(), + metavar="NAME", multiple=True, help="Use repositories from specific forges.", ) @@ -76,9 +77,10 @@ def value_or_default(value: typing.Optional[T], default: T) -> T: "-g", "-o", "--group", - "--organization", + "--org", "select_groups", default=(), + metavar="NAME", multiple=True, help="Use repositories from specific groups.", ) @@ -87,6 +89,7 @@ def value_or_default(value: typing.Optional[T], default: T) -> T: "--user", "select_users", default=(), + metavar="NAME", multiple=True, help="Use repositories from specific users.", ) @@ -95,8 +98,9 @@ def value_or_default(value: typing.Optional[T], default: T) -> T: "--self", "select_self", default=None, + metavar="NAME", is_flag=True, - help="Use repositories from specific users.", + help="Use repositories from the authenticated user.", ) @click.pass_context def main( @@ -109,11 +113,11 @@ def main( """ Clone and manage repositories from GitLab and GitHub in bulk. - Selects from all forges unless '--forge' flags are passed. - - Selects from all repositories unless any '--group', '--user', or '--self' flags are passed. + Selects from all forges unless '--forge' flags are passed. Selects from all repositories unless + any '--group', '--user', or '--self' flags are passed. - Invokes the clone, configure, and remotes subcommands when no subcommand is given. + Invokes the 'clone', 'archived', 'configure', and 'remotes' subcommands when no subcommand is + given. """ config = ctx.ensure_object(git_river.config.Config) ctx.obj = RepositoryManager() @@ -146,14 +150,6 @@ def main( ctx.invoke(configure_remotes) -@main.command(name="archived") -@click.pass_obj -def archived_repositories(workspace: RepositoryManager) -> None: - for repo in workspace.existing(): - if repo.archived: - logger.warning("Local repository is archived", repo=repo.name) - - @main.command(name="clone") @click.pass_obj def clone_repositories(workspace: RepositoryManager) -> None: @@ -182,6 +178,15 @@ def clone_repositories(workspace: RepositoryManager) -> None: local_repo.bind(logger).info("Cloned repository") +@main.command(name="archived") +@click.pass_obj +def archived_repositories(workspace: RepositoryManager) -> None: + """Display a warning for archived repositories that exist locally.""" + for repo in workspace.existing(): + if repo.archived: + logger.warning("Local repository is archived", repo=repo.name) + + @main.command(name="configure") @click.pass_obj def configure_options(workspace: RepositoryManager) -> None: @@ -243,7 +248,9 @@ def tidy(workspace: RepositoryManager, dry_run: bool, target: typing.Optional[st """ logger.info("Removing merged branches") for repo in workspace.existing(): - repo.remove_merged_branches(target=repo.target_or_mainline_branch(target), dry_run=dry_run) + mainline = repo.discover_mainline_branch(target) + + repo.remove_merged_branches(target=mainline, dry_run=dry_run) @main.command(name="list") diff --git a/git_river/commands/repo.py b/git_river/commands/repo.py index 89e00e3..133d89a 100644 --- a/git_river/commands/repo.py +++ b/git_river/commands/repo.py @@ -52,7 +52,7 @@ "target", type=click.STRING, default=None, - help="Branch commits are merged into. Defaults to main or master if they exist.", + help="Branch commits are merged into. Defaults to the first of 'main' or 'master'.", ) click_upstream_remote_option = click.option( @@ -62,7 +62,7 @@ "upstream", type=click.STRING, default=None, - help="Branch to consider the upstream remote. Defaults to upstream.", + help="Remote to consider the \"upstream\" remote. Defaults to 'upstream' or 'origin'.", ) click_downstream_remote_option = click.option( @@ -73,24 +73,20 @@ type=click.STRING, default=None, help=( - "Branch to consider the downstream remote. " + 'Remote to consider the "downstream" remote. ' "Defaults to the first of 'downstream' or 'origin'." ), ) - -@click.command(name="configure") -@click_repo_option -def configure_options(path: pathlib.Path) -> None: - """Configure options.""" - git_river.repository.LocalRepository.from_path(path).configure_options() - - -@click.command(name="remotes") -@click_repo_option -def configure_remotes(path: pathlib.Path) -> None: - """Configure remotes.""" - git_river.repository.LocalRepository.from_path(path).configure_remotes() +click_mainline_option = click.option( + "-m", + "--mainline", + "--mainline-branch", + "mainline", + type=click.STRING, + default=None, + help="Branch to consider the \"mainline\" branch. Defaults to the first of 'main' or 'master'.", +) @click.command(name="fetch") @@ -102,7 +98,7 @@ def fetch_remotes(path: pathlib.Path) -> None: @click.command(name="merge") @click_repo_option -@click_target_option +@click_mainline_option @click.option( "-m", "--merge", @@ -111,14 +107,17 @@ def fetch_remotes(path: pathlib.Path) -> None: default="merged", help="Branch that will contain the merged result.", ) -def merge_feature_branches(path: pathlib.Path, target: typing.Optional[str], merge: str) -> None: +def merge_feature_branches(path: pathlib.Path, mainline: typing.Optional[str], merge: str) -> None: """ Merge feature branches into a new 'merged' branch. By default, merges all branches prefixed 'feature/' into a branch named 'merged'. """ repo = git_river.repository.LocalRepository.from_path(path) - repo.merge_feature_branches(target=repo.target_or_mainline_branch(target), merge=merge) + + mainline = repo.discover_mainline_branch(mainline) + + repo.merge_feature_branches(target=mainline, merge=merge) @click.command(name="tidy") @@ -129,33 +128,39 @@ def merge_feature_branches(path: pathlib.Path, target: typing.Optional[str], mer type=click.BOOL, default=False, ) -@click_target_option +@click_mainline_option @click_repo_option -def tidy_branches(path: pathlib.Path, dry_run: bool, target: typing.Optional[str]) -> None: +def tidy_branches(path: pathlib.Path, dry_run: bool, mainline: typing.Optional[str]) -> None: """ - Remove branches that have been merged into a target branch. + Remove branches that have been merged into a mainline branch. If --branch is not set, uses the repositories configured default branch (for repositories discovered from a remote API), or the first branch found from 'main' and 'master'. """ repo = git_river.repository.LocalRepository.from_path(path) + + mainline = repo.discover_mainline_branch(mainline) + repo.fetch_remotes(prune=True) - repo.remove_merged_branches(repo.target_or_mainline_branch(target), dry_run=dry_run) + repo.remove_merged_branches(mainline, dry_run=dry_run) @click.command(name="restart") @click_repo_option @click_upstream_remote_option +@click_mainline_option def restart( path: pathlib.Path, upstream: typing.Optional[str], + mainline: typing.Optional[str], ) -> None: """ Rebase the currently checked out branch using the upstream mainline branch. """ repo = git_river.repository.LocalRepository.from_path(path) - upstream = repo.discover_remote(upstream, "upstream") - mainline = repo.discover_mainline_branch() + + upstream = repo.discover_upstream_remote(upstream) + mainline = repo.discover_mainline_branch(mainline) repo.fetch_branch_from_remote(mainline, remote=upstream) repo.rebase(f"{upstream}/{mainline}") @@ -177,18 +182,19 @@ def end( This is mostly useful when you're on a feature branch that has been merged into an upstream branch via a GitLab merge request or GitHub pull request. - - Updates the default branch from the 'upstream' remote. - - Switches to the default branch. + - Updates the mainline branch from the upstream remote. + - Switches to the mainline branch. - Removes any branches that have been merged into the default branch. - - Prunes local references to remote branches. + - Fetch all remotes and prunes local references to remote branches. + - Pushes the mainline branch to the downstream remote. """ repo = git_river.repository.LocalRepository.from_path(path) - upstream = repo.discover_remote(upstream, "upstream") - downstream = repo.discover_remote(downstream, "downstream", "origin") - branch = repo.discover_mainline_branch() + upstream = repo.discover_upstream_remote(upstream) + downstream = repo.discover_downstream_remote(downstream) + mainline = repo.discover_mainline_branch() - repo.fetch_branch_from_remote(branch, remote=upstream) - repo.switch_to_branch(branch) - repo.remove_merged_branches(branch, dry_run=False) + repo.fetch_branch_from_remote(mainline, remote=upstream) + repo.switch_to_branch(mainline) + repo.remove_merged_branches(mainline, dry_run=False) repo.fetch_remotes(prune=True) - repo.push_to_remote(branch, remote=downstream) + repo.push_to_remote(mainline, remote=downstream) diff --git a/git_river/repository.py b/git_river/repository.py index 5fefd73..e99e2ca 100644 --- a/git_river/repository.py +++ b/git_river/repository.py @@ -313,47 +313,59 @@ def rebase(self, branch: str) -> None: self.bind(logger).info("Rebasing", branch=branch) self.repo.git.rebase(branch) - def target_or_mainline_branch(self, target: typing.Optional[str]) -> str: - """Return the default branch if the input is None, else the input.""" - - if target is None: - return self.discover_mainline_branch() + def discover_branch(self, *names: str) -> str: + for name in names: + try: + return self._head(name) + except IndexError: + pass - if target not in self.repo.heads: - raise Exception(f"Branch {target} does not exist") + raise ValueError(f"No heads found for {names!r}") - return target + def discover_mainline_branch(self, override: typing.Optional[str] = None) -> str: + if override is not None: + return self._head(override) - def discover_mainline_branch(self) -> str: - if self.default_branch is None: - return self.discover_branch("main", "master") + if self.default_branch is not None: + return self.default_branch - return self.default_branch + return self.discover_branch("main", "master") - def discover_branch(self, *names: typing.Optional[str]) -> str: + def discover_remote(self, *names: str) -> str: for name in names: - if name is None: - continue - try: - remote = self.repo.heads[name] - except IndexError: - logger.debug("No branch found", name=name) - else: - return remote.name + return self._remote(name) + except ValueError: + pass - raise ValueError(f"No heads found for {names=}") + raise ValueError(f"No remotes found for {names!r}") - def discover_remote(self, *names: typing.Optional[str]) -> str: - for name in names: - if name is None: - continue + def discover_upstream_remote(self, override: typing.Optional[str] = None) -> str: + if override is not None: + return self._remote(override) - try: - remote = self.repo.remote(name) - except ValueError: - logger.debug("No remote found", name=name) - else: - return remote.name + return self.discover_remote("upstream", "origin") + + def discover_downstream_remote(self, override: typing.Optional[str] = None) -> str: + if override is not None: + return self._remote(override) - raise ValueError(f"No remotes found for {names=}") + return self.discover_remote("downstream") + + def _head(self, name: str) -> str: + """Check a head exists.""" + try: + head = self.repo.heads[name] + except ValueError as error: + logger.debug("No head found", name=name) + raise error + return head.name + + def _remote(self, name: str) -> str: + """Check a remote exists.""" + try: + remote = self.repo.remote(name) + except ValueError as error: + logger.debug("No remote found", name=name) + raise error + return remote.name diff --git a/pyproject.toml b/pyproject.toml index e91808f..4e84929 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "git-river" -version = "1.1.0" +version = "1.2.0" description = "Tools for working with upstream repositories" authors = ["Sam Clements "] license = "MPL-2.0"