38 Commits
v0.1 ... v0.3

Author SHA1 Message Date
102758c25c Release v0.3 2021-11-26 17:22:09 +01:00
6aa385b044 Merge branch 'develop' 2021-11-26 17:21:48 +01:00
e44b63edbb Remove duplicate docs between README and docs 2021-11-26 17:21:37 +01:00
1e6c9407b6 Do not remove worktree for default branch 2021-11-26 17:21:37 +01:00
b967b6dca3 Set git config properly for worktrees on init/clone
Close #1
2021-11-26 17:21:37 +01:00
83973f8a1a Fix unnecessary to_string() 2021-11-26 17:21:37 +01:00
ff32759058 Add subcommand that converts existing repository
Close #6
2021-11-26 17:21:37 +01:00
b6c06e29a4 Release v0.2 2021-11-24 19:50:45 +01:00
6bec0eda69 docs: Fix repos command 2021-11-24 19:42:33 +01:00
7541a74fa4 Update dependency locks 2021-11-24 19:37:35 +01:00
4ad4a55631 Use single quotes for remote/branch separator 2021-11-24 19:37:11 +01:00
e2db935c74 Add script to update cargo dependencies 2021-11-24 19:22:18 +01:00
99c4f33e28 cargo: Specify patch versions for dependencies 2021-11-24 19:22:18 +01:00
f50fc9aee2 Add github action to build docs 2021-11-24 19:22:18 +01:00
1cf5585e2c Add documentation 2021-11-24 19:22:18 +01:00
0c6a4a72ef Move repo-tree functionality into own subcommand 2021-11-24 19:22:18 +01:00
e516a652f5 Fix typo 2021-11-24 17:22:10 +01:00
ddce614009 Fix repo change detection 2021-11-24 17:22:10 +01:00
d677c2d41b Add "install" target to Justfile 2021-11-24 17:22:10 +01:00
3aecee3549 Set up tracking branches if required 2021-11-24 17:22:10 +01:00
667ea87c39 Do not pass as value needlessly 2021-11-24 17:22:10 +01:00
3e18caf719 Use "namespace" instead of "prefix" for branches 2021-11-24 17:22:10 +01:00
711d9131da Expand the worktree functionality 2021-11-22 21:19:12 +01:00
8ba214d6cf Fix setting for root if there is a root-level repo 2021-11-22 21:19:10 +01:00
77c00cee5f Do not output anything when no repos found 2021-11-22 21:19:10 +01:00
12cb18c528 Fix output for ahead/behind branches 2021-11-22 21:19:02 +01:00
6b80a0f2d5 Add test target to Justfile 2021-11-22 21:13:21 +01:00
06e7d68089 Add check target to Justfile 2021-11-22 21:13:21 +01:00
db0f91f32f Add cargo fmt to lint check 2021-11-22 21:11:41 +01:00
fa40f4d6aa Add some unit tests for path expansion 2021-11-22 21:11:41 +01:00
78a957268d Add a few simple integration tests 2021-11-22 21:11:41 +01:00
ca1f649ecf Linting & formatting 2021-11-22 21:11:31 +01:00
bbedc9d8a8 Fix debug output 2021-11-21 21:13:28 +01:00
09f22edf49 Add commands to manage worktrees 2021-11-21 17:10:30 +01:00
b0746c95b5 Report on untracked files 2021-11-21 17:10:30 +01:00
153d09f3ef Add functionality to check out worktree-ready repos 2021-11-21 17:10:25 +01:00
74a7772a29 Fix wrong report when ignored files exist 2021-11-20 18:04:02 +01:00
5df6dcb053 Shorten report for unchanged repo 2021-11-20 18:03:40 +01:00
21 changed files with 1713 additions and 312 deletions

29
.github/workflows/gh-pages.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: github pages
on:
push:
branches:
- master
pull_request:
jobs:
deploy:
runs-on: ubuntu-20.04
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
steps:
- uses: actions/checkout@v2
- name: Setup mdBook
uses: peaceiris/actions-mdbook@v1
with:
mdbook-version: 'latest'
- run: cd ./docs && mdbook build
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
if: ${{ github.ref == 'refs/heads/master' }}
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./docs/book

81
Cargo.lock generated
View File

