9 Commits

Author SHA1 Message Date
e75aead3a8 Release v0.7.1 2022-05-27 23:37:54 +02:00
dca2b3c9b4 Justfile: Add build targets 2022-05-27 23:37:54 +02:00
a71711978e Make sure we do not expose secrets in output
This is using the RFC-8959 URI scheme to detect secrets. Thanks
hackernews for the idea ;)
2022-05-27 23:37:54 +02:00
90d188e01e Back to pure docker for testing 2022-05-27 23:37:54 +02:00
2e6166e807 Link binary statically with musl 2022-05-27 23:37:54 +02:00
8aaaa55d45 gitlab: Add alternate error field in JSON response 2022-05-27 23:37:54 +02:00
df39bb3076 gitlab: Fix detection of private repositories 2022-05-27 23:37:54 +02:00
bc3d4e1c49 Properly escape URL parameters 2022-05-27 23:37:54 +02:00
32eb4676ee Restructure into smaller modules 2022-05-27 23:37:54 +02:00
25 changed files with 861 additions and 778 deletions

View File

22
Cargo.lock generated
View File

@@ -332,7 +332,7 @@ dependencies = [
[[package]] [[package]]
name = "git-repo-manager" name = "git-repo-manager"
version = "0.7.0" version = "0.7.1"
dependencies = [ dependencies = [
"clap", "clap",
"comfy-table", "comfy-table",
@@ -347,6 +347,7 @@ dependencies = [
"shellexpand", "shellexpand",
"tempdir", "tempdir",
"toml", "toml",
"url-escape",
] ]
[[package]] [[package]]
@@ -608,6 +609,15 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
[[package]]
name = "openssl-src"
version = "111.20.0+1.1.1o"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92892c4f87d56e376e469ace79f1128fdaded07646ddf73aa0be4706ff712dec"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "openssl-sys" name = "openssl-sys"
version = "0.9.73" version = "0.9.73"
@@ -617,6 +627,7 @@ dependencies = [
"autocfg", "autocfg",
"cc", "cc",
"libc", "libc",
"openssl-src",
"pkg-config", "pkg-config",
"vcpkg", "vcpkg",
] ]
@@ -1189,6 +1200,15 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "url-escape"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44e0ce4d1246d075ca5abec4b41d33e87a6054d08e2366b63205665e950db218"
dependencies = [
"percent-encoding",
]
[[package]] [[package]]
name = "vcpkg" name = "vcpkg"
version = "0.2.15" version = "0.2.15"

View File

@@ -1,7 +1,8 @@
[package] [package]
name = "git-repo-manager" name = "git-repo-manager"
version = "0.7.0" version = "0.7.1"
edition = "2021" edition = "2021"
authors = [ authors = [
"Hannes Körber <hannes@hkoerber.de>", "Hannes Körber <hannes@hkoerber.de>",
] ]
@@ -73,10 +74,22 @@ version = "=1.0.81"
[dependencies.isahc] [dependencies.isahc]
version = "=1.7.2" version = "=1.7.2"
features = ["json"] default-features = false
features = ["json", "http2", "text-decoding"]
[dependencies.parse_link_header] [dependencies.parse_link_header]
version = "=0.3.2" version = "=0.3.2"
[dependencies.url-escape]
version = "=0.1.1"
[dev-dependencies.tempdir] [dev-dependencies.tempdir]
version = "=0.3.7" version = "=0.3.7"
[features]
static-build = [
"git2/vendored-openssl",
"git2/vendored-libgit2",
"isahc/static-curl",
"isahc/static-ssl",
]

View File

@@ -1,5 +1,7 @@
set positional-arguments set positional-arguments
target := "x86_64-unknown-linux-musl"
check: test check: test
cargo check cargo check
cargo fmt --check cargo fmt --check
@@ -16,23 +18,26 @@ lint-fix:
cargo clippy --no-deps --fix cargo clippy --no-deps --fix
release: release:
cargo build --release cargo build --release --target {{target}}
test-binary-docker:
env \
GITHUB_API_BASEURL=http://rest:5000/github \
GITLAB_API_BASEURL=http://rest:5000/gitlab \
cargo build --profile e2e-tests
test-binary: test-binary:
env \ env \
GITHUB_API_BASEURL=http://localhost:5000/github \ GITHUB_API_BASEURL=http://rest:5000/github \
GITLAB_API_BASEURL=http://localhost:5000/gitlab \ GITLAB_API_BASEURL=http://rest:5000/gitlab \
cargo build --profile e2e-tests cargo build --target {{target}} --profile e2e-tests --features=static-build
install: install:
cargo install --path . cargo install --path .
install-static:
cargo install --target {{target}} --features=static-build --path .
build:
cargo build
build-static:
cargo build --target {{target}} --features=static-build
test: test-unit test-integration test-e2e test: test-unit test-integration test-e2e
test-unit: test-unit:
@@ -41,23 +46,15 @@ test-unit:
test-integration: test-integration:
cargo test --test "*" cargo test --test "*"
test-e2e-docker +tests=".": test-binary-docker test-e2e +tests=".": test-binary
cd ./e2e_tests \ cd ./e2e_tests \
&& docker-compose rm --stop -f \ && docker-compose rm --stop -f \
&& docker-compose build \ && docker-compose build \
&& docker-compose run \ && docker-compose run \
--rm \ --rm \
-v $PWD/../target/e2e-tests/grm:/grm \ -v $PWD/../target/{{target}}/e2e-tests/grm:/grm \
pytest \ pytest \
"GRM_BINARY=/grm python3 ALTERNATE_DOMAIN=alternate-rest -m pytest -p no:cacheprovider --color=yes "$@"" \ "GRM_BINARY=/grm ALTERNATE_DOMAIN=alternate-rest python3 -m pytest -p no:cacheprovider --color=yes "$@"" \
&& docker-compose rm --stop -f
test-e2e +tests=".": test-binary
cd ./e2e_tests \
&& docker-compose rm --stop -f \
&& docker-compose build \
&& docker-compose up -d rest \
&& GRM_BINARY={{justfile_directory()}}/target/e2e-tests/grm ALTERNATE_DOMAIN=127.0.0.1 python3 -m pytest -p no:cacheprovider --color=yes {{tests}} \
&& docker-compose rm --stop -f && docker-compose rm --stop -f
update-dependencies: update-cargo-dependencies update-dependencies: update-cargo-dependencies

View File

@@ -1,7 +1,7 @@
# Summary # Summary
- [Overview](./overview.md) - [Overview](./overview.md)
- [Getting started](./getting_started.md) - [Installation](./installation.md)
- [Repository trees](./repos.md) - [Repository trees](./repos.md)
- [Git Worktrees](./worktrees.md) - [Git Worktrees](./worktrees.md)
- [Forge Integrations](./forge_integration.md) - [Forge Integrations](./forge_integration.md)

View File

@@ -1,22 +0,0 @@
# 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
```

56
docs/src/installation.md Normal file
View File

@@ -0,0 +1,56 @@
# Installation
## 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
```
Then, install the build dependencies:
| Distribution | Command |
| ------------- | ------------------------------------------------------------------------------ |
| Archlinux | `pacman -S --needed gcc openssl pkg-config` |
| Ubuntu/Debian | `apt-get install --no-install-recommends pkg-config gcc libssl-dev zlib1g-dev` |
Then, it's a simple command to install the latest stable version:
```bash
$ cargo +nightly install git-repo-manager
```
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
```
## Static build
Note that by default, you will get a dynamically linked executable.
Alternatively, you can also build a statically linked binary. For this, you
will need `musl` and a few other build dependencies installed installed:
| Distribution | Command |
| ------------- | --------------------------------------------------------------------------- |
| Archlinux | `pacman -S --needed gcc musl perl make` |
| Ubuntu/Debian | `apt-get install --no-install-recommends gcc musl-tools libc-dev perl make` |
(`perl` and `make` are required for the OpenSSL build script)
The, add the musl target via `rustup`:
```
$ rustup +nightly target add x86_64-unknown-linux-musl
```
Then, use a modified build command to get a statically linked binary:
```
$ cargo +nightly install git-repo-manager --target x86_64-unknown-linux-musl --features=static-build
```

View File

@@ -23,8 +23,6 @@ services:
build: ./docker-rest/ build: ./docker-rest/
expose: expose:
- "5000" - "5000"
ports:
- "5000:5000"
networks: networks:
main: main:
aliases: aliases:

View File

@@ -12,7 +12,7 @@ def check_headers():
app.logger.error("Invalid accept header") app.logger.error("Invalid accept header")
abort(500) abort(500)
auth_header = request.headers.get("authorization") auth_header = request.headers.get("authorization")
if auth_header != "token authtoken": if auth_header != "token secret-token:myauthtoken":
app.logger.error("Invalid authorization header: %s", auth_header) app.logger.error("Invalid authorization header: %s", auth_header)
abort( abort(
make_response( make_response(

View File

@@ -12,7 +12,7 @@ def check_headers():
app.logger.error("Invalid accept header") app.logger.error("Invalid accept header")
abort(500) abort(500)
auth_header = request.headers.get("authorization") auth_header = request.headers.get("authorization")
if auth_header != "bearer authtoken": if auth_header != "bearer secret-token:myauthtoken":
app.logger.error("Invalid authorization header: %s", auth_header) app.logger.error("Invalid authorization header: %s", auth_header)
abort( abort(
make_response( make_response(

View File

@@ -18,6 +18,8 @@ def grm(args, cwd=None, is_invalid=False):
print(f"grmcmd: {args}") print(f"grmcmd: {args}")
print(f"stdout:\n{cmd.stdout}") print(f"stdout:\n{cmd.stdout}")
print(f"stderr:\n{cmd.stderr}") print(f"stderr:\n{cmd.stderr}")
assert "secret-token:" not in cmd.stdout
assert "secret-token:" not in cmd.stderr
assert "panicked" not in cmd.stderr assert "panicked" not in cmd.stderr
return cmd return cmd

View File

@@ -141,7 +141,7 @@ def test_repos_find_remote_no_filter(provider, configtype, default, use_config):
f.write( f.write(
f""" f"""
provider = "{provider}" provider = "{provider}"
token_command = "echo authtoken" token_command = "echo secret-token:myauthtoken"
root = "/myroot" root = "/myroot"
""" """
) )
@@ -157,7 +157,7 @@ def test_repos_find_remote_no_filter(provider, configtype, default, use_config):
"--provider", "--provider",
provider, provider,
"--token-command", "--token-command",
"echo authtoken", "echo secret-token:myauthtoken",
"--root", "--root",
"/myroot", "/myroot",
] ]
@@ -193,7 +193,7 @@ def test_repos_find_remote_user_empty(
with open(config.name, "w") as f: with open(config.name, "w") as f:
cfg = f""" cfg = f"""
provider = "{provider}" provider = "{provider}"
token_command = "echo authtoken" token_command = "echo secret-token:myauthtoken"
root = "/myroot" root = "/myroot"
[filters] [filters]
@@ -213,7 +213,7 @@ def test_repos_find_remote_user_empty(
"--provider", "--provider",
provider, provider,
"--token-command", "--token-command",
"echo authtoken", "echo secret-token:myauthtoken",
"--root", "--root",
"/myroot", "/myroot",
"--user", "--user",
@@ -264,7 +264,7 @@ def test_repos_find_remote_user(
with open(config.name, "w") as f: with open(config.name, "w") as f:
cfg = f""" cfg = f"""
provider = "{provider}" provider = "{provider}"
token_command = "echo authtoken" token_command = "echo secret-token:myauthtoken"
root = "/myroot" root = "/myroot"
""" """
@@ -300,7 +300,7 @@ def test_repos_find_remote_user(
"--provider", "--provider",
provider, provider,
"--token-command", "--token-command",
"echo authtoken", "echo secret-token:myauthtoken",
"--root", "--root",
"/myroot", "/myroot",
] ]
@@ -378,7 +378,7 @@ def test_repos_find_remote_group_empty(
with open(config.name, "w") as f: with open(config.name, "w") as f:
cfg = f""" cfg = f"""
provider = "{provider}" provider = "{provider}"
token_command = "echo authtoken" token_command = "echo secret-token:myauthtoken"
root = "/myroot" root = "/myroot"
""" """
@@ -403,7 +403,7 @@ def test_repos_find_remote_group_empty(
"--provider", "--provider",
provider, provider,
"--token-command", "--token-command",
"echo authtoken", "echo secret-token:myauthtoken",
"--root", "--root",
"/myroot", "/myroot",
"--group", "--group",
@@ -459,7 +459,7 @@ def test_repos_find_remote_group(
with open(config.name, "w") as f: with open(config.name, "w") as f:
cfg = f""" cfg = f"""
provider = "{provider}" provider = "{provider}"
token_command = "echo authtoken" token_command = "echo secret-token:myauthtoken"
root = "/myroot" root = "/myroot"
""" """
@@ -488,7 +488,7 @@ def test_repos_find_remote_group(
"--provider", "--provider",
provider, provider,
"--token-command", "--token-command",
"echo authtoken", "echo secret-token:myauthtoken",
"--root", "--root",
"/myroot", "/myroot",
"--group", "--group",
@@ -575,7 +575,7 @@ def test_repos_find_remote_user_and_group(
with open(config.name, "w") as f: with open(config.name, "w") as f:
cfg = f""" cfg = f"""
provider = "{provider}" provider = "{provider}"
token_command = "echo authtoken" token_command = "echo secret-token:myauthtoken"
root = "/myroot" root = "/myroot"
""" """
@@ -609,7 +609,7 @@ def test_repos_find_remote_user_and_group(
"--provider", "--provider",
provider, provider,
"--token-command", "--token-command",
"echo authtoken", "echo secret-token:myauthtoken",
"--root", "--root",
"/myroot", "/myroot",
"--group", "--group",
@@ -726,7 +726,7 @@ def test_repos_find_remote_owner(
with open(config.name, "w") as f: with open(config.name, "w") as f:
cfg = f""" cfg = f"""
provider = "{provider}" provider = "{provider}"
token_command = "echo authtoken" token_command = "echo secret-token:myauthtoken"
root = "/myroot" root = "/myroot"
""" """
@@ -761,7 +761,7 @@ def test_repos_find_remote_owner(
"--provider", "--provider",
provider, provider,
"--token-command", "--token-command",
"echo authtoken", "echo secret-token:myauthtoken",
"--root", "--root",
"/myroot", "/myroot",
"--access", "--access",

36
src/auth.rs Normal file
View File

@@ -0,0 +1,36 @@
use std::process;
pub fn get_token_from_command(command: &str) -> Result<String, String> {
let output = process::Command::new("/usr/bin/env")
.arg("sh")
.arg("-c")
.arg(command)
.output()
.map_err(|error| format!("Failed to run token-command: {}", error))?;
let stderr = String::from_utf8(output.stderr).map_err(|error| error.to_string())?;
let stdout = String::from_utf8(output.stdout).map_err(|error| error.to_string())?;
if !output.status.success() {
if !stderr.is_empty() {
return Err(format!("Token command failed: {}", stderr));
} else {
return Err(String::from("Token command failed."));
}
}
if !stderr.is_empty() {
return Err(format!("Token command produced stderr: {}", stderr));
}
if stdout.is_empty() {
return Err(String::from("Token command did not produce output"));
}
let token = stdout
.split('\n')
.next()
.ok_or_else(|| String::from("Output did not contain any newline"))?;
Ok(token.to_string())
}

View File

@@ -1,18 +1,19 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::process; use std::process;
use crate::output::*;
use std::path::Path; use std::path::Path;
use crate::{get_token_from_command, path_as_string, Remote, Repo, Tree}; use super::auth;
use super::output::*;
use super::path;
use super::provider;
use super::provider::Filter;
use super::provider::Provider;
use super::repo;
use super::tree;
use crate::provider; pub type RemoteProvider = provider::RemoteProvider;
use crate::provider::Filter; pub type RemoteType = repo::RemoteType;
use crate::provider::Provider;
pub type RemoteProvider = crate::provider::RemoteProvider;
pub type RemoteType = crate::repo::RemoteType;
fn worktree_setup_default() -> bool { fn worktree_setup_default() -> bool {
false false
@@ -64,7 +65,7 @@ pub struct RemoteConfig {
} }
impl RemoteConfig { impl RemoteConfig {
pub fn from_remote(remote: Remote) -> Self { pub fn from_remote(remote: repo::Remote) -> Self {
Self { Self {
name: remote.name, name: remote.name,
url: remote.url, url: remote.url,
@@ -72,8 +73,8 @@ impl RemoteConfig {
} }
} }
pub fn into_remote(self) -> Remote { pub fn into_remote(self) -> repo::Remote {
Remote { repo::Remote {
name: self.name, name: self.name,
url: self.url, url: self.url,
remote_type: self.remote_type, remote_type: self.remote_type,
@@ -93,7 +94,7 @@ pub struct RepoConfig {
} }
impl RepoConfig { impl RepoConfig {
pub fn from_repo(repo: Repo) -> Self { pub fn from_repo(repo: repo::Repo) -> Self {
Self { Self {
name: repo.name, name: repo.name,
worktree_setup: repo.worktree_setup, worktree_setup: repo.worktree_setup,
@@ -103,14 +104,14 @@ impl RepoConfig {
} }
} }
pub fn into_repo(self) -> Repo { pub fn into_repo(self) -> repo::Repo {
let (namespace, name) = if let Some((namespace, name)) = self.name.rsplit_once('/') { let (namespace, name) = if let Some((namespace, name)) = self.name.rsplit_once('/') {
(Some(namespace.to_string()), name.to_string()) (Some(namespace.to_string()), name.to_string())
} else { } else {
(None, self.name) (None, self.name)
}; };
Repo { repo::Repo {
name, name,
namespace, namespace,
worktree_setup: self.worktree_setup, worktree_setup: self.worktree_setup,
@@ -133,7 +134,7 @@ impl ConfigTrees {
ConfigTrees { trees: vec } ConfigTrees { trees: vec }
} }
pub fn from_trees(vec: Vec<Tree>) -> Self { pub fn from_trees(vec: Vec<tree::Tree>) -> Self {
ConfigTrees { ConfigTrees {
trees: vec.into_iter().map(ConfigTree::from_tree).collect(), trees: vec.into_iter().map(ConfigTree::from_tree).collect(),
} }
@@ -157,7 +158,7 @@ impl Config {
match self { match self {
Config::ConfigTrees(config) => Ok(config.trees), Config::ConfigTrees(config) => Ok(config.trees),
Config::ConfigProvider(config) => { Config::ConfigProvider(config) => {
let token = match get_token_from_command(&config.token_command) { let token = match auth::get_token_from_command(&config.token_command) {
Ok(token) => token, Ok(token) => token,
Err(error) => { Err(error) => {
print_error(&format!("Getting token from command failed: {}", error)); print_error(&format!("Getting token from command failed: {}", error));
@@ -217,9 +218,9 @@ impl Config {
.collect(); .collect();
let tree = ConfigTree { let tree = ConfigTree {
root: if let Some(namespace) = namespace { root: if let Some(namespace) = namespace {
path_as_string(&Path::new(&config.root).join(namespace)) path::path_as_string(&Path::new(&config.root).join(namespace))
} else { } else {
path_as_string(Path::new(&config.root)) path::path_as_string(Path::new(&config.root))
}, },
repos: Some(repos), repos: Some(repos),
}; };
@@ -236,7 +237,7 @@ impl Config {
pub fn normalize(&mut self) { pub fn normalize(&mut self) {
if let Config::ConfigTrees(config) = self { if let Config::ConfigTrees(config) = self {
let home = super::env_home().display().to_string(); let home = path::env_home().display().to_string();
for tree in &mut config.trees_mut().iter_mut() { for tree in &mut config.trees_mut().iter_mut() {
if tree.root.starts_with(&home) { if tree.root.starts_with(&home) {
// The tilde is not handled differently, it's just a normal path component for `Path`. // The tilde is not handled differently, it's just a normal path component for `Path`.
@@ -275,14 +276,14 @@ pub struct ConfigTree {
} }
impl ConfigTree { impl ConfigTree {
pub fn from_repos(root: String, repos: Vec<Repo>) -> Self { pub fn from_repos(root: String, repos: Vec<repo::Repo>) -> Self {
Self { Self {
root, root,
repos: Some(repos.into_iter().map(RepoConfig::from_repo).collect()), repos: Some(repos.into_iter().map(RepoConfig::from_repo).collect()),
} }
} }
pub fn from_tree(tree: Tree) -> Self { pub fn from_tree(tree: tree::Tree) -> Self {
Self { Self {
root: tree.root, root: tree.root,
repos: Some(tree.repos.into_iter().map(RepoConfig::from_repo).collect()), repos: Some(tree.repos.into_iter().map(RepoConfig::from_repo).collect()),

View File

@@ -181,7 +181,7 @@ pub struct Config {
pub init_worktree: String, pub init_worktree: String,
} }
pub type RemoteProvider = grm::provider::RemoteProvider; pub type RemoteProvider = super::provider::RemoteProvider;
#[derive(Parser)] #[derive(Parser)]
#[clap()] #[clap()]

View File

@@ -3,12 +3,17 @@ use std::process;
mod cmd; mod cmd;
use grm::auth;
use grm::config; use grm::config;
use grm::find_in_tree;
use grm::output::*; use grm::output::*;
use grm::path_as_string; use grm::path;
use grm::provider; use grm::provider;
use grm::provider::Provider; use grm::provider::Provider;
use grm::repo; use grm::repo;
use grm::table;
use grm::tree;
use grm::worktree;
fn main() { fn main() {
let opts = cmd::parse(); let opts = cmd::parse();
@@ -24,7 +29,7 @@ fn main() {
process::exit(1); process::exit(1);
} }
}; };
match grm::sync_trees(config, args.init_worktree == "true") { match tree::sync_trees(config, args.init_worktree == "true") {
Ok(success) => { Ok(success) => {
if !success { if !success {
process::exit(1) process::exit(1)
@@ -37,7 +42,7 @@ fn main() {
} }
} }
cmd::SyncAction::Remote(args) => { cmd::SyncAction::Remote(args) => {
let token = match grm::get_token_from_command(&args.token_command) { let token = match auth::get_token_from_command(&args.token_command) {
Ok(token) => token, Ok(token) => token,
Err(error) => { Err(error) => {
print_error(&format!("Getting token from command failed: {}", error)); print_error(&format!("Getting token from command failed: {}", error));
@@ -45,18 +50,14 @@ fn main() {
} }
}; };
let filter = grm::provider::Filter::new( let filter =
args.users, provider::Filter::new(args.users, args.groups, args.owner, args.access);
args.groups,
args.owner,
args.access,
);
let worktree = args.worktree == "true"; let worktree = args.worktree == "true";
let repos = match args.provider { let repos = match args.provider {
cmd::RemoteProvider::Github => { cmd::RemoteProvider::Github => {
match grm::provider::Github::new(filter, token, args.api_url) { match provider::Github::new(filter, token, args.api_url) {
Ok(provider) => provider, Ok(provider) => provider,
Err(error) => { Err(error) => {
print_error(&format!("Error: {}", error)); print_error(&format!("Error: {}", error));
@@ -66,7 +67,7 @@ fn main() {
.get_repos(worktree, args.force_ssh) .get_repos(worktree, args.force_ssh)
} }
cmd::RemoteProvider::Gitlab => { cmd::RemoteProvider::Gitlab => {
match grm::provider::Gitlab::new(filter, token, args.api_url) { match provider::Gitlab::new(filter, token, args.api_url) {
Ok(provider) => provider, Ok(provider) => provider,
Err(error) => { Err(error) => {
print_error(&format!("Error: {}", error)); print_error(&format!("Error: {}", error));
@@ -83,9 +84,9 @@ fn main() {
for (namespace, repolist) in repos { for (namespace, repolist) in repos {
let root = if let Some(namespace) = namespace { let root = if let Some(namespace) = namespace {
path_as_string(&Path::new(&args.root).join(namespace)) path::path_as_string(&Path::new(&args.root).join(namespace))
} else { } else {
path_as_string(Path::new(&args.root)) path::path_as_string(Path::new(&args.root))
}; };
let tree = config::ConfigTree::from_repos(root, repolist); let tree = config::ConfigTree::from_repos(root, repolist);
@@ -94,7 +95,7 @@ fn main() {
let config = config::Config::from_trees(trees); let config = config::Config::from_trees(trees);
match grm::sync_trees(config, args.init_worktree == "true") { match tree::sync_trees(config, args.init_worktree == "true") {
Ok(success) => { Ok(success) => {
if !success { if !success {
process::exit(1) process::exit(1)
@@ -122,7 +123,7 @@ fn main() {
process::exit(1); process::exit(1);
} }
}; };
match grm::table::get_status_table(config) { match table::get_status_table(config) {
Ok((tables, errors)) => { Ok((tables, errors)) => {
for table in tables { for table in tables {
println!("{}", table); println!("{}", table);
@@ -146,7 +147,7 @@ fn main() {
} }
}; };
match grm::table::show_single_repo_status(&dir) { match table::show_single_repo_status(&dir) {
Ok((table, warnings)) => { Ok((table, warnings)) => {
println!("{}", table); println!("{}", table);
for warning in warnings { for warning in warnings {
@@ -184,7 +185,7 @@ fn main() {
} }
}; };
let (found_repos, warnings) = match grm::find_in_tree(&path) { let (found_repos, warnings) = match find_in_tree(&path) {
Ok((repos, warnings)) => (repos, warnings), Ok((repos, warnings)) => (repos, warnings),
Err(error) => { Err(error) => {
print_error(&error); print_error(&error);
@@ -192,7 +193,7 @@ fn main() {
} }
}; };
let trees = grm::config::ConfigTrees::from_trees(vec![found_repos]); let trees = config::ConfigTrees::from_trees(vec![found_repos]);
if trees.trees_ref().iter().all(|t| match &t.repos { if trees.trees_ref().iter().all(|t| match &t.repos {
None => false, None => false,
Some(r) => r.is_empty(), Some(r) => r.is_empty(),
@@ -237,16 +238,15 @@ fn main() {
} }
} }
cmd::FindAction::Config(args) => { cmd::FindAction::Config(args) => {
let config: crate::config::ConfigProvider = let config: config::ConfigProvider = match config::read_config(&args.config) {
match config::read_config(&args.config) { Ok(config) => config,
Ok(config) => config, Err(error) => {
Err(error) => { print_error(&error);
print_error(&error); process::exit(1);
process::exit(1); }
} };
};
let token = match grm::get_token_from_command(&config.token_command) { let token = match auth::get_token_from_command(&config.token_command) {
Ok(token) => token, Ok(token) => token,
Err(error) => { Err(error) => {
print_error(&format!("Getting token from command failed: {}", error)); print_error(&format!("Getting token from command failed: {}", error));
@@ -254,7 +254,7 @@ fn main() {
} }
}; };
let filters = config.filters.unwrap_or(grm::config::ConfigProviderFilter { let filters = config.filters.unwrap_or(config::ConfigProviderFilter {
access: Some(false), access: Some(false),
owner: Some(false), owner: Some(false),
users: Some(vec![]), users: Some(vec![]),
@@ -314,14 +314,14 @@ fn main() {
for (namespace, namespace_repos) in repos { for (namespace, namespace_repos) in repos {
let tree = config::ConfigTree { let tree = config::ConfigTree {
root: if let Some(namespace) = namespace { root: if let Some(namespace) = namespace {
path_as_string(&Path::new(&config.root).join(namespace)) path::path_as_string(&Path::new(&config.root).join(namespace))
} else { } else {
path_as_string(Path::new(&config.root)) path::path_as_string(Path::new(&config.root))
}, },
repos: Some( repos: Some(
namespace_repos namespace_repos
.into_iter() .into_iter()
.map(grm::config::RepoConfig::from_repo) .map(config::RepoConfig::from_repo)
.collect(), .collect(),
), ),
}; };
@@ -360,7 +360,7 @@ fn main() {
} }
} }
cmd::FindAction::Remote(args) => { cmd::FindAction::Remote(args) => {
let token = match grm::get_token_from_command(&args.token_command) { let token = match auth::get_token_from_command(&args.token_command) {
Ok(token) => token, Ok(token) => token,
Err(error) => { Err(error) => {
print_error(&format!("Getting token from command failed: {}", error)); print_error(&format!("Getting token from command failed: {}", error));
@@ -368,18 +368,14 @@ fn main() {
} }
}; };
let filter = grm::provider::Filter::new( let filter =
args.users, provider::Filter::new(args.users, args.groups, args.owner, args.access);
args.groups,
args.owner,
args.access,
);
let worktree = args.worktree == "true"; let worktree = args.worktree == "true";
let repos = match args.provider { let repos = match args.provider {
cmd::RemoteProvider::Github => { cmd::RemoteProvider::Github => {
match grm::provider::Github::new(filter, token, args.api_url) { match provider::Github::new(filter, token, args.api_url) {
Ok(provider) => provider, Ok(provider) => provider,
Err(error) => { Err(error) => {
print_error(&format!("Error: {}", error)); print_error(&format!("Error: {}", error));
@@ -389,7 +385,7 @@ fn main() {
.get_repos(worktree, args.force_ssh) .get_repos(worktree, args.force_ssh)
} }
cmd::RemoteProvider::Gitlab => { cmd::RemoteProvider::Gitlab => {
match grm::provider::Gitlab::new(filter, token, args.api_url) { match provider::Gitlab::new(filter, token, args.api_url) {
Ok(provider) => provider, Ok(provider) => provider,
Err(error) => { Err(error) => {
print_error(&format!("Error: {}", error)); print_error(&format!("Error: {}", error));
@@ -410,14 +406,14 @@ fn main() {
for (namespace, repolist) in repos { for (namespace, repolist) in repos {
let tree = config::ConfigTree { let tree = config::ConfigTree {
root: if let Some(namespace) = namespace { root: if let Some(namespace) = namespace {
path_as_string(&Path::new(&args.root).join(namespace)) path::path_as_string(&Path::new(&args.root).join(namespace))
} else { } else {
path_as_string(Path::new(&args.root)) path::path_as_string(Path::new(&args.root))
}, },
repos: Some( repos: Some(
repolist repolist
.into_iter() .into_iter()
.map(grm::config::RepoConfig::from_repo) .map(config::RepoConfig::from_repo)
.collect(), .collect(),
), ),
}; };
@@ -503,7 +499,13 @@ fn main() {
} }
} }
match grm::add_worktree(&cwd, name, subdirectory, track, action_args.no_track) { match worktree::add_worktree(
&cwd,
name,
subdirectory,
track,
action_args.no_track,
) {
Ok(_) => print_success(&format!("Worktree {} created", &action_args.name)), Ok(_) => print_success(&format!("Worktree {} created", &action_args.name)),
Err(error) => { Err(error) => {
print_error(&format!("Error creating worktree: {}", error)); print_error(&format!("Error creating worktree: {}", error));
@@ -525,7 +527,7 @@ fn main() {
} }
}; };
let repo = grm::RepoHandle::open(&cwd, true).unwrap_or_else(|error| { let repo = repo::RepoHandle::open(&cwd, true).unwrap_or_else(|error| {
print_error(&format!("Error opening repository: {}", error)); print_error(&format!("Error opening repository: {}", error));
process::exit(1); process::exit(1);
}); });
@@ -539,17 +541,17 @@ fn main() {
Ok(_) => print_success(&format!("Worktree {} deleted", &action_args.name)), Ok(_) => print_success(&format!("Worktree {} deleted", &action_args.name)),
Err(error) => { Err(error) => {
match error { match error {
grm::WorktreeRemoveFailureReason::Error(msg) => { repo::WorktreeRemoveFailureReason::Error(msg) => {
print_error(&msg); print_error(&msg);
process::exit(1); process::exit(1);
} }
grm::WorktreeRemoveFailureReason::Changes(changes) => { repo::WorktreeRemoveFailureReason::Changes(changes) => {
print_warning(&format!( print_warning(&format!(
"Changes in worktree: {}. Refusing to delete", "Changes in worktree: {}. Refusing to delete",
changes changes
)); ));
} }
grm::WorktreeRemoveFailureReason::NotMerged(message) => { repo::WorktreeRemoveFailureReason::NotMerged(message) => {
print_warning(&message); print_warning(&message);
} }
} }
@@ -558,12 +560,12 @@ fn main() {
} }
} }
cmd::WorktreeAction::Status(_args) => { cmd::WorktreeAction::Status(_args) => {
let repo = grm::RepoHandle::open(&cwd, true).unwrap_or_else(|error| { let repo = repo::RepoHandle::open(&cwd, true).unwrap_or_else(|error| {
print_error(&format!("Error opening repository: {}", error)); print_error(&format!("Error opening repository: {}", error));
process::exit(1); process::exit(1);
}); });
match grm::table::get_worktree_status_table(&repo, &cwd) { match table::get_worktree_status_table(&repo, &cwd) {
Ok((table, errors)) => { Ok((table, errors)) => {
println!("{}", table); println!("{}", table);
for error in errors { for error in errors {
@@ -583,8 +585,8 @@ fn main() {
// * Remove all files // * Remove all files
// * Set `core.bare` to `true` // * Set `core.bare` to `true`
let repo = grm::RepoHandle::open(&cwd, false).unwrap_or_else(|error| { let repo = repo::RepoHandle::open(&cwd, false).unwrap_or_else(|error| {
if error.kind == grm::RepoErrorKind::NotFound { if error.kind == repo::RepoErrorKind::NotFound {
print_error("Directory does not contain a git repository"); print_error("Directory does not contain a git repository");
} else { } else {
print_error(&format!("Opening repository failed: {}", error)); print_error(&format!("Opening repository failed: {}", error));
@@ -611,8 +613,8 @@ fn main() {
} }
} }
cmd::WorktreeAction::Clean(_args) => { cmd::WorktreeAction::Clean(_args) => {
let repo = grm::RepoHandle::open(&cwd, true).unwrap_or_else(|error| { let repo = repo::RepoHandle::open(&cwd, true).unwrap_or_else(|error| {
if error.kind == grm::RepoErrorKind::NotFound { if error.kind == repo::RepoErrorKind::NotFound {
print_error("Directory does not contain a git repository"); print_error("Directory does not contain a git repository");
} else { } else {
print_error(&format!("Opening repository failed: {}", error)); print_error(&format!("Opening repository failed: {}", error));
@@ -645,8 +647,8 @@ fn main() {
} }
} }
cmd::WorktreeAction::Fetch(_args) => { cmd::WorktreeAction::Fetch(_args) => {
let repo = grm::RepoHandle::open(&cwd, true).unwrap_or_else(|error| { let repo = repo::RepoHandle::open(&cwd, true).unwrap_or_else(|error| {
if error.kind == grm::RepoErrorKind::NotFound { if error.kind == repo::RepoErrorKind::NotFound {
print_error("Directory does not contain a git repository"); print_error("Directory does not contain a git repository");
} else { } else {
print_error(&format!("Opening repository failed: {}", error)); print_error(&format!("Opening repository failed: {}", error));
@@ -661,8 +663,8 @@ fn main() {
print_success("Fetched from all remotes"); print_success("Fetched from all remotes");
} }
cmd::WorktreeAction::Pull(args) => { cmd::WorktreeAction::Pull(args) => {
let repo = grm::RepoHandle::open(&cwd, true).unwrap_or_else(|error| { let repo = repo::RepoHandle::open(&cwd, true).unwrap_or_else(|error| {
if error.kind == grm::RepoErrorKind::NotFound { if error.kind == repo::RepoErrorKind::NotFound {
print_error("Directory does not contain a git repository"); print_error("Directory does not contain a git repository");
} else { } else {
print_error(&format!("Opening repository failed: {}", error)); print_error(&format!("Opening repository failed: {}", error));
@@ -702,8 +704,8 @@ fn main() {
print_error("There is no point in using --rebase without --pull"); print_error("There is no point in using --rebase without --pull");
process::exit(1); process::exit(1);
} }
let repo = grm::RepoHandle::open(&cwd, true).unwrap_or_else(|error| { let repo = repo::RepoHandle::open(&cwd, true).unwrap_or_else(|error| {
if error.kind == grm::RepoErrorKind::NotFound { if error.kind == repo::RepoErrorKind::NotFound {
print_error("Directory does not contain a git repository"); print_error("Directory does not contain a git repository");
} else { } else {
print_error(&format!("Opening repository failed: {}", error)); print_error(&format!("Opening repository failed: {}", error));
@@ -718,14 +720,10 @@ fn main() {
}); });
} }
let config = let config = repo::read_worktree_root_config(&cwd).unwrap_or_else(|error| {
grm::repo::read_worktree_root_config(&cwd).unwrap_or_else(|error| { print_error(&format!("Failed to read worktree configuration: {}", error));
print_error(&format!( process::exit(1);
"Failed to read worktree configuration: {}", });
error
));
process::exit(1);
});
let worktrees = repo.get_worktrees().unwrap_or_else(|error| { let worktrees = repo.get_worktrees().unwrap_or_else(|error| {
print_error(&format!("Error getting worktrees: {}", error)); print_error(&format!("Error getting worktrees: {}", error));

View File

@@ -1,423 +1,37 @@
#![feature(io_error_more)] #![feature(io_error_more)]
#![feature(const_option_ext)] #![feature(const_option_ext)]
use std::fs; use std::path::Path;
use std::path::{Path, PathBuf};
use std::process;
pub mod auth;
pub mod config; pub mod config;
pub mod output; pub mod output;
pub mod path;
pub mod provider; pub mod provider;
pub mod repo; pub mod repo;
pub mod table; pub mod table;
pub mod tree;
pub mod worktree;
use config::Config;
use output::*;
use repo::{clone_repo, detect_remote_type, Remote, RemoteType};
pub use repo::{
RemoteTrackingStatus, Repo, RepoErrorKind, RepoHandle, WorktreeRemoveFailureReason,
};
const GIT_MAIN_WORKTREE_DIRECTORY: &str = ".git-main-working-tree";
const BRANCH_NAMESPACE_SEPARATOR: &str = "/"; const BRANCH_NAMESPACE_SEPARATOR: &str = "/";
const GIT_CONFIG_BARE_KEY: &str = "core.bare";
const GIT_CONFIG_PUSH_DEFAULT: &str = "push.default";
pub struct Tree {
root: String,
repos: Vec<Repo>,
}
#[cfg(test)]
mod tests {
use super::*;
fn setup() {
std::env::set_var("HOME", "/home/test");
}
#[test]
fn check_expand_tilde() {
setup();
assert_eq!(
expand_path(Path::new("~/file")),
Path::new("/home/test/file")
);
}
#[test]
fn check_expand_invalid_tilde() {
setup();
assert_eq!(
expand_path(Path::new("/home/~/file")),
Path::new("/home/~/file")
);
}
#[test]
fn check_expand_home() {
setup();
assert_eq!(
expand_path(Path::new("$HOME/file")),
Path::new("/home/test/file")
);
assert_eq!(
expand_path(Path::new("${HOME}/file")),
Path::new("/home/test/file")
);
}
}
pub fn path_as_string(path: &Path) -> String {
path.to_path_buf().into_os_string().into_string().unwrap()
}
pub fn env_home() -> PathBuf {
match std::env::var("HOME") {
Ok(path) => Path::new(&path).to_path_buf(),
Err(e) => {
print_error(&format!("Unable to read HOME: {}", e));
process::exit(1);
}
}
}
fn expand_path(path: &Path) -> PathBuf {
fn home_dir() -> Option<PathBuf> {
Some(env_home())
}
let expanded_path = match shellexpand::full_with_context(
&path_as_string(path),
home_dir,
|name| -> Result<Option<String>, &'static str> {
match name {
"HOME" => Ok(Some(path_as_string(home_dir().unwrap().as_path()))),
_ => Ok(None),
}
},
) {
Ok(std::borrow::Cow::Borrowed(path)) => path.to_owned(),
Ok(std::borrow::Cow::Owned(path)) => path,
Err(e) => {
print_error(&format!("Unable to expand root: {}", e));
process::exit(1);
}
};
Path::new(&expanded_path).to_path_buf()
}
pub fn get_token_from_command(command: &str) -> Result<String, String> {
let output = std::process::Command::new("/usr/bin/env")
.arg("sh")
.arg("-c")
.arg(command)
.output()
.map_err(|error| format!("Failed to run token-command: {}", error))?;
let stderr = String::from_utf8(output.stderr).map_err(|error| error.to_string())?;
let stdout = String::from_utf8(output.stdout).map_err(|error| error.to_string())?;
if !output.status.success() {
if !stderr.is_empty() {
return Err(format!("Token command failed: {}", stderr));
} else {
return Err(String::from("Token command failed."));
}
}
if !stderr.is_empty() {
return Err(format!("Token command produced stderr: {}", stderr));
}
if stdout.is_empty() {
return Err(String::from("Token command did not produce output"));
}
let token = stdout
.split('\n')
.next()
.ok_or_else(|| String::from("Output did not contain any newline"))?;
Ok(token.to_string())
}
fn sync_repo(root_path: &Path, repo: &Repo, init_worktree: bool) -> Result<(), String> {
let repo_path = root_path.join(&repo.fullname());
let actual_git_directory = get_actual_git_directory(&repo_path, repo.worktree_setup);
let mut newly_created = false;
if repo_path.exists() {
if repo.worktree_setup && !actual_git_directory.exists() {
return Err(String::from(
"Repo already exists, but is not using a worktree setup",
));
};
} else if matches!(&repo.remotes, None) || repo.remotes.as_ref().unwrap().is_empty() {
print_repo_action(
&repo.name,
"Repository does not have remotes configured, initializing new",
);
match RepoHandle::init(&repo_path, repo.worktree_setup) {
Ok(r) => {
print_repo_success(&repo.name, "Repository created");
Some(r)
}
Err(e) => {
return Err(format!("Repository failed during init: {}", e));
}
};
} else {
let first = repo.remotes.as_ref().unwrap().first().unwrap();
match clone_repo(first, &repo_path, repo.worktree_setup) {
Ok(_) => {
print_repo_success(&repo.name, "Repository successfully cloned");
}
Err(e) => {
return Err(format!("Repository failed during clone: {}", e));
}
};
newly_created = true;
}
let repo_handle = match RepoHandle::open(&repo_path, repo.worktree_setup) {
Ok(repo) => repo,
Err(error) => {
if !repo.worktree_setup && RepoHandle::open(&repo_path, true).is_ok() {
return Err(String::from(
"Repo already exists, but is using a worktree setup",
));
} else {
return Err(format!("Opening repository failed: {}", error));
}
}
};
if newly_created && repo.worktree_setup && init_worktree {
match repo_handle.default_branch() {
Ok(branch) => {
add_worktree(&repo_path, &branch.name()?, None, None, false)?;
}
Err(_error) => print_repo_error(
&repo.name,
"Could not determine default branch, skipping worktree initializtion",
),
}
}
if let Some(remotes) = &repo.remotes {
let current_remotes: Vec<String> = repo_handle
.remotes()
.map_err(|error| format!("Repository failed during getting the remotes: {}", error))?;
for remote in remotes {
let current_remote = repo_handle.find_remote(&remote.name)?;
match current_remote {
Some(current_remote) => {
let current_url = current_remote.url();
if remote.url != current_url {
print_repo_action(
&repo.name,
&format!("Updating remote {} to \"{}\"", &remote.name, &remote.url),
);
if let Err(e) = repo_handle.remote_set_url(&remote.name, &remote.url) {
return Err(format!("Repository failed during setting of the remote URL for remote \"{}\": {}", &remote.name, e));
};
}
}
None => {
print_repo_action(
&repo.name,
&format!(
"Setting up new remote \"{}\" to \"{}\"",
&remote.name, &remote.url
),
);
if let Err(e) = repo_handle.new_remote(&remote.name, &remote.url) {
return Err(format!(
"Repository failed during setting the remotes: {}",
e
));
}
}
}
}
for current_remote in &current_remotes {
if !remotes.iter().any(|r| &r.name == current_remote) {
print_repo_action(
&repo.name,
&format!("Deleting remote \"{}\"", &current_remote,),
);
if let Err(e) = repo_handle.remote_delete(current_remote) {
return Err(format!(
"Repository failed during deleting remote \"{}\": {}",
&current_remote, e
));
}
}
}
}
Ok(())
}
pub fn find_unmanaged_repos(
root_path: &Path,
managed_repos: &[Repo],
) -> Result<Vec<PathBuf>, String> {
let mut unmanaged_repos = Vec::new();
for repo_path in find_repo_paths(root_path)? {
if !managed_repos
.iter()
.any(|r| Path::new(root_path).join(r.fullname()) == repo_path)
{
unmanaged_repos.push(repo_path);
}
}
Ok(unmanaged_repos)
}
pub fn sync_trees(config: Config, init_worktree: bool) -> Result<bool, String> {
let mut failures = false;
let mut unmanaged_repos_absolute_paths = vec![];
let mut managed_repos_absolute_paths = vec![];
let trees = config.trees()?;
for tree in trees {
let repos: Vec<Repo> = tree
.repos
.unwrap_or_default()
.into_iter()
.map(|repo| repo.into_repo())
.collect();
let root_path = expand_path(Path::new(&tree.root));
for repo in &repos {
managed_repos_absolute_paths.push(root_path.join(repo.fullname()));
match sync_repo(&root_path, repo, init_worktree) {
Ok(_) => print_repo_success(&repo.name, "OK"),
Err(error) => {
print_repo_error(&repo.name, &error);
failures = true;
}
}
}
match find_unmanaged_repos(&root_path, &repos) {
Ok(repos) => {
unmanaged_repos_absolute_paths.extend(repos);
}
Err(error) => {
print_error(&format!("Error getting unmanaged repos: {}", error));
failures = true;
}
}
}
for unmanaged_repo_absolute_path in &unmanaged_repos_absolute_paths {
if managed_repos_absolute_paths
.iter()
.any(|managed_repo_absolute_path| {
managed_repo_absolute_path == unmanaged_repo_absolute_path
})
{
continue;
}
print_warning(&format!(
"Found unmanaged repository: \"{}\"",
path_as_string(unmanaged_repo_absolute_path)
));
}
Ok(!failures)
}
/// Finds repositories recursively, returning their path
fn find_repo_paths(path: &Path) -> Result<Vec<PathBuf>, String> {
let mut repos = Vec::new();
let git_dir = path.join(".git");
let git_worktree = path.join(GIT_MAIN_WORKTREE_DIRECTORY);
if git_dir.exists() || git_worktree.exists() {
repos.push(path.to_path_buf());
} else {
match fs::read_dir(path) {
Ok(contents) => {
for content in contents {
match content {
Ok(entry) => {
let path = entry.path();
if path.is_symlink() {
continue;
}
if path.is_dir() {
match find_repo_paths(&path) {
Ok(ref mut r) => repos.append(r),
Err(error) => return Err(error),
}
}
}
Err(e) => {
return Err(format!("Error accessing directory: {}", e));
}
};
}
}
Err(e) => {
return Err(format!(
"Failed to open \"{}\": {}",
&path.display(),
match e.kind() {
std::io::ErrorKind::NotADirectory =>
String::from("directory expected, but path is not a directory"),
std::io::ErrorKind::NotFound => String::from("not found"),
_ => format!("{:?}", e.kind()),
}
));
}
};
}
Ok(repos)
}
fn get_actual_git_directory(path: &Path, is_worktree: bool) -> PathBuf {
match is_worktree {
false => path.to_path_buf(),
true => path.join(GIT_MAIN_WORKTREE_DIRECTORY),
}
}
/// Find all git repositories under root, recursively /// Find all git repositories under root, recursively
/// ///
/// The bool in the return value specifies whether there is a repository /// The bool in the return value specifies whether there is a repository
/// in root itself. /// in root itself.
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
fn find_repos(root: &Path) -> Result<Option<(Vec<Repo>, Vec<String>, bool)>, String> { fn find_repos(root: &Path) -> Result<Option<(Vec<repo::Repo>, Vec<String>, bool)>, String> {
let mut repos: Vec<Repo> = Vec::new(); let mut repos: Vec<repo::Repo> = Vec::new();
let mut repo_in_root = false; let mut repo_in_root = false;
let mut warnings = Vec::new(); let mut warnings = Vec::new();
for path in find_repo_paths(root)? { for path in tree::find_repo_paths(root)? {
let is_worktree = RepoHandle::detect_worktree(&path); let is_worktree = repo::RepoHandle::detect_worktree(&path);
if path == root { if path == root {
repo_in_root = true; repo_in_root = true;
} }
match RepoHandle::open(&path, is_worktree) { match repo::RepoHandle::open(&path, is_worktree) {
Err(error) => { Err(error) => {
warnings.push(format!( warnings.push(format!(
"Error opening repo {}{}: {}", "Error opening repo {}{}: {}",
@@ -436,32 +50,32 @@ fn find_repos(root: &Path) -> Result<Option<(Vec<Repo>, Vec<String>, bool)>, Str
Err(error) => { Err(error) => {
warnings.push(format!( warnings.push(format!(
"{}: Error getting remotes: {}", "{}: Error getting remotes: {}",
&path_as_string(&path), &path::path_as_string(&path),
error error
)); ));
continue; continue;
} }
}; };
let mut results: Vec<Remote> = Vec::new(); let mut results: Vec<repo::Remote> = Vec::new();
for remote_name in remotes.iter() { for remote_name in remotes.iter() {
match repo.find_remote(remote_name)? { match repo.find_remote(remote_name)? {
Some(remote) => { Some(remote) => {
let name = remote.name(); let name = remote.name();
let url = remote.url(); let url = remote.url();
let remote_type = match detect_remote_type(&url) { let remote_type = match repo::detect_remote_type(&url) {
Some(t) => t, Some(t) => t,
None => { None => {
warnings.push(format!( warnings.push(format!(
"{}: Could not detect remote type of \"{}\"", "{}: Could not detect remote type of \"{}\"",
&path_as_string(&path), &path::path_as_string(&path),
&url &url
)); ));
continue; continue;
} }
}; };
results.push(Remote { results.push(repo::Remote {
name, name,
url, url,
remote_type, remote_type,
@@ -470,7 +84,7 @@ fn find_repos(root: &Path) -> Result<Option<(Vec<Repo>, Vec<String>, bool)>, Str
None => { None => {
warnings.push(format!( warnings.push(format!(
"{}: Remote {} not found", "{}: Remote {} not found",
&path_as_string(&path), &path::path_as_string(&path),
remote_name remote_name
)); ));
continue; continue;
@@ -483,7 +97,9 @@ fn find_repos(root: &Path) -> Result<Option<(Vec<Repo>, Vec<String>, bool)>, Str
( (
None, None,
match &root.parent() { match &root.parent() {
Some(parent) => path_as_string(path.strip_prefix(parent).unwrap()), Some(parent) => {
path::path_as_string(path.strip_prefix(parent).unwrap())
}
None => { None => {
warnings.push(String::from("Getting name of the search root failed. Do you have a git repository in \"/\"?")); warnings.push(String::from("Getting name of the search root failed. Do you have a git repository in \"/\"?"));
continue; continue;
@@ -495,15 +111,15 @@ fn find_repos(root: &Path) -> Result<Option<(Vec<Repo>, Vec<String>, bool)>, Str
let namespace = name.parent().unwrap(); let namespace = name.parent().unwrap();
( (
if namespace != Path::new("") { if namespace != Path::new("") {
Some(path_as_string(namespace).to_string()) Some(path::path_as_string(namespace).to_string())
} else { } else {
None None
}, },
path_as_string(name), path::path_as_string(name),
) )
}; };
repos.push(Repo { repos.push(repo::Repo {
name, name,
namespace, namespace,
remotes: Some(remotes), remotes: Some(remotes),
@@ -515,10 +131,10 @@ fn find_repos(root: &Path) -> Result<Option<(Vec<Repo>, Vec<String>, bool)>, Str
Ok(Some((repos, warnings, repo_in_root))) Ok(Some((repos, warnings, repo_in_root)))
} }
pub fn find_in_tree(path: &Path) -> Result<(Tree, Vec<String>), String> { pub fn find_in_tree(path: &Path) -> Result<(tree::Tree, Vec<String>), String> {
let mut warnings = Vec::new(); let mut warnings = Vec::new();
let (repos, repo_in_root): (Vec<Repo>, bool) = match find_repos(path)? { let (repos, repo_in_root): (Vec<repo::Repo>, bool) = match find_repos(path)? {
Some((vec, mut repo_warnings, repo_in_root)) => { Some((vec, mut repo_warnings, repo_in_root)) => {
warnings.append(&mut repo_warnings); warnings.append(&mut repo_warnings);
(vec, repo_in_root) (vec, repo_in_root)
@@ -539,171 +155,10 @@ pub fn find_in_tree(path: &Path) -> Result<(Tree, Vec<String>), String> {
} }
Ok(( Ok((
Tree { tree::Tree {
root: root.into_os_string().into_string().unwrap(), root: root.into_os_string().into_string().unwrap(),
repos, repos,
}, },
warnings, warnings,
)) ))
} }
pub fn add_worktree(
directory: &Path,
name: &str,
subdirectory: Option<&Path>,
track: Option<(&str, &str)>,
no_track: bool,
) -> Result<(), String> {
let repo = RepoHandle::open(directory, true).map_err(|error| match error.kind {
RepoErrorKind::NotFound => {
String::from("Current directory does not contain a worktree setup")
}
_ => format!("Error opening repo: {}", error),
})?;
let config = repo::read_worktree_root_config(directory)?;
if repo.find_worktree(name).is_ok() {
return Err(format!("Worktree {} already exists", &name));
}
let path = match subdirectory {
Some(dir) => directory.join(dir).join(name),
None => directory.join(Path::new(name)),
};
let mut remote_branch_exists = false;
let default_checkout = || repo.default_branch()?.to_commit();
let checkout_commit;
if no_track {
checkout_commit = default_checkout()?;
} else {
match track {
Some((remote_name, remote_branch_name)) => {
let remote_branch = repo.find_remote_branch(remote_name, remote_branch_name);
match remote_branch {
Ok(branch) => {
remote_branch_exists = true;
checkout_commit = branch.to_commit()?;
}
Err(_) => {
remote_branch_exists = false;
checkout_commit = default_checkout()?;
}
}
}
None => match &config {
None => checkout_commit = default_checkout()?,
Some(config) => match &config.track {
None => checkout_commit = default_checkout()?,
Some(track_config) => {
if track_config.default {
let remote_branch =
repo.find_remote_branch(&track_config.default_remote, name);
match remote_branch {
Ok(branch) => {
remote_branch_exists = true;
checkout_commit = branch.to_commit()?;
}
Err(_) => {
checkout_commit = default_checkout()?;
}
}
} else {
checkout_commit = default_checkout()?;
}
}
},
},
};
}
let mut target_branch = match repo.find_local_branch(name) {
Ok(branchref) => branchref,
Err(_) => repo.create_branch(name, &checkout_commit)?,
};
fn push(
remote: &mut repo::RemoteHandle,
branch_name: &str,
remote_branch_name: &str,
repo: &repo::RepoHandle,
) -> Result<(), String> {
if !remote.is_pushable()? {
return Err(format!(
"Cannot push to non-pushable remote {}",
remote.url()
));
}
remote.push(branch_name, remote_branch_name, repo)
}
if !no_track {
if let Some((remote_name, remote_branch_name)) = track {
if remote_branch_exists {
target_branch.set_upstream(remote_name, remote_branch_name)?;
} else {
let mut remote = repo
.find_remote(remote_name)
.map_err(|error| format!("Error getting remote {}: {}", remote_name, error))?
.ok_or_else(|| format!("Remote {} not found", remote_name))?;
push(
&mut remote,
&target_branch.name()?,
remote_branch_name,
&repo,
)?;
target_branch.set_upstream(remote_name, remote_branch_name)?;
}
} else if let Some(config) = config {
if let Some(track_config) = config.track {
if track_config.default {
let remote_name = track_config.default_remote;
if remote_branch_exists {
target_branch.set_upstream(&remote_name, name)?;
} else {
let remote_branch_name = match track_config.default_remote_prefix {
Some(prefix) => {
format!("{}{}{}", &prefix, BRANCH_NAMESPACE_SEPARATOR, &name)
}
None => name.to_string(),
};
let mut remote = repo
.find_remote(&remote_name)
.map_err(|error| {
format!("Error getting remote {}: {}", remote_name, error)
})?
.ok_or_else(|| format!("Remote {} not found", remote_name))?;
if !remote.is_pushable()? {
return Err(format!(
"Cannot push to non-pushable remote {}",
remote.url()
));
}
push(
&mut remote,
&target_branch.name()?,
&remote_branch_name,
&repo,
)?;
target_branch.set_upstream(&remote_name, &remote_branch_name)?;
}
}
}
}
}
if let Some(subdirectory) = subdirectory {
std::fs::create_dir_all(subdirectory).map_err(|error| error.to_string())?;
}
repo.new_worktree(name, &path, &target_branch)?;
Ok(())
}

84
src/path.rs Normal file
View File

@@ -0,0 +1,84 @@
use std::path::{Path, PathBuf};
use std::process;
use super::output::*;
#[cfg(test)]
mod tests {
use super::*;
fn setup() {
std::env::set_var("HOME", "/home/test");
}
#[test]
fn check_expand_tilde() {
setup();
assert_eq!(
expand_path(Path::new("~/file")),
Path::new("/home/test/file")
);
}
#[test]
fn check_expand_invalid_tilde() {
setup();
assert_eq!(
expand_path(Path::new("/home/~/file")),
Path::new("/home/~/file")
);
}
#[test]
fn check_expand_home() {
setup();
assert_eq!(
expand_path(Path::new("$HOME/file")),
Path::new("/home/test/file")
);
assert_eq!(
expand_path(Path::new("${HOME}/file")),
Path::new("/home/test/file")
);
}
}
pub fn path_as_string(path: &Path) -> String {
path.to_path_buf().into_os_string().into_string().unwrap()
}
pub fn env_home() -> PathBuf {
match std::env::var("HOME") {
Ok(path) => Path::new(&path).to_path_buf(),
Err(e) => {
print_error(&format!("Unable to read HOME: {}", e));
process::exit(1);
}
}
}
pub fn expand_path(path: &Path) -> PathBuf {
fn home_dir() -> Option<PathBuf> {
Some(env_home())
}
let expanded_path = match shellexpand::full_with_context(
&path_as_string(path),
home_dir,
|name| -> Result<Option<String>, &'static str> {
match name {
"HOME" => Ok(Some(path_as_string(home_dir().unwrap().as_path()))),
_ => Ok(None),
}
},
) {
Ok(std::borrow::Cow::Borrowed(path)) => path.to_owned(),
Ok(std::borrow::Cow::Owned(path)) => path,
Err(e) => {
print_error(&format!("Unable to expand root: {}", e));
process::exit(1);
}
};
Path::new(&expanded_path).to_path_buf()
}

View File

@@ -1,5 +1,6 @@
use serde::Deserialize; use serde::Deserialize;
use super::escape;
use super::ApiErrorResponse; use super::ApiErrorResponse;
use super::Filter; use super::Filter;
use super::JsonError; use super::JsonError;
@@ -108,7 +109,7 @@ impl Provider for Github {
user: &str, user: &str,
) -> Result<Vec<GithubProject>, ApiErrorResponse<GithubApiErrorResponse>> { ) -> Result<Vec<GithubProject>, ApiErrorResponse<GithubApiErrorResponse>> {
self.call_list( self.call_list(
&format!("{GITHUB_API_BASEURL}/users/{user}/repos"), &format!("{GITHUB_API_BASEURL}/users/{}/repos", escape(user)),
Some(ACCEPT_HEADER_JSON), Some(ACCEPT_HEADER_JSON),
) )
} }
@@ -118,7 +119,7 @@ impl Provider for Github {
group: &str, group: &str,
) -> Result<Vec<GithubProject>, ApiErrorResponse<GithubApiErrorResponse>> { ) -> Result<Vec<GithubProject>, ApiErrorResponse<GithubApiErrorResponse>> {
self.call_list( self.call_list(
&format!("{GITHUB_API_BASEURL}/orgs/{group}/repos?type=all"), &format!("{GITHUB_API_BASEURL}/orgs/{}/repos?type=all", escape(group)),
Some(ACCEPT_HEADER_JSON), Some(ACCEPT_HEADER_JSON),
) )
} }

View File

@@ -1,5 +1,6 @@
use serde::Deserialize; use serde::Deserialize;
use super::escape;
use super::ApiErrorResponse; use super::ApiErrorResponse;
use super::Filter; use super::Filter;
use super::JsonError; use super::JsonError;
@@ -56,13 +57,13 @@ impl Project for GitlabProject {
} }
fn private(&self) -> bool { fn private(&self) -> bool {
matches!(self.visibility, GitlabVisibility::Private) !matches!(self.visibility, GitlabVisibility::Public)
} }
} }
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct GitlabApiErrorResponse { pub struct GitlabApiErrorResponse {
#[serde(alias = "error_description")] #[serde(alias = "error_description", alias = "error")]
pub message: String, pub message: String,
} }
@@ -125,7 +126,7 @@ impl Provider for Gitlab {
user: &str, user: &str,
) -> Result<Vec<GitlabProject>, ApiErrorResponse<GitlabApiErrorResponse>> { ) -> Result<Vec<GitlabProject>, ApiErrorResponse<GitlabApiErrorResponse>> {
self.call_list( self.call_list(
&format!("{}/api/v4/users/{}/projects", self.api_url(), user), &format!("{}/api/v4/users/{}/projects", self.api_url(), escape(user)),
Some(ACCEPT_HEADER_JSON), Some(ACCEPT_HEADER_JSON),
) )
} }
@@ -138,7 +139,7 @@ impl Provider for Gitlab {
&format!( &format!(
"{}/api/v4/groups/{}/projects?include_subgroups=true&archived=false", "{}/api/v4/groups/{}/projects?include_subgroups=true&archived=false",
self.api_url(), self.api_url(),
group escape(group),
), ),
Some(ACCEPT_HEADER_JSON), Some(ACCEPT_HEADER_JSON),
) )

View File

@@ -9,7 +9,7 @@ pub mod gitlab;
pub use github::Github; pub use github::Github;
pub use gitlab::Gitlab; pub use gitlab::Gitlab;
use crate::{Remote, RemoteType, Repo}; use super::repo;
use std::collections::HashMap; use std::collections::HashMap;
@@ -28,16 +28,25 @@ enum ProjectResponse<T, U> {
Failure(U), Failure(U),
} }
pub fn escape(s: &str) -> String {
url_escape::encode_component(s).to_string()
}
pub trait Project { pub trait Project {
fn into_repo_config(self, provider_name: &str, worktree_setup: bool, force_ssh: bool) -> Repo fn into_repo_config(
self,
provider_name: &str,
worktree_setup: bool,
force_ssh: bool,
) -> repo::Repo
where where
Self: Sized, Self: Sized,
{ {
Repo { repo::Repo {
name: self.name(), name: self.name(),
namespace: self.namespace(), namespace: self.namespace(),
worktree_setup, worktree_setup,
remotes: Some(vec![Remote { remotes: Some(vec![repo::Remote {
name: String::from(provider_name), name: String::from(provider_name),
url: if force_ssh || self.private() { url: if force_ssh || self.private() {
self.ssh_url() self.ssh_url()
@@ -45,9 +54,9 @@ pub trait Project {
self.http_url() self.http_url()
}, },
remote_type: if force_ssh || self.private() { remote_type: if force_ssh || self.private() {
RemoteType::Ssh repo::RemoteType::Ssh
} else { } else {
RemoteType::Https repo::RemoteType::Https
}, },
}]), }]),
} }
@@ -201,7 +210,7 @@ pub trait Provider {
&self, &self,
worktree_setup: bool, worktree_setup: bool,
force_ssh: bool, force_ssh: bool,
) -> Result<HashMap<Option<String>, Vec<Repo>>, String> { ) -> Result<HashMap<Option<String>, Vec<repo::Repo>>, String> {
let mut repos = vec![]; let mut repos = vec![];
if self.filter().owner { if self.filter().owner {
@@ -278,7 +287,7 @@ pub trait Provider {
} }
} }
let mut ret: HashMap<Option<String>, Vec<Repo>> = HashMap::new(); let mut ret: HashMap<Option<String>, Vec<repo::Repo>> = HashMap::new();
for repo in repos { for repo in repos {
let namespace = repo.namespace(); let namespace = repo.namespace();

View File

@@ -3,9 +3,13 @@ use std::path::Path;
use git2::Repository; use git2::Repository;
use crate::output::*; use super::output::*;
use super::path;
use super::worktree;
const WORKTREE_CONFIG_FILE_NAME: &str = "grm.toml"; const WORKTREE_CONFIG_FILE_NAME: &str = "grm.toml";
const GIT_CONFIG_BARE_KEY: &str = "core.bare";
const GIT_CONFIG_PUSH_DEFAULT: &str = "push.default";
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
@@ -506,7 +510,7 @@ impl RepoHandle {
false => Repository::open, false => Repository::open,
}; };
let path = match is_worktree { let path = match is_worktree {
true => path.join(crate::GIT_MAIN_WORKTREE_DIRECTORY), true => path.join(worktree::GIT_MAIN_WORKTREE_DIRECTORY),
false => path.to_path_buf(), false => path.to_path_buf(),
}; };
match open_func(path) { match open_func(path) {
@@ -679,7 +683,7 @@ impl RepoHandle {
pub fn init(path: &Path, is_worktree: bool) -> Result<Self, String> { pub fn init(path: &Path, is_worktree: bool) -> Result<Self, String> {
let repo = match is_worktree { let repo = match is_worktree {
false => Repository::init(path).map_err(convert_libgit2_error)?, false => Repository::init(path).map_err(convert_libgit2_error)?,
true => Repository::init_bare(path.join(crate::GIT_MAIN_WORKTREE_DIRECTORY)) true => Repository::init_bare(path.join(worktree::GIT_MAIN_WORKTREE_DIRECTORY))
.map_err(convert_libgit2_error)?, .map_err(convert_libgit2_error)?,
}; };
@@ -742,8 +746,8 @@ impl RepoHandle {
let mut config = self.config()?; let mut config = self.config()?;
config config
.set_bool(crate::GIT_CONFIG_BARE_KEY, value) .set_bool(GIT_CONFIG_BARE_KEY, value)
.map_err(|error| format!("Could not set {}: {}", crate::GIT_CONFIG_BARE_KEY, error)) .map_err(|error| format!("Could not set {}: {}", GIT_CONFIG_BARE_KEY, error))
} }
pub fn convert_to_worktree( pub fn convert_to_worktree(
@@ -766,7 +770,7 @@ impl RepoHandle {
return Err(WorktreeConversionFailureReason::Ignored); return Err(WorktreeConversionFailureReason::Ignored);
} }
std::fs::rename(".git", crate::GIT_MAIN_WORKTREE_DIRECTORY).map_err(|error| { std::fs::rename(".git", worktree::GIT_MAIN_WORKTREE_DIRECTORY).map_err(|error| {
WorktreeConversionFailureReason::Error(format!( WorktreeConversionFailureReason::Error(format!(
"Error moving .git directory: {}", "Error moving .git directory: {}",
error error
@@ -786,7 +790,7 @@ impl RepoHandle {
Ok(entry) => { Ok(entry) => {
let path = entry.path(); let path = entry.path();
// unwrap is safe here, the path will ALWAYS have a file component // unwrap is safe here, the path will ALWAYS have a file component
if path.file_name().unwrap() == crate::GIT_MAIN_WORKTREE_DIRECTORY { if path.file_name().unwrap() == worktree::GIT_MAIN_WORKTREE_DIRECTORY {
continue; continue;
} }
if path.is_file() || path.is_symlink() { if path.is_file() || path.is_symlink() {
@@ -835,18 +839,12 @@ impl RepoHandle {
config config
.set_str( .set_str(
crate::GIT_CONFIG_PUSH_DEFAULT, GIT_CONFIG_PUSH_DEFAULT,
match value { match value {
GitPushDefaultSetting::Upstream => "upstream", GitPushDefaultSetting::Upstream => "upstream",
}, },
) )
.map_err(|error| { .map_err(|error| format!("Could not set {}: {}", GIT_CONFIG_PUSH_DEFAULT, error))
format!(
"Could not set {}: {}",
crate::GIT_CONFIG_PUSH_DEFAULT,
error
)
})
} }
pub fn has_untracked_files(&self, is_worktree: bool) -> Result<bool, String> { pub fn has_untracked_files(&self, is_worktree: bool) -> Result<bool, String> {
@@ -1105,7 +1103,7 @@ impl RepoHandle {
})?; })?;
if branch_name != name if branch_name != name
&& !branch_name.ends_with(&format!("{}{}", crate::BRANCH_NAMESPACE_SEPARATOR, name)) && !branch_name.ends_with(&format!("{}{}", super::BRANCH_NAMESPACE_SEPARATOR, name))
{ {
return Err(WorktreeRemoveFailureReason::Error(format!( return Err(WorktreeRemoveFailureReason::Error(format!(
"Branch {} is checked out in worktree, this does not look correct", "Branch {} is checked out in worktree, this does not look correct",
@@ -1275,7 +1273,7 @@ impl RepoHandle {
let mut unmanaged_worktrees = Vec::new(); let mut unmanaged_worktrees = Vec::new();
for entry in std::fs::read_dir(&directory).map_err(|error| error.to_string())? { for entry in std::fs::read_dir(&directory).map_err(|error| error.to_string())? {
let dirname = crate::path_as_string( let dirname = path::path_as_string(
entry entry
.map_err(|error| error.to_string())? .map_err(|error| error.to_string())?
.path() .path()
@@ -1308,7 +1306,7 @@ impl RepoHandle {
}, },
}; };
if dirname == crate::GIT_MAIN_WORKTREE_DIRECTORY { if dirname == worktree::GIT_MAIN_WORKTREE_DIRECTORY {
continue; continue;
} }
if dirname == WORKTREE_CONFIG_FILE_NAME { if dirname == WORKTREE_CONFIG_FILE_NAME {
@@ -1327,7 +1325,7 @@ impl RepoHandle {
} }
pub fn detect_worktree(path: &Path) -> bool { pub fn detect_worktree(path: &Path) -> bool {
path.join(crate::GIT_MAIN_WORKTREE_DIRECTORY).exists() path.join(worktree::GIT_MAIN_WORKTREE_DIRECTORY).exists()
} }
} }
@@ -1486,7 +1484,7 @@ pub fn clone_repo(
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let clone_target = match is_worktree { let clone_target = match is_worktree {
false => path.to_path_buf(), false => path.to_path_buf(),
true => path.join(crate::GIT_MAIN_WORKTREE_DIRECTORY), true => path.join(worktree::GIT_MAIN_WORKTREE_DIRECTORY),
}; };
print_action(&format!( print_action(&format!(

View File

@@ -1,4 +1,6 @@
use crate::RepoHandle; use super::config;
use super::path;
use super::repo;
use comfy_table::{Cell, Table}; use comfy_table::{Cell, Table};
@@ -21,7 +23,7 @@ fn add_table_header(table: &mut Table) {
fn add_repo_status( fn add_repo_status(
table: &mut Table, table: &mut Table,
repo_name: &str, repo_name: &str,
repo_handle: &crate::RepoHandle, repo_handle: &repo::RepoHandle,
is_worktree: bool, is_worktree: bool,
) -> Result<(), String> { ) -> Result<(), String> {
let repo_status = repo_handle.status(is_worktree)?; let repo_status = repo_handle.status(is_worktree)?;
@@ -65,11 +67,11 @@ fn add_repo_status(
" <{}>{}", " <{}>{}",
remote_branch_name, remote_branch_name,
&match remote_tracking_status { &match remote_tracking_status {
crate::RemoteTrackingStatus::UpToDate => repo::RemoteTrackingStatus::UpToDate =>
String::from(" \u{2714}"), String::from(" \u{2714}"),
crate::RemoteTrackingStatus::Ahead(d) => format!(" [+{}]", &d), repo::RemoteTrackingStatus::Ahead(d) => format!(" [+{}]", &d),
crate::RemoteTrackingStatus::Behind(d) => format!(" [-{}]", &d), repo::RemoteTrackingStatus::Behind(d) => format!(" [-{}]", &d),
crate::RemoteTrackingStatus::Diverged(d1, d2) => repo::RemoteTrackingStatus::Diverged(d1, d2) =>
format!(" [+{}/-{}]", &d1, &d2), format!(" [+{}/-{}]", &d1, &d2),
} }
) )
@@ -99,7 +101,7 @@ fn add_repo_status(
// Don't return table, return a type that implements Display(?) // Don't return table, return a type that implements Display(?)
pub fn get_worktree_status_table( pub fn get_worktree_status_table(
repo: &crate::RepoHandle, repo: &repo::RepoHandle,
directory: &Path, directory: &Path,
) -> Result<(impl std::fmt::Display, Vec<String>), String> { ) -> Result<(impl std::fmt::Display, Vec<String>), String> {
let worktrees = repo.get_worktrees()?; let worktrees = repo.get_worktrees()?;
@@ -111,7 +113,7 @@ pub fn get_worktree_status_table(
for worktree in &worktrees { for worktree in &worktrees {
let worktree_dir = &directory.join(&worktree.name()); let worktree_dir = &directory.join(&worktree.name());
if worktree_dir.exists() { if worktree_dir.exists() {
let repo = match crate::RepoHandle::open(worktree_dir, false) { let repo = match repo::RepoHandle::open(worktree_dir, false) {
Ok(repo) => repo, Ok(repo) => repo,
Err(error) => { Err(error) => {
errors.push(format!( errors.push(format!(
@@ -132,7 +134,7 @@ pub fn get_worktree_status_table(
)); ));
} }
} }
for worktree in RepoHandle::find_unmanaged_worktrees(repo, directory)? { for worktree in repo::RepoHandle::find_unmanaged_worktrees(repo, directory)? {
errors.push(format!( errors.push(format!(
"Found {}, which is not a valid worktree directory!", "Found {}, which is not a valid worktree directory!",
&worktree &worktree
@@ -141,13 +143,13 @@ pub fn get_worktree_status_table(
Ok((table, errors)) Ok((table, errors))
} }
pub fn get_status_table(config: crate::Config) -> Result<(Vec<Table>, Vec<String>), String> { pub fn get_status_table(config: config::Config) -> Result<(Vec<Table>, Vec<String>), String> {
let mut errors = Vec::new(); let mut errors = Vec::new();
let mut tables = Vec::new(); let mut tables = Vec::new();
for tree in config.trees()? { for tree in config.trees()? {
let repos = tree.repos.unwrap_or_default(); let repos = tree.repos.unwrap_or_default();
let root_path = crate::expand_path(Path::new(&tree.root)); let root_path = path::expand_path(Path::new(&tree.root));
let mut table = Table::new(); let mut table = Table::new();
add_table_header(&mut table); add_table_header(&mut table);
@@ -163,12 +165,12 @@ pub fn get_status_table(config: crate::Config) -> Result<(Vec<Table>, Vec<String
continue; continue;
} }
let repo_handle = crate::RepoHandle::open(&repo_path, repo.worktree_setup); let repo_handle = repo::RepoHandle::open(&repo_path, repo.worktree_setup);
let repo_handle = match repo_handle { let repo_handle = match repo_handle {
Ok(repo) => repo, Ok(repo) => repo,
Err(error) => { Err(error) => {
if error.kind == crate::RepoErrorKind::NotFound { if error.kind == repo::RepoErrorKind::NotFound {
errors.push(format!( errors.push(format!(
"{}: No git repository found. Run sync?", "{}: No git repository found. Run sync?",
&repo.name &repo.name
@@ -206,8 +208,8 @@ fn add_worktree_table_header(table: &mut Table) {
fn add_worktree_status( fn add_worktree_status(
table: &mut Table, table: &mut Table,
worktree: &crate::repo::Worktree, worktree: &repo::Worktree,
repo: &crate::RepoHandle, repo: &repo::RepoHandle,
) -> Result<(), String> { ) -> Result<(), String> {
let repo_status = repo.status(false)?; let repo_status = repo.status(false)?;
@@ -272,13 +274,13 @@ pub fn show_single_repo_status(
let mut table = Table::new(); let mut table = Table::new();
let mut warnings = Vec::new(); let mut warnings = Vec::new();
let is_worktree = crate::RepoHandle::detect_worktree(path); let is_worktree = repo::RepoHandle::detect_worktree(path);
add_table_header(&mut table); add_table_header(&mut table);
let repo_handle = crate::RepoHandle::open(path, is_worktree); let repo_handle = repo::RepoHandle::open(path, is_worktree);
if let Err(error) = repo_handle { if let Err(error) = repo_handle {
if error.kind == crate::RepoErrorKind::NotFound { if error.kind == repo::RepoErrorKind::NotFound {
return Err(String::from("Directory is not a git directory")); return Err(String::from("Directory is not a git directory"));
} else { } else {
return Err(format!("Opening repository failed: {}", error)); return Err(format!("Opening repository failed: {}", error));

268
src/tree.rs Normal file
View File

@@ -0,0 +1,268 @@
use std::fs;
use std::path::{Path, PathBuf};
use super::config;
use super::output::*;
use super::path;
use super::repo;
use super::worktree;
pub struct Tree {
pub root: String,
pub repos: Vec<repo::Repo>,
}
pub fn find_unmanaged_repos(
root_path: &Path,
managed_repos: &[repo::Repo],
) -> Result<Vec<PathBuf>, String> {
let mut unmanaged_repos = Vec::new();
for repo_path in find_repo_paths(root_path)? {
if !managed_repos
.iter()
.any(|r| Path::new(root_path).join(r.fullname()) == repo_path)
{
unmanaged_repos.push(repo_path);
}
}
Ok(unmanaged_repos)
}
pub fn sync_trees(config: config::Config, init_worktree: bool) -> Result<bool, String> {
let mut failures = false;
let mut unmanaged_repos_absolute_paths = vec![];
let mut managed_repos_absolute_paths = vec![];
let trees = config.trees()?;
for tree in trees {
let repos: Vec<repo::Repo> = tree
.repos
.unwrap_or_default()
.into_iter()
.map(|repo| repo.into_repo())
.collect();
let root_path = path::expand_path(Path::new(&tree.root));
for repo in &repos {
managed_repos_absolute_paths.push(root_path.join(repo.fullname()));
match sync_repo(&root_path, repo, init_worktree) {
Ok(_) => print_repo_success(&repo.name, "OK"),
Err(error) => {
print_repo_error(&repo.name, &error);
failures = true;
}
}
}
match find_unmanaged_repos(&root_path, &repos) {
Ok(repos) => {
unmanaged_repos_absolute_paths.extend(repos);
}
Err(error) => {
print_error(&format!("Error getting unmanaged repos: {}", error));
failures = true;
}
}
}
for unmanaged_repo_absolute_path in &unmanaged_repos_absolute_paths {
if managed_repos_absolute_paths
.iter()
.any(|managed_repo_absolute_path| {
managed_repo_absolute_path == unmanaged_repo_absolute_path
})
{
continue;
}
print_warning(&format!(
"Found unmanaged repository: \"{}\"",
path::path_as_string(unmanaged_repo_absolute_path)
));
}
Ok(!failures)
}
/// Finds repositories recursively, returning their path
pub fn find_repo_paths(path: &Path) -> Result<Vec<PathBuf>, String> {
let mut repos = Vec::new();
let git_dir = path.join(".git");
let git_worktree = path.join(worktree::GIT_MAIN_WORKTREE_DIRECTORY);
if git_dir.exists() || git_worktree.exists() {
repos.push(path.to_path_buf());
} else {
match fs::read_dir(path) {
Ok(contents) => {
for content in contents {
match content {
Ok(entry) => {
let path = entry.path();
if path.is_symlink() {
continue;
}
if path.is_dir() {
match find_repo_paths(&path) {
Ok(ref mut r) => repos.append(r),
Err(error) => return Err(error),
}
}
}
Err(e) => {
return Err(format!("Error accessing directory: {}", e));
}
};
}
}
Err(e) => {
return Err(format!(
"Failed to open \"{}\": {}",
&path.display(),
match e.kind() {
std::io::ErrorKind::NotADirectory =>
String::from("directory expected, but path is not a directory"),
std::io::ErrorKind::NotFound => String::from("not found"),
_ => format!("{:?}", e.kind()),
}
));
}
};
}
Ok(repos)
}
fn sync_repo(root_path: &Path, repo: &repo::Repo, init_worktree: bool) -> Result<(), String> {
let repo_path = root_path.join(&repo.fullname());
let actual_git_directory = get_actual_git_directory(&repo_path, repo.worktree_setup);
let mut newly_created = false;
if repo_path.exists() {
if repo.worktree_setup && !actual_git_directory.exists() {
return Err(String::from(
"Repo already exists, but is not using a worktree setup",
));
};
} else if matches!(&repo.remotes, None) || repo.remotes.as_ref().unwrap().is_empty() {
print_repo_action(
&repo.name,
"Repository does not have remotes configured, initializing new",
);
match repo::RepoHandle::init(&repo_path, repo.worktree_setup) {
Ok(r) => {
print_repo_success(&repo.name, "Repository created");
Some(r)
}
Err(e) => {
return Err(format!("Repository failed during init: {}", e));
}
};
} else {
let first = repo.remotes.as_ref().unwrap().first().unwrap();
match repo::clone_repo(first, &repo_path, repo.worktree_setup) {
Ok(_) => {
print_repo_success(&repo.name, "Repository successfully cloned");
}
Err(e) => {
return Err(format!("Repository failed during clone: {}", e));
}
};
newly_created = true;
}
let repo_handle = match repo::RepoHandle::open(&repo_path, repo.worktree_setup) {
Ok(repo) => repo,
Err(error) => {
if !repo.worktree_setup && repo::RepoHandle::open(&repo_path, true).is_ok() {
return Err(String::from(
"Repo already exists, but is using a worktree setup",
));
} else {
return Err(format!("Opening repository failed: {}", error));
}
}
};
if newly_created && repo.worktree_setup && init_worktree {
match repo_handle.default_branch() {
Ok(branch) => {
worktree::add_worktree(&repo_path, &branch.name()?, None, None, false)?;
}
Err(_error) => print_repo_error(
&repo.name,
"Could not determine default branch, skipping worktree initializtion",
),
}
}
if let Some(remotes) = &repo.remotes {
let current_remotes: Vec<String> = repo_handle
.remotes()
.map_err(|error| format!("Repository failed during getting the remotes: {}", error))?;
for remote in remotes {
let current_remote = repo_handle.find_remote(&remote.name)?;
match current_remote {
Some(current_remote) => {
let current_url = current_remote.url();
if remote.url != current_url {
print_repo_action(
&repo.name,
&format!("Updating remote {} to \"{}\"", &remote.name, &remote.url),
);
if let Err(e) = repo_handle.remote_set_url(&remote.name, &remote.url) {
return Err(format!("Repository failed during setting of the remote URL for remote \"{}\": {}", &remote.name, e));
};
}
}
None => {
print_repo_action(
&repo.name,
&format!(
"Setting up new remote \"{}\" to \"{}\"",
&remote.name, &remote.url
),
);
if let Err(e) = repo_handle.new_remote(&remote.name, &remote.url) {
return Err(format!(
"Repository failed during setting the remotes: {}",
e
));
}
}
}
}
for current_remote in &current_remotes {
if !remotes.iter().any(|r| &r.name == current_remote) {
print_repo_action(
&repo.name,
&format!("Deleting remote \"{}\"", &current_remote,),
);
if let Err(e) = repo_handle.remote_delete(current_remote) {
return Err(format!(
"Repository failed during deleting remote \"{}\": {}",
&current_remote, e
));
}
}
}
}
Ok(())
}
fn get_actual_git_directory(path: &Path, is_worktree: bool) -> PathBuf {
match is_worktree {
false => path.to_path_buf(),
true => path.join(worktree::GIT_MAIN_WORKTREE_DIRECTORY),
}
}

166
src/worktree.rs Normal file
View File

@@ -0,0 +1,166 @@
use std::path::Path;
use super::repo;
pub const GIT_MAIN_WORKTREE_DIRECTORY: &str = ".git-main-working-tree";
pub fn add_worktree(
directory: &Path,
name: &str,
subdirectory: Option<&Path>,
track: Option<(&str, &str)>,
no_track: bool,
) -> Result<(), String> {
let repo = repo::RepoHandle::open(directory, true).map_err(|error| match error.kind {
repo::RepoErrorKind::NotFound => {
String::from("Current directory does not contain a worktree setup")
}
_ => format!("Error opening repo: {}", error),
})?;
let config = repo::read_worktree_root_config(directory)?;
if repo.find_worktree(name).is_ok() {
return Err(format!("Worktree {} already exists", &name));
}
let path = match subdirectory {
Some(dir) => directory.join(dir).join(name),
None => directory.join(Path::new(name)),
};
let mut remote_branch_exists = false;
let default_checkout = || repo.default_branch()?.to_commit();
let checkout_commit;
if no_track {
checkout_commit = default_checkout()?;
} else {
match track {
Some((remote_name, remote_branch_name)) => {
let remote_branch = repo.find_remote_branch(remote_name, remote_branch_name);
match remote_branch {
Ok(branch) => {
remote_branch_exists = true;
checkout_commit = branch.to_commit()?;
}
Err(_) => {
remote_branch_exists = false;
checkout_commit = default_checkout()?;
}
}
}
None => match &config {
None => checkout_commit = default_checkout()?,
Some(config) => match &config.track {
None => checkout_commit = default_checkout()?,
Some(track_config) => {
if track_config.default {
let remote_branch =
repo.find_remote_branch(&track_config.default_remote, name);
match remote_branch {
Ok(branch) => {
remote_branch_exists = true;
checkout_commit = branch.to_commit()?;
}
Err(_) => {
checkout_commit = default_checkout()?;
}
}
} else {
checkout_commit = default_checkout()?;
}
}
},
},
};
}
let mut target_branch = match repo.find_local_branch(name) {
Ok(branchref) => branchref,
Err(_) => repo.create_branch(name, &checkout_commit)?,
};
fn push(
remote: &mut repo::RemoteHandle,
branch_name: &str,
remote_branch_name: &str,
repo: &repo::RepoHandle,
) -> Result<(), String> {
if !remote.is_pushable()? {
return Err(format!(
"Cannot push to non-pushable remote {}",
remote.url()
));
}
remote.push(branch_name, remote_branch_name, repo)
}
if !no_track {
if let Some((remote_name, remote_branch_name)) = track {
if remote_branch_exists {
target_branch.set_upstream(remote_name, remote_branch_name)?;
} else {
let mut remote = repo
.find_remote(remote_name)
.map_err(|error| format!("Error getting remote {}: {}", remote_name, error))?
.ok_or_else(|| format!("Remote {} not found", remote_name))?;
push(
&mut remote,
&target_branch.name()?,
remote_branch_name,
&repo,
)?;
target_branch.set_upstream(remote_name, remote_branch_name)?;
}
} else if let Some(config) = config {
if let Some(track_config) = config.track {
if track_config.default {
let remote_name = track_config.default_remote;
if remote_branch_exists {
target_branch.set_upstream(&remote_name, name)?;
} else {
let remote_branch_name = match track_config.default_remote_prefix {
Some(prefix) => {
format!("{}{}{}", &prefix, super::BRANCH_NAMESPACE_SEPARATOR, &name)
}
None => name.to_string(),
};
let mut remote = repo
.find_remote(&remote_name)
.map_err(|error| {
format!("Error getting remote {}: {}", remote_name, error)
})?
.ok_or_else(|| format!("Remote {} not found", remote_name))?;
if !remote.is_pushable()? {
return Err(format!(
"Cannot push to non-pushable remote {}",
remote.url()
));
}
push(
&mut remote,
&target_branch.name()?,
&remote_branch_name,
&repo,
)?;
target_branch.set_upstream(&remote_name, &remote_branch_name)?;
}
}
}
}
}
if let Some(subdirectory) = subdirectory {
std::fs::create_dir_all(subdirectory).map_err(|error| error.to_string())?;
}
repo.new_worktree(name, &path, &target_branch)?;
Ok(())
}