@@ -169,6 +169,12 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "fuchsia-cprng"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba"
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.3" version = "0.2.3"
@@ -182,7 +188,7 @@ dependencies = [
[[package]] [[package]]
name = "git-repo-manager" name = "git-repo-manager"
version = "0.1.0" version = "0.3.0"
dependencies = [ dependencies = [
"clap", "clap",
"comfy-table", "comfy-table",
@@ -191,14 +197,15 @@ dependencies = [
"regex", "regex",
"serde", "serde",
"shellexpand", "shellexpand",
"tempdir",
"toml", "toml",
] ]
[[package]] [[package]]
name = "git2" name = "git2"
version = "0.13.23" version = "0.13.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a8057932925d3a9d9e4434ea016570d37420ddb1ceed45a174d577f24ed6700" checksum = "845e007a28f1fcac035715988a234e8ec5458fd825b20a20c7dec74237ef341f"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"libc", "libc",
@@ -280,15 +287,15 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.107" version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbe5e23404da5b4f555ef85ebed98fb4083e55a00c317800bc2a50ede9f3d219" checksum = "8521a1b57e76b1ec69af7599e75e38e7b7fad6610f037db8c79b127201b5d119"
[[package]] [[package]]
name = "libgit2-sys" name = "libgit2-sys"
version = "0.12.24+1.3.0" version = "0.12.25+1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddbd6021eef06fb289a8f54b3c2acfdd85ff2a585dfbb24b8576325373d2152c" checksum = "8f68169ef08d6519b2fe133ecc637408d933c0174b23b80bb2f79828966fbaab"
dependencies = [ dependencies = [
"cc", "cc",
"libc", "libc",
@@ -399,9 +406,9 @@ checksum = "28988d872ab76095a6e6ac88d99b54fd267702734fd7ffe610ca27f533ddb95a"
[[package]] [[package]]
name = "openssl-sys" name = "openssl-sys"
version = "0.9.70" version = "0.9.71"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6517987b3f8226b5da3661dad65ff7f300cc59fb5ea8333ca191fc65fde3edf" checksum = "7df13d165e607909b363a4757a6f133f8a818a74e9d3a98d09c6128e15fa4c73"
dependencies = [ dependencies = [
"autocfg", "autocfg",
"cc", "cc",
@@ -498,6 +505,43 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "rand"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "552840b97013b1a26992c11eac34bdd778e464601a4c2054b5f0bff7c6761293"
dependencies = [
"fuchsia-cprng",
"libc",
"rand_core 0.3.1",
"rdrand",
"winapi",
]
[[package]]
name = "rand_core"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b"
dependencies = [
"rand_core 0.4.2",
]
[[package]]
name = "rand_core"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc"
[[package]]
name = "rdrand"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2"
dependencies = [
"rand_core 0.3.1",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.2.10" version = "0.2.10"
@@ -534,6 +578,15 @@ version = "0.6.25"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b"
[[package]]
name = "remove_dir_all"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7"
dependencies = [
"winapi",
]
[[package]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.1.0" version = "1.1.0"
@@ -640,6 +693,16 @@ dependencies = [
"unicode-xid", "unicode-xid",
] ]
[[package]]
name = "tempdir"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15f2b5fb00ccdf689e0149d1b1b3c03fead81c2b37735d812fa8bddbbf41b6d8"
dependencies = [
"rand",
"remove_dir_all",
]
[[package]] [[package]]
name = "termcolor" name = "termcolor"
version = "1.1.2" version = "1.1.2"

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "git-repo-manager" name = "git-repo-manager"
version = "0.1.0" version = "0.3.0"
edition = "2021" edition = "2021"
authors = [ authors = [
"Hannes Körber <hannes@hkoerber.de>", "Hannes Körber <hannes@hkoerber.de>",
@@ -37,17 +37,17 @@ path = "src/main.rs"
[dependencies] [dependencies]
[dependencies.toml] [dependencies.toml]
version = "0.5" version = "0.5.8"
[dependencies.serde] [dependencies.serde]
version = "1.0" version = "1.0.130"
features = ["derive"] features = ["derive"]
[dependencies.git2] [dependencies.git2]
version = "0.13" version = "0.13.24"
[dependencies.shellexpand] [dependencies.shellexpand]
version = "2.1" version = "2.1.0"
[dependencies.clap] [dependencies.clap]
version = "3.0.0-beta.5" version = "3.0.0-beta.5"
@@ -56,7 +56,10 @@ version = "3.0.0-beta.5"
version = "0.15.0" version = "0.15.0"
[dependencies.regex] [dependencies.regex]
version = "1.5" version = "1.5.4"
[dependencies.comfy-table] [dependencies.comfy-table]
version = "5.0" version = "5.0.0"
[dev-dependencies.tempdir]
version = "0.3.7"

View File

@@ -1,4 +1,6 @@
lint: check:
cargo check
cargo fmt --check
cargo clippy --no-deps cargo clippy --no-deps
lint-fix: lint-fix:
@@ -6,3 +8,16 @@ lint-fix:
release: release:
cargo build --release cargo build --release
install:
cargo install --path .
test:
cargo test --lib --bins
update-dependencies:
@cd ./depcheck \
&& python3 -m venv ./venv \
&& . ./venv/bin/activate \
&& pip --disable-pip-version-check install -r ./requirements.txt > /dev/null \
&& ./update-cargo-dependencies.py

View File

@@ -3,81 +3,8 @@
GRM helps you manage git repositories in a declarative way. Configure your GRM helps you manage git repositories in a declarative way. Configure your
repositories in a [TOML](https://toml.io/) file, GRM does the rest. repositories in a [TOML](https://toml.io/) file, GRM does the rest.
## Quickstart **Take a look at the [official documentation](https://hakoerber.github.io/git-repo-manager/)
for installation & quickstart.**
See [the example configuration](example.config.toml) to get a feel for the way
you configure your repositories.
### Install
```bash
$ cargo install --git https://github.com/hakoerber/git-repo-manager.git --branch master
```
### Get the example configuration
```bash
$ curl --proto '=https' --tlsv1.2 -sSfO https://raw.githubusercontent.com/hakoerber/git-repo-manager/master/example.config.toml
```
### Run it!
```bash
$ grm sync --config example.config.toml
[] Cloning into "/home/me/projects/git-repo-manager" from "https://code.hkoerber.de/hannes/git-repo-manager.git"
[] git-repo-manager: Repository successfully cloned
[] git-repo-manager: Setting up new remote "github" to "https://github.com/hakoerber/git-repo-manager.git"
[] git-repo-manager: OK
[] Cloning into "/home/me/projects/dotfiles" from "https://github.com/hakoerber/dotfiles.git"
[] dotfiles: Repository successfully cloned
[] dotfiles: OK
```
If you run it again, it will report no changes:
```
$ grm sync --config example.config.toml
[✔] git-repo-manager: OK
[✔] dotfiles: OK
```
### Generate your own configuration
Now, if you already have a few repositories, it would be quite laborious to write
a configuration from scratch. Luckily, GRM has a way to generate a configuration
from an existing file tree:
```bash
$ grm find ~/your/project/root > config.toml
```
This will detect all repositories and remotes and write them to `config.toml`.
### Show the state of your projects
```bash
$ grm status --config example.config.toml
+------------------+------------+----------------------------------+--------+---------+
| Repo | Status | Branches | HEAD | Remotes |
+=====================================================================================+
| git-repo-manager | | branch: master <origin/master> ✔ | master | github |
| | | | | origin |
|------------------+------------+----------------------------------+--------+---------|
| dotfiles | No changes | branch: master <origin/master> ✔ | master | origin |
+------------------+------------+----------------------------------+--------+---------+
```
You can also use `status` without `--config` to check the current directory:
```
$ cd ./dotfiles
$ grm status
+----------+------------+----------------------------------+--------+---------+
| Repo | Status | Branches | HEAD | Remotes |
+=============================================================================+
| dotfiles | No changes | branch: master <origin/master> ✔ | master | origin |
+----------+------------+----------------------------------+--------+---------+
```
# Why? # Why?
@@ -117,10 +44,6 @@ repositories itself.
* Support multiple file formats (YAML, JSON). * Support multiple file formats (YAML, JSON).
* Add systemd timer unit to run regular syncs * Add systemd timer unit to run regular syncs
# Dev Notes
It requires nightly features due to the usage of [`std::path::Path::is_symlink()`](https://doc.rust-lang.org/std/fs/struct.FileType.html#method.is_symlink). See the [tracking issue](https://github.com/rust-lang/rust/issues/85748).
# Crates # Crates
* [`toml`](https://docs.rs/toml/) for the configuration file * [`toml`](https://docs.rs/toml/) for the configuration file

2
depcheck/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/crates.io-index/
/venv/

View File

@@ -0,0 +1,2 @@
semver==2.13.0
tomlkit==0.7.2

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env python3
import subprocess
import os
import json
import sys
import semver
import tomlkit
INDEX_DIR = "crates.io-index"
if os.path.exists(INDEX_DIR):
subprocess.run(
["git", "pull", "--depth=1", "origin"],
cwd=INDEX_DIR,
check=True,
capture_output=True,
)
else:
subprocess.run(
["git", "clone", "--depth=1", "https://github.com/rust-lang/crates.io-index"],
check=True,
capture_output=False, # to get some git output
)
with open("../Cargo.toml", "r") as cargo_config:
cargo = tomlkit.parse(cargo_config.read())
update_necessary = False
for tier in ["dependencies", "dev-dependencies"]:
for name, dependency in cargo[tier].items():
version = dependency["version"]
if len(name) >= 4:
info_file = f"{INDEX_DIR}/{name[0:2]}/{name[2:4]}/{name}"
elif len(name) == 3:
info_file = f"{INDEX_DIR}/3/{name[0]}/{name}"
elif len(name) == 2:
info_file = f"{INDEX_DIR}/2/{name}"
elif len(name) == 1:
info_file = f"{INDEX_DIR}/1/{name}"
current_version = semver.VersionInfo.parse(version)
latest_version = None
for version_entry in open(info_file, "r").readlines():
version = semver.VersionInfo.parse(json.loads(version_entry)["vers"])
if current_version.prerelease == "" and version.prerelease != "":
# skip prereleases, except when we are on a prerelease already
continue
if latest_version is None or version > latest_version:
latest_version = version
if latest_version != current_version:
update_necessary = True
if latest_version < current_version:
print(
f"{name}: Your current version is newer than the newest version on crates.io, the hell?"
)
else:
print(
f"{name}: New version found: {latest_version} (current {current_version})"
)
cargo[tier][name]["version"] = str(latest_version)
if update_necessary is True:
with open("../Cargo.toml", "w") as cargo_config:
cargo_config.write(tomlkit.dumps(cargo))
sys.exit(1)
else:
print("Everything up to date")
sys.exit(0)

1
docs/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
book

9
docs/book.toml Normal file
View File

@@ -0,0 +1,9 @@
[book]
authors = ["Hannes Körber"]
language = "en"
multilingual = false
src = "src"
title = "Git Repo Manager"
[output.html]
mathjax-support = true

7
docs/src/SUMMARY.md Normal file
View File

@@ -0,0 +1,7 @@
# Summary
- [Overview](./overview.md)
- [Getting started](./getting_started.md)
- [Repository trees](./repos.md)
- [Git Worktrees](./worktrees.md)
- [FAQ](./faq.md)

10
docs/src/faq.md Normal file
View File

@@ -0,0 +1,10 @@
# FAQ
## Why is the nightly toolchain required?
Building GRM currently requires nightly features due to the usage of
[`std::path::Path::is_symlink()`](https://doc.rust-lang.org/std/fs/struct.FileType.html#method.is_symlink).
See the [tracking issue](https://github.com/rust-lang/rust/issues/85748).
`is_symlink()` is actually available in rustc 1.57, so it will be on stable in
the near future. This would mean that GRM can be built using the stable toolchain!

View File

@@ -0,0 +1,22 @@
# Quickstart
## Installation
Building GRM currently requires the nightly Rust toolchain. The easiest way
is using [`rustup`](https://rustup.rs/). Make sure that rustup is properly installed.
Make sure that the nightly toolchain is installed:
```
$ rustup toolchain install nightly
```
```bash
$ cargo +nightly install --git https://github.com/hakoerber/git-repo-manager.git --branch master
```
If you're brave, you can also run the development build:
```bash
$ cargo +nightly install --git https://github.com/hakoerber/git-repo-manager.git --branch develop
```

33
docs/src/overview.md Normal file
View File

@@ -0,0 +1,33 @@
# Overview
Welcome! This is the documentation for [Git Repo
Manager](https://github.com/hakoerber/git-repo-manager/) (GRM for short), a
tool that helps you manage git repositories.
GRM helps you manage git repositories in a declarative way. Configure your
repositories in a TOML file, GRM does the rest. Take a look at [the example
configuration](https://github.com/hakoerber/git-repo-manager/blob/master/example.config.toml)
to get a feel for the way you configure your repositories. See the [repository
tree chapter](./repos.md) for details.
GRM also provides some tooling to work with single git repositories using
`git-worktree`. See [the worktree chapter](./worktree.md) for more details.
## Why use GRM?
If you're working with a lot of git repositories, GRM can help you to manage them
in an easy way:
* You want to easily clone many repositories to a new machine.
* You want to change remotes for multiple repositories (e.g. because your GitLab
domain changed).
* You want to get an overview over all repositories you have, and check whether
you forgot to commit or push something.
If you want to work with [git worktrees](https://git-scm.com/docs/git-worktree)
in a streamlined, easy way, GRM provides you with an opinionated workflow. It's
especially helpful when the following describes you:
* You're juggling a lot of git branches, switching between them a lot.
* When switching branches, you'd like to just leave your work as-is, without
using the stash or temporary commits.

76
docs/src/repos.md Normal file
View File

@@ -0,0 +1,76 @@
# Managing tree of git repositories
When managing multiple git repositories with GRM, you'll generally have a
configuration file containing information about all the repos you have. GRM then
makes sure that you repositories match that config. If they don't exist yet, it
will clone them. It will also make sure that all remotes are configured properly.
Let's try it out:
## Get the example configuration
```bash
$ curl --proto '=https' --tlsv1.2 -sSfO https://raw.githubusercontent.com/hakoerber/git-repo-manager/master/example.config.toml
```
Then, you're ready to run the first sync. This will clone all configured repositories
and set up the remotes.
```bash
$ grm repos sync --config example.config.toml
[] Cloning into "/home/me/projects/git-repo-manager" from "https://code.hkoerber.de/hannes/git-repo-manager.git"
[] git-repo-manager: Repository successfully cloned
[] git-repo-manager: Setting up new remote "github" to "https://github.com/hakoerber/git-repo-manager.git"
[] git-repo-manager: OK
[] Cloning into "/home/me/projects/dotfiles" from "https://github.com/hakoerber/dotfiles.git"
[] dotfiles: Repository successfully cloned
[] dotfiles: OK
```
If you run it again, it will report no changes:
```
$ grm repos sync --config example.config.toml
[✔] git-repo-manager: OK
[✔] dotfiles: OK
```
### Generate your own configuration
Now, if you already have a few repositories, it would be quite laborious to write
a configuration from scratch. Luckily, GRM has a way to generate a configuration
from an existing file tree:
```bash
$ grm repos find ~/your/project/root > config.toml
```
This will detect all repositories and remotes and write them to `config.toml`.
### Show the state of your projects
```bash
$ grm repos status --config example.config.toml
╭──────────────────┬──────────┬────────┬───────────────────┬────────┬─────────╮
│ Repo ┆ Worktree ┆ Status ┆ Branches ┆ HEAD ┆ Remotes │
╞══════════════════╪══════════╪════════╪═══════════════════╪════════╪═════════╡
│ git-repo-manager ┆ ┆ ✔ ┆ branch: master ┆ master ┆ github │
│ ┆ ┆ ┆ <origin/master> ✔ ┆ ┆ origin │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤
│ dotfiles ┆ ┆ ✔ ┆ ┆ Empty ┆ origin │
╰──────────────────┴──────────┴────────┴───────────────────┴────────┴─────────╯
```
You can also use `status` without `--config` to check the repository you're currently
in:
```
$ cd ~/example-projects/dotfiles
$ grm repos status
╭──────────┬──────────┬────────┬──────────┬───────┬─────────╮
│ Repo ┆ Worktree ┆ Status ┆ Branches ┆ HEAD ┆ Remotes │
╞══════════╪══════════╪════════╪══════════╪═══════╪═════════╡
│ dotfiles ┆ ┆ ✔ ┆ ┆ Empty ┆ origin │
╰──────────┴──────────┴────────┴──────────┴───────┴─────────╯
```

227
docs/src/worktrees.md Normal file
View File

@@ -0,0 +1,227 @@
# Git Worktrees
## Why?
The default workflow when using git is having your repository in a single directory.
Then, you can check out a certain reference (usually a branch), which will update
the files in the directory to match the state of that reference. Most of the time,
this is exactly what you need and works perfectly. But especially when you're using
with branches a lot, you may notice that there is a lot of work required to make
everything run smootly.
Maybe you experienced the following: You're working on a feature branch. Then,
for some reason, you have to change branches (maybe to investigate some issue).
But you get the following:
```
error: Your local changes to the following files would be overwritten by checkout
```
Now you can create a temporary commit or stash your changes. In any case, you have
some mental overhead before you can work on something else. Especially with stashes,
you'll have to remember to do a `git stash pop` before resuming your work (I
cannot count the number of times where is "rediscovered" some code hidden in some
old stash I forgot about.
And even worse: If you're currently in the process of resolving merge conflicts or an
interactive rebase, there is just no way to "pause" this work to check out a
different branch.
Sometimes, it's crucial to have an unchanging state of your repository until some
long-running process finishes. I'm thinking of Ansible and Terraform runs. I'd
rather not change to a different branch while ansible or Terraform are running as
I have no idea how those tools would behave (and I'm not too eager to find out).
In any case, Git Worktrees are here for the rescue:
## What are git worktrees?
[Git Worktrees](https://git-scm.com/docs/git-worktree) allow you to have multiple
independent checkouts of your repository on different directories. You can have
multiple directories that correspond to different references in your repository.
Each worktree has it's independent working tree (duh) and index, so there is no
to run into conflicts. Changing to a different branch is just a `cd` away (if
the worktree is already set up).
## Worktrees in GRM
GRM exposes an opinionated way to use worktrees in your repositories. Opinionated,
because there is a single invariant that makes reasoning about your worktree
setup quite easy:
**The branch inside the worktree is always the same as the directory name of the worktree.**
In other words: If you're checking out branch `mybranch` into a new worktree, the
worktree directory will be named `mybranch`.
GRM can be used with both "normal" and worktree-enabled repositories. But note
that a single repository can be either the former or the latter. You'll have to
decide during the initial setup which way you want to go for that repository.
If you want to clone your repository in a worktree-enabled way, specify
`worktree_setup = true` for the repository in your `config.toml`:
```toml
[[trees.repos]]
name = "git-repo-manager"
worktree_setup = true
```
Now, when you run a `grm sync`, you'll notice that the directory of the repository
is empty! Well, not totally, there is a hidden directory called `.git-main-working-tree`.
This is where the repository actually "lives" (it's a bare checkout).
### Creating a new worktree
To actually work, you'll first have to create a new worktree checkout. All
worktree-related commands are available as subcommands of `grm worktree` (or
`grm wt` for short):
```
$ grm wt add mybranch
[✔] Worktree mybranch created
```
You'll see that there is now a directory called `mybranch` that contains a checkout
of your repository, using the branch `mybranch`
```bash
$ cd ./mybranch && git status
On branch mybranch
nothing to commit, working tree clean
```
You can work in this repository as usual. Make changes, commit them, revert them,
whatever you're up to :)
Just note that you *should* not change the branch inside the worktree
directory. There is nothing preventing you from doing so, but you will notice
that you'll run into problems when trying to remove a worktree (more on that
later). It may also lead to confusing behaviour, as there can be no two
worktrees that have the same branch checked out. So if you decide to use the
worktree setup, go all in, let `grm` manage your branches and bury `git branch`
(and `git checkout -b`).
You will notice that there is no tracking branch set up for the new branch. You
can of course set up one manually after creating the worktree, but there is an
easier way, using the `--track` flag during creation. Let's create another
worktree. Go back to the root of the repository, and run:
```bash
$ grm wt add mybranch2 --track origin/mybranch2
[] Worktree mybranch2 created
```
You'll see that this branch is now tracking `mybranch` on the `origin` remote:
```bash
$ cd ./mybranch2 && git status
On branch mybranch
Your branch is up to date with 'origin/mybranch2'.
nothing to commit, working tree clean
```
The behaviour of `--track` differs depending on the existence of the remote branch:
* If the remote branch already exists, `grm` uses it as the base of the new
local branch.
* If the remote branch does not exist (as in our example), `grm` will create a
new remote tracking branch, using the default branch (either `main` or `master`)
as the base
### Showing the status of your worktrees
There is a handy little command that will show your an overview over all worktrees
in a repository, including their status (i.e. changes files). Just run the following
in the root of your repository:
```
$ grm wt status
╭───────────┬────────┬──────────┬──────────────────╮
│ Worktree ┆ Status ┆ Branch ┆ Remote branch │
╞═══════════╪════════╪══════════╪══════════════════╡
│ mybranch ┆ ✔ ┆ mybranch ┆ │
│ mybranch2 ┆ ✔ ┆ mybranch ┆ origin/mybranch2 │
╰───────────┴────────┴──────────┴──────────────────╯
```
The "Status" column would show any uncommitted changes (new / modified / deleted
files) and the "Remote branch" would show differences to the remote branch (e.g.
if there are new pushes to the remote branch that are not yet incorporated into
your local branch).
### Deleting worktrees
If you're done with your worktrees, use `grm wt delete` to delete them. Let's
start with `mybranch2`:
```
$ grm wt delete mybranch2
[✔] Worktree mybranch2 deleted
```
Easy. On to `mybranch`:
```
$ grm wt delete mybranch
[!] Changes in worktree: No remote tracking branch for branch mybranch found. Refusing to delete
```
Hmmm. `grm` tells you:
"Hey, there is no remote branch that you could have pushed
your changes to. I'd rather not delete work that you cannot recover."
Note that `grm` is very cautious here. As your repository will not be deleted,
you could still recover the commits via [`git-reflog`](https://git-scm.com/docs/git-reflog).
But better safe then sorry! Note that you'd get a similar error message if your
worktree had any uncommitted files, for the same reason. Now you can either
commit & push your changes, or your tell `grm` that you know what you're doing:
```
$ grm wt delete mybranch --force
[✔] Worktree mybranch deleted
```
If you just want to delete all worktrees that do not contain any changes, you
can also use the following:
```
$ grm wt clean
```
Note that this will not delete the default branch of the repository. It can of
course still be delete with `grm wt delete` if neccessary.
### Converting an existing repository
It is possible to convert an existing directory to a worktree setup, using `grm
wt convert`. This command has to be run in the root of the repository you want
to convert:
```
grm wt convert
[✔] Conversion successful
```
This command will refuse to run if you have any changes in your repository.
Commit them and try again!
Afterwards, the directory is empty, as there are no worktrees checked out yet.
Now you can use the usual commands to set up worktrees.
### Manual access
GRM isn't doing any magic, it's just git under the hood. If you need to have access
to the underlying git repository, you can always do this:
```
$ git --git-dir ./.git-main-working-tree [...]
```
This should never be required (whenever you have to do this, you can consider
this a bug in GRM and open an [issue](https://github.com/hakoerber/git-repo-manager/issues/new),
but it may help in a pinch.

View File

@@ -19,6 +19,20 @@ pub struct Opts {
#[derive(Parser)] #[derive(Parser)]
pub enum SubCommand { pub enum SubCommand {
#[clap(about = "Manage repositories")]
Repos(Repos),
#[clap(visible_alias = "wt", about = "Manage worktrees")]
Worktree(Worktree),
}
#[derive(Parser)]
pub struct Repos {
#[clap(subcommand, name = "action")]
pub action: ReposAction,
}
#[derive(Parser)]
pub enum ReposAction {
#[clap( #[clap(
visible_alias = "run", visible_alias = "run",
about = "Synchronize the repositories to the configured values" about = "Synchronize the repositories to the configured values"
@@ -45,11 +59,7 @@ pub struct Sync {
#[derive(Parser)] #[derive(Parser)]
#[clap()] #[clap()]
pub struct OptionalConfig { pub struct OptionalConfig {
#[clap( #[clap(short, long, about = "Path to the configuration file")]
short,
long,
about = "Path to the configuration file"
)]
pub config: Option<String>, pub config: Option<String>,
} }
@@ -59,6 +69,61 @@ pub struct Find {
pub path: String, pub path: String,
} }
#[derive(Parser)]
pub struct Worktree {
#[clap(subcommand, name = "action")]
pub action: WorktreeAction,
}
#[derive(Parser)]
pub enum WorktreeAction {
#[clap(about = "Add a new worktree")]
Add(WorktreeAddArgs),
#[clap(about = "Add an existing worktree")]
Delete(WorktreeDeleteArgs),
#[clap(about = "Show state of existing worktrees")]
Status(WorktreeStatusArgs),
#[clap(about = "Convert a normal repository to a worktree setup")]
Convert(WorktreeConvertArgs),
#[clap(about = "Clean all worktrees that do not contain uncommited/unpushed changes")]
Clean(WorktreeCleanArgs),
}
#[derive(Parser)]
pub struct WorktreeAddArgs {
#[clap(about = "Name of the worktree")]
pub name: String,
#[clap(
short = 'n',
long = "branch-namespace",
about = "Namespace of the branch"
)]
pub branch_namespace: Option<String>,
#[clap(short = 't', long = "track", about = "Remote branch to track")]
pub track: Option<String>,
}
#[derive(Parser)]
pub struct WorktreeDeleteArgs {
#[clap(about = "Name of the worktree")]
pub name: String,
#[clap(
long = "force",
about = "Force deletion, even when there are uncommitted/unpushed changes"
)]
pub force: bool,
}
#[derive(Parser)]
pub struct WorktreeStatusArgs {}
#[derive(Parser)]
pub struct WorktreeConvertArgs {}
#[derive(Parser)]
pub struct WorktreeCleanArgs {}
pub fn parse() -> Opts { pub fn parse() -> Opts {
Opts::parse() Opts::parse()
} }

File diff suppressed because it is too large Load Diff

View File

@@ -46,10 +46,18 @@ pub struct Remote {
pub remote_type: RemoteType, pub remote_type: RemoteType,
} }
fn worktree_setup_default() -> bool {
false
}
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct Repo { pub struct Repo {
pub name: String, pub name: String,
#[serde(default = "worktree_setup_default")]
pub worktree_setup: bool,
pub remotes: Option<Vec<Remote>>, pub remotes: Option<Vec<Remote>>,
} }
@@ -82,11 +90,14 @@ pub struct RepoStatus {
pub head: Option<String>, pub head: Option<String>,
pub changes: Option<RepoChanges>, // None(_) => Could not get changes (e.g. because it's a worktree setup)
// Some(None) => No changes
// Some(Some(_)) => Changes
pub changes: Option<Option<RepoChanges>>,
pub worktrees: usize, pub worktrees: usize,
pub submodules: Vec<(String, SubmoduleStatus)>, pub submodules: Option<Vec<(String, SubmoduleStatus)>>,
pub branches: Vec<(String, Option<(String, RemoteTrackingStatus)>)>, pub branches: Vec<(String, Option<(String, RemoteTrackingStatus)>)>,
} }
@@ -167,8 +178,16 @@ pub fn detect_remote_type(remote_url: &str) -> Option<RemoteType> {
None None
} }
pub fn open_repo(path: &Path) -> Result<Repository, RepoError> { pub fn open_repo(path: &Path, is_worktree: bool) -> Result<Repository, RepoError> {
match Repository::open(path) { let open_func = match is_worktree {
true => Repository::open_bare,
false => Repository::open,
};
let path = match is_worktree {
true => path.join(super::GIT_MAIN_WORKTREE_DIRECTORY),
false => path.to_path_buf(),
};
match open_func(path) {
Ok(r) => Ok(r), Ok(r) => Ok(r),
Err(e) => match e.code() { Err(e) => match e.code() {
git2::ErrorCode::NotFound => Err(RepoError::new(RepoErrorKind::NotFound)), git2::ErrorCode::NotFound => Err(RepoError::new(RepoErrorKind::NotFound)),
@@ -179,24 +198,67 @@ pub fn open_repo(path: &Path) -> Result<Repository, RepoError> {
} }
} }
pub fn init_repo(path: &Path) -> Result<Repository, Box<dyn std::error::Error>> { pub fn init_repo(path: &Path, is_worktree: bool) -> Result<Repository, Box<dyn std::error::Error>> {
match Repository::init(path) { let repo = match is_worktree {
Ok(r) => Ok(r), false => Repository::init(path)?,
Err(e) => Err(Box::new(e)), true => Repository::init_bare(path.join(super::GIT_MAIN_WORKTREE_DIRECTORY))?,
};
if is_worktree {
repo_set_config_push(&repo, "upstream")?;
} }
Ok(repo)
} }
pub fn clone_repo(remote: &Remote, path: &Path) -> Result<(), Box<dyn std::error::Error>> { pub fn get_repo_config(repo: &git2::Repository) -> Result<git2::Config, String> {
repo.config()
.map_err(|error| format!("Failed getting repository configuration: {}", error))
}
pub fn repo_make_bare(repo: &git2::Repository, value: bool) -> Result<(), String> {
let mut config = get_repo_config(repo)?;
config
.set_bool(super::GIT_CONFIG_BARE_KEY, value)
.map_err(|error| format!("Could not set {}: {}", super::GIT_CONFIG_BARE_KEY, error))
}
pub fn repo_set_config_push(repo: &git2::Repository, value: &str) -> Result<(), String> {
let mut config = get_repo_config(repo)?;
config
.set_str(super::GIT_CONFIG_PUSH_DEFAULT, value)
.map_err(|error| {
format!(
"Could not set {}: {}",
super::GIT_CONFIG_PUSH_DEFAULT,
error
)
})
}
pub fn clone_repo(
remote: &Remote,
path: &Path,
is_worktree: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let clone_target = match is_worktree {
false => path.to_path_buf(),
true => path.join(super::GIT_MAIN_WORKTREE_DIRECTORY),
};
print_action(&format!( print_action(&format!(
"Cloning into \"{}\" from \"{}\"", "Cloning into \"{}\" from \"{}\"",
&path.display(), &clone_target.display(),
&remote.url &remote.url
)); ));
match remote.remote_type { match remote.remote_type {
RemoteType::Https => match Repository::clone(&remote.url, &path) { RemoteType::Https => {
Ok(_) => Ok(()), let mut builder = git2::build::RepoBuilder::new();
Err(e) => Err(Box::new(e)), builder.bare(is_worktree);
}, builder.clone(&remote.url, &clone_target)?;
}
RemoteType::Ssh => { RemoteType::Ssh => {
let mut callbacks = RemoteCallbacks::new(); let mut callbacks = RemoteCallbacks::new();
callbacks.credentials(|_url, username_from_url, _allowed_types| { callbacks.credentials(|_url, username_from_url, _allowed_types| {
@@ -207,17 +269,22 @@ pub fn clone_repo(remote: &Remote, path: &Path) -> Result<(), Box<dyn std::error
fo.remote_callbacks(callbacks); fo.remote_callbacks(callbacks);
let mut builder = git2::build::RepoBuilder::new(); let mut builder = git2::build::RepoBuilder::new();
builder.bare(is_worktree);
builder.fetch_options(fo); builder.fetch_options(fo);
match builder.clone(&remote.url, path) { builder.clone(&remote.url, &clone_target)?;
Ok(_) => Ok(()),
Err(e) => Err(Box::new(e)),
}
} }
} }
if is_worktree {
let repo = open_repo(&clone_target, false)?;
repo_set_config_push(&repo, "upstream")?;
}
Ok(())
} }
pub fn get_repo_status(repo: &git2::Repository) -> RepoStatus { pub fn get_repo_status(repo: &git2::Repository, is_worktree: bool) -> RepoStatus {
let operation = match repo.state() { let operation = match repo.state() {
git2::RepositoryState::Clean => None, git2::RepositoryState::Clean => None,
state => Some(state), state => Some(state),
@@ -232,73 +299,100 @@ pub fn get_repo_status(repo: &git2::Repository) -> RepoStatus {
.map(|repo_name| repo_name.unwrap().to_string()) .map(|repo_name| repo_name.unwrap().to_string())
.collect::<Vec<String>>(); .collect::<Vec<String>>();
let head = match empty { let head = match is_worktree {
true => None, true => None,
false => Some(repo.head().unwrap().shorthand().unwrap().to_string()), false => match empty {
true => None,
false => Some(repo.head().unwrap().shorthand().unwrap().to_string()),
},
}; };
let statuses = repo.statuses(None).unwrap(); let changes = match is_worktree {
let changes = match statuses.is_empty() {
true => None, true => None,
false => { false => {
let mut files_new = 0; let statuses = repo
let mut files_modified = 0; .statuses(Some(
let mut files_deleted = 0; git2::StatusOptions::new()
for status in statuses.iter() { .include_ignored(false)
let status_bits = status.status(); .include_untracked(true),
if status_bits.intersects( ))
git2::Status::INDEX_MODIFIED .unwrap();
| git2::Status::INDEX_RENAMED
| git2::Status::INDEX_TYPECHANGE match statuses.is_empty() {
| git2::Status::WT_MODIFIED true => Some(None),
| git2::Status::WT_RENAMED false => {
| git2::Status::WT_TYPECHANGE, let mut files_new = 0;
) { let mut files_modified = 0;
files_modified += 1; let mut files_deleted = 0;
} else if status_bits.intersects(git2::Status::INDEX_NEW | git2::Status::WT_NEW) { for status in statuses.iter() {
files_new += 1; let status_bits = status.status();
} else if status_bits if status_bits.intersects(
.intersects(git2::Status::INDEX_DELETED | git2::Status::WT_DELETED) git2::Status::INDEX_MODIFIED
{ | git2::Status::INDEX_RENAMED
files_deleted += 1; | git2::Status::INDEX_TYPECHANGE
| git2::Status::WT_MODIFIED
| git2::Status::WT_RENAMED
| git2::Status::WT_TYPECHANGE,
) {
files_modified += 1;
} else if status_bits
.intersects(git2::Status::INDEX_NEW | git2::Status::WT_NEW)
{
files_new += 1;
} else if status_bits
.intersects(git2::Status::INDEX_DELETED | git2::Status::WT_DELETED)
{
files_deleted += 1;
}
}
if (files_new, files_modified, files_deleted) == (0, 0, 0) {
panic!(
"is_empty() returned true, but no file changes were detected. This is a bug!"
);
}
Some(Some(RepoChanges {
files_new,
files_modified,
files_deleted,
}))
} }
} }
Some(RepoChanges {
files_new,
files_modified,
files_deleted,
})
} }
}; };
let worktrees = repo.worktrees().unwrap().len(); let worktrees = repo.worktrees().unwrap().len();
let mut submodules = Vec::new(); let submodules = match is_worktree {
for submodule in repo.submodules().unwrap() { true => None,
let submodule_name = submodule.name().unwrap().to_string(); false => {
let mut submodules = Vec::new();
for submodule in repo.submodules().unwrap() {
let submodule_name = submodule.name().unwrap().to_string();
let submodule_status; let submodule_status;
let status = repo let status = repo
.submodule_status(submodule.name().unwrap(), git2::SubmoduleIgnore::None) .submodule_status(submodule.name().unwrap(), git2::SubmoduleIgnore::None)
.unwrap(); .unwrap();
if status.intersects( if status.intersects(
git2::SubmoduleStatus::WD_INDEX_MODIFIED git2::SubmoduleStatus::WD_INDEX_MODIFIED
| git2::SubmoduleStatus::WD_WD_MODIFIED | git2::SubmoduleStatus::WD_WD_MODIFIED
| git2::SubmoduleStatus::WD_UNTRACKED, | git2::SubmoduleStatus::WD_UNTRACKED,
) { ) {
submodule_status = SubmoduleStatus::Changed; submodule_status = SubmoduleStatus::Changed;
} else if status.is_wd_uninitialized() { } else if status.is_wd_uninitialized() {
submodule_status = SubmoduleStatus::Uninitialized; submodule_status = SubmoduleStatus::Uninitialized;
} else if status.is_wd_modified() { } else if status.is_wd_modified() {
submodule_status = SubmoduleStatus::OutOfDate; submodule_status = SubmoduleStatus::OutOfDate;
} else { } else {
submodule_status = SubmoduleStatus::Clean; submodule_status = SubmoduleStatus::Clean;
}
submodules.push((submodule_name, submodule_status));
}
Some(submodules)
} }
};
submodules.push((submodule_name, submodule_status));
}
let mut branches = Vec::new(); let mut branches = Vec::new();
for (local_branch, _) in repo for (local_branch, _) in repo

11
tests/helpers.rs Normal file
View File

@@ -0,0 +1,11 @@
use tempdir::TempDir;
pub fn init_tmpdir() -> TempDir {
let tmp_dir = TempDir::new("grm-test").unwrap();
println!("Temporary directory: {}", tmp_dir.path().display());
tmp_dir
}
pub fn cleanup_tmpdir(tempdir: TempDir) {
tempdir.close().unwrap();
}

43
tests/repo.rs Normal file
View File

@@ -0,0 +1,43 @@
use grm::repo::*;
mod helpers;
use helpers::*;
#[test]
fn open_empty_repo() {
let tmpdir = init_tmpdir();
assert!(matches!(
open_repo(tmpdir.path(), true),
Err(RepoError {
kind: RepoErrorKind::NotFound
})
));
assert!(matches!(
open_repo(tmpdir.path(), false),
Err(RepoError {
kind: RepoErrorKind::NotFound
})
));
cleanup_tmpdir(tmpdir);
}
#[test]
fn create_repo() -> Result<(), Box<dyn std::error::Error>> {
let tmpdir = init_tmpdir();
let repo = init_repo(tmpdir.path(), false)?;
assert!(!repo.is_bare());
assert!(repo.is_empty()?);
cleanup_tmpdir(tmpdir);
Ok(())
}
#[test]
fn create_repo_with_worktree() -> Result<(), Box<dyn std::error::Error>> {
let tmpdir = init_tmpdir();
let repo = init_repo(tmpdir.path(), true)?;
assert!(repo.is_bare());
assert!(repo.is_empty()?);
cleanup_tmpdir(tmpdir);
Ok(())
}