Compare commits
114 Commits
fd6ffc9645
...
renovate/r
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b8a11da36 | |||
|
38a51f3aa7
|
|||
|
43aac23453
|
|||
|
f90dc1e8d5
|
|||
| aef7b65e0c | |||
| 313b971c74 | |||
| 167d71024d | |||
|
|
e2102bfd99 | ||
| 716adfab8b | |||
| c77a9dcbef | |||
| 7077c623ca | |||
| 80614fa667 | |||
| 6ff93b100a | |||
| 76b617fc2d | |||
| d699432f14 | |||
| 5c46cca96f | |||
| 8d071981a3 | |||
| 8f9e40b98f | |||
| 98d4402461 | |||
| 816a65a04c | |||
| d893e7e21a | |||
| 2a3c4fbb83 | |||
| 9d459f10d5 | |||
| 5aa5702272 | |||
| 45b45420f8 | |||
| b49da1ca71 | |||
| 3c63992632 | |||
| aaf9cd414e | |||
| 0f30aaadf5 | |||
|
84941de931
|
|||
|
aa1ef7041e
|
|||
|
2f501c0efd
|
|||
|
962425c515
|
|||
|
708f8d6388
|
|||
|
|
5e97f68a77 | ||
| eb684ef8e9 | |||
| 176dc9f094 | |||
| 1b9f8d96fc | |||
|
|
69d008feb6 | ||
|
ada020e283
|
|||
| 708180f9a2 | |||
| 33ba27bf77 | |||
|
74a1daac4c
|
|||
| 3bd243b45c | |||
| 15620da103 | |||
| a228ec82a3 | |||
| 503680d81c | |||
| d13dc5b0a2 | |||
| a1a993957b | |||
| 916beffb6d | |||
| 291d056dd7 | |||
| dfacf96967 | |||
| 3524847d9f | |||
| 48b77deb5c | |||
| c3f626cce0 | |||
| 52d522107d | |||
| 4dd8e58122 | |||
| aa399e11c1 | |||
| e670dc0384 | |||
| 8eb2970f5d | |||
| f94ef8e919 | |||
| 62c9166677 | |||
| 5e859f99a9 | |||
| 4d78609839 | |||
| 90b7fe9b03 | |||
| 79ad2e872c | |||
| ab963600f3 | |||
| 1aab9c2970 | |||
| 9dfcb52010 | |||
| 659f2247ff | |||
| 76f82f9060 | |||
| bdfd001458 | |||
| f4a2b52c72 | |||
| aa78933f0e | |||
| b242128d52 | |||
|
17cb06904f
|
|||
| e3292b0c73 | |||
|
cc70131101
|
|||
| 784c7303a5 | |||
| bf6d637095 | |||
|
7231c85448
|
|||
|
b5c3c9bac9
|
|||
|
1a2958a6e3
|
|||
|
1f5ad2a216
|
|||
|
|
f117f74130 | ||
| 4c91bb7242 | |||
|
348e448ce9
|
|||
|
f0f81f8a0b
|
|||
|
c9aacf0ecd
|
|||
|
866a8b4c52
|
|||
|
5900482b56
|
|||
|
96d97a8167
|
|||
|
c2faf6d0b6
|
|||
|
a330e4454e
|
|||
|
1eee1d9f3e
|
|||
|
fbe030aba0
|
|||
|
fc66692f37
|
|||
|
bc4ebed1f7
|
|||
|
bee38a9d12
|
|||
|
e2be9ba59a
|
|||
|
5401f3707d
|
|||
| d0a5da0946 | |||
| 8410453921 | |||
| c7b9f75a0b | |||
| 43a7196cf8 | |||
| 39e1fea36f | |||
| c2dfd020bf | |||
| 37ae70bc56 | |||
| 95fa4128ca | |||
| 55fff9612e | |||
| 102af558f5 | |||
| ff8103c805 | |||
| 6773122076 | |||
| 1520374a39 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
|||||||
target/
|
target/
|
||||||
.cuddle/
|
.cuddle/
|
||||||
|
.DS_Store
|
||||||
|
|||||||
157
CHANGELOG.md
157
CHANGELOG.md
@@ -6,6 +6,163 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.3.5] - 2025-03-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- allow clone all
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- *(deps)* update rust crate serde to v1.0.218
|
||||||
|
- *(deps)* update tokio-prost monorepo to v0.13.5
|
||||||
|
- *(deps)* update rust crate termwiz to 0.23.0
|
||||||
|
- *(deps)* update all dependencies
|
||||||
|
- *(deps)* update rust crate uuid to v1.13.0
|
||||||
|
- *(deps)* update all dependencies
|
||||||
|
- *(deps)* update rust crate async-trait to v0.1.86
|
||||||
|
- *(deps)* update rust crate uuid to v1.12.1
|
||||||
|
- *(deps)* update rust crate octocrab to 0.43.0
|
||||||
|
- *(deps)* update rust crate uuid to v1.12.0
|
||||||
|
- *(deps)* update rust crate dirs to v6
|
||||||
|
- *(deps)* update rust crate uuid to v1.11.1
|
||||||
|
|
||||||
|
### Other
|
||||||
|
- *(deps)* update all dependencies
|
||||||
|
- *(deps)* update all dependencies
|
||||||
|
- *(deps)* update rust crate clap to v4.5.29
|
||||||
|
- *(deps)* update rust crate clap to v4.5.27
|
||||||
|
- *(deps)* update rust crate clap to v4.5.26
|
||||||
|
- *(deps)* update rust crate tokio to v1.43.0
|
||||||
|
- update example
|
||||||
|
|
||||||
|
## [0.3.4] - 2025-01-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- feat/add-post-clone-command
|
||||||
|
|
||||||
|
- add ability to specify custom command
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- use correct post clone command
|
||||||
|
- tests for config
|
||||||
|
|
||||||
|
### Other
|
||||||
|
- add ability to specify multiple commands
|
||||||
|
|
||||||
|
|
||||||
|
## [0.3.3] - 2025-01-07
|
||||||
|
|
||||||
|
### Other
|
||||||
|
- replace dotenv with dotenvy, a slightly more maintained version of the same library (#50)
|
||||||
|
|
||||||
|
## [0.3.2] - 2025-01-07
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- *(deps)* update rust crate async-trait to v0.1.85
|
||||||
|
|
||||||
|
## [0.3.1] - 2025-01-02
|
||||||
|
|
||||||
|
### Other
|
||||||
|
- enable publish
|
||||||
|
|
||||||
|
## [0.3.0] - 2025-01-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- add small help to see how much time is left in cache
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- *(deps)* update rust crate serde to v1.0.217
|
||||||
|
- *(deps)* update rust crate serde to v1.0.216
|
||||||
|
- *(deps)* update tokio-prost monorepo to v0.13.4
|
||||||
|
- *(deps)* update rust crate bytes to v1.9.0
|
||||||
|
- *(deps)* update all dependencies
|
||||||
|
- *(deps)* update rust crate octocrab to 0.42.0
|
||||||
|
- *(deps)* update rust crate serde to v1.0.215
|
||||||
|
- *(deps)* update rust crate url to v2.5.3
|
||||||
|
- *(deps)* update rust crate serde to v1.0.214
|
||||||
|
- *(deps)* update rust crate serde to v1.0.213
|
||||||
|
- *(deps)* update all dependencies
|
||||||
|
- *(deps)* update all dependencies
|
||||||
|
- *(deps)* update rust crate octocrab to v0.41.1
|
||||||
|
- *(deps)* update rust crate futures to v0.3.31
|
||||||
|
- *(deps)* update rust crate octocrab to 0.41.0
|
||||||
|
|
||||||
|
### Other
|
||||||
|
- bump default cache duration to 7 days
|
||||||
|
- *(deps)* update rust crate anyhow to v1.0.95
|
||||||
|
- *(deps)* update rust crate clap to v4.5.23
|
||||||
|
- *(deps)* update all dependencies
|
||||||
|
- *(deps)* update rust crate tracing-subscriber to v0.3.19
|
||||||
|
- *(deps)* update rust crate tracing to v0.1.41
|
||||||
|
- *(deps)* update rust crate clap to v4.5.21
|
||||||
|
- *(deps)* update rust crate tokio to v1.41.1
|
||||||
|
- *(deps)* update rust crate anyhow to v1.0.93
|
||||||
|
- *(deps)* update rust crate anyhow to v1.0.92
|
||||||
|
- *(deps)* update all dependencies to v1.0.91
|
||||||
|
- *(deps)* update rust crate anyhow to v1.0.90
|
||||||
|
- *(deps)* update rust crate clap to v4.5.20
|
||||||
|
- *(deps)* update rust crate clap to v4.5.19
|
||||||
|
|
||||||
|
## [0.2.3] - 2024-09-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- add update command
|
||||||
|
- only do clone if not exists
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- *(deps)* update rust crate async-trait to v0.1.83
|
||||||
|
- *(deps)* update rust crate octocrab to 0.40.0
|
||||||
|
|
||||||
|
## [0.2.2] - 2024-09-23
|
||||||
|
|
||||||
|
### Other
|
||||||
|
- add docs
|
||||||
|
|
||||||
|
main@origin
|
||||||
|
|
||||||
|
- add license
|
||||||
|
- update to gitea-client
|
||||||
|
- add publish
|
||||||
|
- *(release)* 0.2.1
|
||||||
|
|
||||||
|
## [0.2.1] - 2024-09-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- use termwiz as backend as that enables a ptty, which can be cleaned up nicely
|
||||||
|
- add errout for interactive for script support and atty for clean output
|
||||||
|
- add clone spinner
|
||||||
|
- add spinner around download
|
||||||
|
- spawn a subshell for session
|
||||||
|
- implement git clone
|
||||||
|
- include vhs demo
|
||||||
|
- add interactive search
|
||||||
|
- implement naive fuzzy matcher
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- *(deps)* update tokio-prost monorepo to v0.13.3
|
||||||
|
- *(deps)* update rust crate bytes to v1.7.2
|
||||||
|
|
||||||
|
### Other
|
||||||
|
- update gif to include spinner
|
||||||
|
- clean up ui
|
||||||
|
- build in cuddle instead of vhs
|
||||||
|
- build first then run
|
||||||
|
- clear screen after build
|
||||||
|
- fix warnings
|
||||||
|
- update theme for vhs
|
||||||
|
- *(deps)* update rust crate clap to v4.5.18
|
||||||
|
- *(deps)* update rust crate pretty_assertions to v1.4.1
|
||||||
|
- refactor fuzzy match into own function
|
||||||
|
- cleanup warnings
|
||||||
|
- move fuzzy search out of command
|
||||||
|
- refactor/matcher move to a separate file
|
||||||
|
|
||||||
|
- move fuzzy search out of command
|
||||||
|
- Actually add fuzzy matcher
|
||||||
|
|
||||||
|
- extract matcher
|
||||||
|
- update dependencies
|
||||||
|
- *(deps)* update rust crate anyhow to v1.0.89
|
||||||
|
|
||||||
## [0.2.0] - 2024-09-14
|
## [0.2.0] - 2024-09-14
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
2513
Cargo.lock
generated
2513
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -3,14 +3,13 @@ members = ["crates/*"]
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.2.0"
|
version = "0.3.5"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
gitnow = { path = "crates/gitnow" }
|
|
||||||
|
|
||||||
anyhow = { version = "1" }
|
anyhow = { version = "1" }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
tracing = { version = "0.1", features = ["log"] }
|
tracing = { version = "0.1", features = ["log"] }
|
||||||
tracing-subscriber = { version = "0.3.18" }
|
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
|
||||||
clap = { version = "4", features = ["derive", "env"] }
|
clap = { version = "4", features = ["derive", "env"] }
|
||||||
dotenv = { version = "0.15" }
|
dotenvy = { version = "0.15.7" }
|
||||||
|
|||||||
92
README.md
92
README.md
@@ -1,7 +1,27 @@
|
|||||||
# Git Now
|
# Git Now
|
||||||
|
|
||||||
|
> https://gitnow.kjuulh.io/
|
||||||
|
|
||||||
Git Now is a utility for easily navigating git projects from common upstream providers. Search, Download, and Enter projects as quickly as you can type.
|
Git Now is a utility for easily navigating git projects from common upstream providers. Search, Download, and Enter projects as quickly as you can type.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo (b)install gitnow
|
||||||
|
|
||||||
|
# You can either use gitnow directly (and use spawned shell sessions)
|
||||||
|
gitnow
|
||||||
|
|
||||||
|
# Or install gitnow scripts (in your .bashrc, .zshrc) this will use native shell commands to move you around
|
||||||
|
eval $(gitnow init zsh)
|
||||||
|
git-now # Long
|
||||||
|
gn # Short alias
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reasoning
|
||||||
|
|
||||||
How many steps do you normally do to download a project?
|
How many steps do you normally do to download a project?
|
||||||
|
|
||||||
1. Navigate to github.com
|
1. Navigate to github.com
|
||||||
@@ -18,3 +38,75 @@ With gitnow
|
|||||||
1. `git now`
|
1. `git now`
|
||||||
2. Enter parts of the project name and press enter
|
2. Enter parts of the project name and press enter
|
||||||
3. Your project is automatically downloaded if it doesn't exist in an opinionated path dir, and move you there.
|
3. Your project is automatically downloaded if it doesn't exist in an opinionated path dir, and move you there.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Configuration lives at `~/.config/gitnow/gitnow.toml` (override with `$GITNOW_CONFIG`).
|
||||||
|
|
||||||
|
### Custom clone command
|
||||||
|
|
||||||
|
By default gitnow uses `git clone`. You can override this with any command using a [minijinja](https://docs.rs/minijinja) template:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[settings]
|
||||||
|
# Use jj (Jujutsu) instead of git
|
||||||
|
clone_command = "jj git clone {{ ssh_url }} {{ path }}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Available template variables: `ssh_url`, `path`.
|
||||||
|
|
||||||
|
### Worktrees
|
||||||
|
|
||||||
|
gitnow supports git worktrees (or jj workspaces) via the `worktree` subcommand. This uses bare repositories so each branch gets its own directory as a sibling:
|
||||||
|
|
||||||
|
```
|
||||||
|
~/git/github.com/owner/repo/
|
||||||
|
├── .bare/ # bare clone (git clone --bare)
|
||||||
|
├── main/ # worktree for main branch
|
||||||
|
├── feature-login/ # worktree for feature/login branch
|
||||||
|
└── fix-typo/ # worktree for fix/typo branch
|
||||||
|
```
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Interactive: pick repo, then pick branch
|
||||||
|
gitnow worktree
|
||||||
|
|
||||||
|
# Pre-filter repo
|
||||||
|
gitnow worktree myproject
|
||||||
|
|
||||||
|
# Specify branch directly
|
||||||
|
gitnow worktree myproject -b feature/login
|
||||||
|
|
||||||
|
# Print worktree path instead of entering a shell
|
||||||
|
gitnow worktree myproject -b main --no-shell
|
||||||
|
```
|
||||||
|
|
||||||
|
All worktree commands are configurable via minijinja templates:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[settings.worktree]
|
||||||
|
# Default: "git clone --bare {{ ssh_url }} {{ bare_path }}"
|
||||||
|
clone_command = "git clone --bare {{ ssh_url }} {{ bare_path }}"
|
||||||
|
|
||||||
|
# Default: "git -C {{ bare_path }} worktree add {{ worktree_path }} {{ branch }}"
|
||||||
|
add_command = "git -C {{ bare_path }} worktree add {{ worktree_path }} {{ branch }}"
|
||||||
|
|
||||||
|
# Default: "git -C {{ bare_path }} branch --format=%(refname:short)"
|
||||||
|
list_branches_command = "git -C {{ bare_path }} branch --format=%(refname:short)"
|
||||||
|
```
|
||||||
|
|
||||||
|
For jj, you might use:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[settings]
|
||||||
|
clone_command = "jj git clone {{ ssh_url }} {{ path }}"
|
||||||
|
|
||||||
|
[settings.worktree]
|
||||||
|
clone_command = "jj git clone {{ ssh_url }} {{ bare_path }}"
|
||||||
|
add_command = "jj -R {{ bare_path }} workspace add --name {{ branch }} {{ worktree_path }}"
|
||||||
|
list_branches_command = "jj -R {{ bare_path }} bookmark list -T 'name ++ \"\\n\"'"
|
||||||
|
```
|
||||||
|
|
||||||
|
Available template variables for worktree commands: `bare_path`, `worktree_path`, `branch`, `ssh_url`.
|
||||||
|
|||||||
BIN
assets/gifs/example.gif
Normal file
BIN
assets/gifs/example.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 452 KiB |
1
crates/gitnow/.gitignore
vendored
1
crates/gitnow/.gitignore
vendored
@@ -1 +0,0 @@
|
|||||||
/target
|
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "gitnow"
|
name = "gitnow"
|
||||||
edition = "2021"
|
description = "Git Now is a utility for easily navigating git projects from common upstream providers. Search, Download, and Enter projects as quickly as you can type."
|
||||||
|
edition = "2024"
|
||||||
|
readme = "../../README.md"
|
||||||
|
repository = "https://github.com/kjuulh/gitnow"
|
||||||
|
homepage = "https://gitnow-client.prod.kjuulh.app"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
|
publish = true
|
||||||
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
@@ -10,20 +17,31 @@ tokio.workspace = true
|
|||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
dotenv.workspace = true
|
dotenvy.workspace = true
|
||||||
|
|
||||||
serde = { version = "1.0.197", features = ["derive"] }
|
serde = { version = "1.0.197", features = ["derive"] }
|
||||||
uuid = { version = "1.7.0", features = ["v4"] }
|
uuid = { version = "1.7.0", features = ["v4"] }
|
||||||
async-trait = "0.1.82"
|
async-trait = "0.1.82"
|
||||||
toml = "0.8.19"
|
toml = "0.8.19"
|
||||||
|
|
||||||
gitea-rs = { git = "https://git.front.kjuulh.io/kjuulh/gitea-rs", version = "1.22.1" }
|
gitea-client = { version = "1.22.1" }
|
||||||
url = "2.5.2"
|
url = "2.5.2"
|
||||||
octocrab = "0.39.0"
|
octocrab = "0.43.0"
|
||||||
dirs = "5.0.1"
|
dirs = "6.0.0"
|
||||||
prost = "0.13.2"
|
prost = "0.13.2"
|
||||||
prost-types = "0.13.2"
|
prost-types = "0.13.2"
|
||||||
bytes = "1.7.1"
|
bytes = "1.7.1"
|
||||||
|
nucleo-matcher = "0.3.1"
|
||||||
|
ratatui = { version = "0.29.0", features = ["termwiz"] }
|
||||||
|
crossterm = { version = "0.28.0", features = ["event-stream"] }
|
||||||
|
futures = "0.3.30"
|
||||||
|
termwiz = "0.23.0"
|
||||||
|
regex = "1.11.1"
|
||||||
|
minijinja = "2"
|
||||||
|
shell-words = "1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
pretty_assertions = "1.4.0"
|
pretty_assertions = "1.4.0"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
example = []
|
||||||
|
|||||||
22
crates/gitnow/include/shell/zsh.sh
Normal file
22
crates/gitnow/include/shell/zsh.sh
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
function git-now {
|
||||||
|
# Run an update in the background
|
||||||
|
(
|
||||||
|
nohup gitnow update > /dev/null 2>&1 &
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find the repository of choice
|
||||||
|
choice=$(gitnow "$@" --no-shell)
|
||||||
|
if [[ $? -ne 0 ]]; then
|
||||||
|
return $?
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Enter local repository path
|
||||||
|
cd "$(echo "$choice" | tail --lines 1)"
|
||||||
|
}
|
||||||
|
|
||||||
|
function gn {
|
||||||
|
git-now "$@"
|
||||||
|
if [[ $? -ne 0 ]]; then
|
||||||
|
return $?
|
||||||
|
fi
|
||||||
|
}
|
||||||
@@ -79,6 +79,11 @@ impl Cache {
|
|||||||
tracing::debug!("cache has expired");
|
tracing::debug!("cache has expired");
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
"cache is valid for: {} mins",
|
||||||
|
cache_duration.saturating_sub(file_modified_last).as_secs() / 60
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,84 @@
|
|||||||
pub mod root;
|
pub mod root;
|
||||||
|
pub mod shell;
|
||||||
|
pub mod update;
|
||||||
|
pub mod worktree;
|
||||||
|
pub mod clone {
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use futures::future::join_all;
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
app::App, cache::CacheApp, custom_command::CustomCommandApp, git_clone::GitCloneApp,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(clap::Parser)]
|
||||||
|
pub struct CloneCommand {
|
||||||
|
#[arg(long = "search")]
|
||||||
|
search: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CloneCommand {
|
||||||
|
pub async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> {
|
||||||
|
let repos = app.cache().get().await?.ok_or(anyhow::anyhow!(
|
||||||
|
"failed to get cache, do a gitnow update first"
|
||||||
|
))?;
|
||||||
|
|
||||||
|
let search = Regex::new(&self.search)?;
|
||||||
|
|
||||||
|
let filtered_repos = repos
|
||||||
|
.iter()
|
||||||
|
.filter(|r| search.is_match(&r.to_rel_path().display().to_string()))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let concurrency_limit = Arc::new(tokio::sync::Semaphore::new(5));
|
||||||
|
let mut handles = Vec::default();
|
||||||
|
for repo in filtered_repos {
|
||||||
|
let config = app.config.clone();
|
||||||
|
let custom_command = app.custom_command();
|
||||||
|
let git_clone = app.git_clone();
|
||||||
|
let repo = repo.clone();
|
||||||
|
let concurrency = Arc::clone(&concurrency_limit);
|
||||||
|
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
let permit = concurrency.acquire().await?;
|
||||||
|
|
||||||
|
let project_path = config.settings.projects.directory.join(repo.to_rel_path());
|
||||||
|
if !project_path.exists() {
|
||||||
|
eprintln!("cloning repository: {}", repo.to_rel_path().display());
|
||||||
|
git_clone.clone_repo(&repo, false).await?;
|
||||||
|
|
||||||
|
custom_command
|
||||||
|
.execute_post_clone_command(&project_path)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(permit);
|
||||||
|
|
||||||
|
Ok::<(), anyhow::Error>(())
|
||||||
|
});
|
||||||
|
|
||||||
|
handles.push(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = join_all(handles).await;
|
||||||
|
|
||||||
|
for res in res {
|
||||||
|
match res {
|
||||||
|
Ok(Ok(())) => {}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
tracing::error!("failed to clone repo: {}", e);
|
||||||
|
anyhow::bail!(e)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("failed to clone repo: {}", e);
|
||||||
|
anyhow::bail!(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,17 @@
|
|||||||
use crate::{app::App, cache::CacheApp, projects_list::ProjectsListApp};
|
use std::{collections::BTreeMap, io::IsTerminal};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
app::App,
|
||||||
|
cache::CacheApp,
|
||||||
|
components::inline_command::InlineCommand,
|
||||||
|
custom_command::CustomCommandApp,
|
||||||
|
fuzzy_matcher::{FuzzyMatcher, FuzzyMatcherApp},
|
||||||
|
git_clone::GitCloneApp,
|
||||||
|
git_provider::Repository,
|
||||||
|
interactive::InteractiveApp,
|
||||||
|
projects_list::ProjectsListApp,
|
||||||
|
shell::ShellApp,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct RootCommand {
|
pub struct RootCommand {
|
||||||
@@ -10,11 +23,20 @@ impl RootCommand {
|
|||||||
Self { app }
|
Self { app }
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip(self))]
|
pub async fn execute(
|
||||||
pub async fn execute(&mut self) -> anyhow::Result<()> {
|
&mut self,
|
||||||
|
search: Option<impl Into<String>>,
|
||||||
|
cache: bool,
|
||||||
|
clone: bool,
|
||||||
|
shell: bool,
|
||||||
|
force_refresh: bool,
|
||||||
|
force_cache_update: bool,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
tracing::debug!("executing");
|
tracing::debug!("executing");
|
||||||
|
|
||||||
let repositories = match self.app.cache().get().await? {
|
let repositories = if !force_cache_update {
|
||||||
|
if cache {
|
||||||
|
match self.app.cache().get().await? {
|
||||||
Some(repos) => repos,
|
Some(repos) => repos,
|
||||||
None => {
|
None => {
|
||||||
tracing::info!("finding repositories...");
|
tracing::info!("finding repositories...");
|
||||||
@@ -24,14 +46,145 @@ impl RootCommand {
|
|||||||
|
|
||||||
repositories
|
repositories
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.app.projects_list().get_projects().await?
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::info!("forcing cache update...");
|
||||||
|
let repositories = self.app.projects_list().get_projects().await?;
|
||||||
|
|
||||||
|
self.app.cache().update(&repositories).await?;
|
||||||
|
|
||||||
|
repositories
|
||||||
};
|
};
|
||||||
|
|
||||||
for repo in &repositories {
|
let repo = match search {
|
||||||
//tracing::info!("repo: {}", repo.to_rel_path().display());
|
Some(needle) => {
|
||||||
|
let matched_repos = self
|
||||||
|
.app
|
||||||
|
.fuzzy_matcher()
|
||||||
|
.match_repositories(&needle.into(), &repositories);
|
||||||
|
|
||||||
|
let repo = matched_repos
|
||||||
|
.first()
|
||||||
|
.ok_or(anyhow::anyhow!("failed to find repository"))?;
|
||||||
|
tracing::debug!("selected repo: {}", repo.to_rel_path().display());
|
||||||
|
|
||||||
|
repo.to_owned()
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let repo = self
|
||||||
|
.app
|
||||||
|
.interactive()
|
||||||
|
.interactive_search(&repositories)?
|
||||||
|
.ok_or(anyhow::anyhow!("failed to find a repository"))?;
|
||||||
|
|
||||||
|
tracing::debug!("selected repo: {}", repo.to_rel_path().display());
|
||||||
|
|
||||||
|
repo
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let project_path = self
|
||||||
|
.app
|
||||||
|
.config
|
||||||
|
.settings
|
||||||
|
.projects
|
||||||
|
.directory
|
||||||
|
.join(repo.to_rel_path());
|
||||||
|
if !project_path.exists() {
|
||||||
|
if clone {
|
||||||
|
let git_clone = self.app.git_clone();
|
||||||
|
|
||||||
|
if std::io::stdout().is_terminal() && shell {
|
||||||
|
let mut wrap_cmd =
|
||||||
|
InlineCommand::new(format!("cloning: {}", repo.to_rel_path().display()));
|
||||||
|
let repo = repo.clone();
|
||||||
|
wrap_cmd
|
||||||
|
.execute(move || async move {
|
||||||
|
git_clone.clone_repo(&repo, force_refresh).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
} else {
|
||||||
|
eprintln!("cloning repository...");
|
||||||
|
git_clone.clone_repo(&repo, force_refresh).await?;
|
||||||
|
|
||||||
|
self.app
|
||||||
|
.custom_command()
|
||||||
|
.execute_post_clone_command(&project_path)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::info!("skipping clone for repo: {}", &repo.to_rel_path().display());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::info!("repository already exists");
|
||||||
|
|
||||||
|
self.app
|
||||||
|
.custom_command()
|
||||||
|
.execute_post_update_command(&project_path)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!("amount of repos fetched {}", repositories.len());
|
if shell {
|
||||||
|
self.app.shell().spawn_shell(&repo).await?;
|
||||||
|
} else {
|
||||||
|
tracing::info!("skipping shell for repo: {}", &repo.to_rel_path().display());
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
self.app
|
||||||
|
.config
|
||||||
|
.settings
|
||||||
|
.projects
|
||||||
|
.directory
|
||||||
|
.join(repo.to_rel_path())
|
||||||
|
.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
trait StringExt {
|
||||||
|
fn as_str_vec(&self) -> Vec<&str>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StringExt for Vec<String> {
|
||||||
|
fn as_str_vec(&self) -> Vec<&str> {
|
||||||
|
self.iter().map(|r| r.as_ref()).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StringExt for Vec<&String> {
|
||||||
|
fn as_str_vec(&self) -> Vec<&str> {
|
||||||
|
self.iter().map(|r| r.as_ref()).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait RepositoryMatcher {
|
||||||
|
fn match_repositories(&self, pattern: &str, repositories: &[Repository]) -> Vec<Repository>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RepositoryMatcher for FuzzyMatcher {
|
||||||
|
fn match_repositories(&self, pattern: &str, repositories: &[Repository]) -> Vec<Repository> {
|
||||||
|
let haystack = repositories
|
||||||
|
.iter()
|
||||||
|
.map(|r| (r.to_rel_path().display().to_string(), r))
|
||||||
|
.collect::<BTreeMap<_, _>>();
|
||||||
|
let haystack_keys = haystack.keys().collect::<Vec<_>>();
|
||||||
|
let haystack_keys = haystack_keys.as_str_vec();
|
||||||
|
|
||||||
|
let res = self.match_pattern(pattern, &haystack_keys);
|
||||||
|
|
||||||
|
let matched_repos = res
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|repo_key| haystack.get(repo_key).map(|r| (*r).to_owned()))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
matched_repos
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
33
crates/gitnow/src/commands/shell.rs
Normal file
33
crates/gitnow/src/commands/shell.rs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
use zsh::ZshShell;
|
||||||
|
|
||||||
|
pub mod zsh;
|
||||||
|
|
||||||
|
#[derive(clap::Parser)]
|
||||||
|
pub struct Shell {
|
||||||
|
#[command(subcommand)]
|
||||||
|
shell: ShellSubcommands,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Shell {
|
||||||
|
pub async fn execute(&mut self) -> anyhow::Result<()> {
|
||||||
|
self.shell.execute().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::Subcommand)]
|
||||||
|
pub enum ShellSubcommands {
|
||||||
|
#[command()]
|
||||||
|
Zsh(ZshShell),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ShellSubcommands {
|
||||||
|
pub async fn execute(&mut self) -> anyhow::Result<()> {
|
||||||
|
match self {
|
||||||
|
ShellSubcommands::Zsh(zsh) => zsh.execute().await?,
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
12
crates/gitnow/src/commands/shell/zsh.rs
Normal file
12
crates/gitnow/src/commands/shell/zsh.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#[derive(clap::Parser)]
|
||||||
|
pub struct ZshShell {}
|
||||||
|
|
||||||
|
const SCRIPT: &str = include_str!("../../../include/shell/zsh.sh");
|
||||||
|
|
||||||
|
impl ZshShell {
|
||||||
|
pub async fn execute(&mut self) -> anyhow::Result<()> {
|
||||||
|
println!("{}", SCRIPT);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
14
crates/gitnow/src/commands/update.rs
Normal file
14
crates/gitnow/src/commands/update.rs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
use crate::{app::App, cache::CacheApp, projects_list::ProjectsListApp};
|
||||||
|
|
||||||
|
#[derive(clap::Parser)]
|
||||||
|
pub struct Update {}
|
||||||
|
|
||||||
|
impl Update {
|
||||||
|
pub async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> {
|
||||||
|
let repositories = app.projects_list().get_projects().await?;
|
||||||
|
|
||||||
|
app.cache().update(&repositories).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
166
crates/gitnow/src/commands/worktree.rs
Normal file
166
crates/gitnow/src/commands/worktree.rs
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
use std::io::IsTerminal;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
app::App,
|
||||||
|
cache::CacheApp,
|
||||||
|
components::inline_command::InlineCommand,
|
||||||
|
fuzzy_matcher::FuzzyMatcherApp,
|
||||||
|
interactive::{InteractiveApp, StringItem},
|
||||||
|
projects_list::ProjectsListApp,
|
||||||
|
shell::ShellApp,
|
||||||
|
worktree::{sanitize_branch_name, WorktreeApp},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::root::RepositoryMatcher;
|
||||||
|
|
||||||
|
#[derive(clap::Parser)]
|
||||||
|
pub struct WorktreeCommand {
|
||||||
|
/// Optional search string to pre-filter repositories
|
||||||
|
#[arg()]
|
||||||
|
search: Option<String>,
|
||||||
|
|
||||||
|
/// Branch to check out (skips interactive branch selection)
|
||||||
|
#[arg(long = "branch", short = 'b')]
|
||||||
|
branch: Option<String>,
|
||||||
|
|
||||||
|
/// Skip cache
|
||||||
|
#[arg(long = "no-cache", default_value = "false")]
|
||||||
|
no_cache: bool,
|
||||||
|
|
||||||
|
/// Skip spawning a shell in the worktree
|
||||||
|
#[arg(long = "no-shell", default_value = "false")]
|
||||||
|
no_shell: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorktreeCommand {
|
||||||
|
pub async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> {
|
||||||
|
// Step 1: Load repositories
|
||||||
|
let repositories = if !self.no_cache {
|
||||||
|
match app.cache().get().await? {
|
||||||
|
Some(repos) => repos,
|
||||||
|
None => {
|
||||||
|
tracing::info!("finding repositories...");
|
||||||
|
let repositories = app.projects_list().get_projects().await?;
|
||||||
|
app.cache().update(&repositories).await?;
|
||||||
|
repositories
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
app.projects_list().get_projects().await?
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 2: Select repository
|
||||||
|
let repo = match &self.search {
|
||||||
|
Some(needle) => {
|
||||||
|
let matched_repos = app
|
||||||
|
.fuzzy_matcher()
|
||||||
|
.match_repositories(needle, &repositories);
|
||||||
|
|
||||||
|
matched_repos
|
||||||
|
.first()
|
||||||
|
.ok_or(anyhow::anyhow!("failed to find repository"))?
|
||||||
|
.to_owned()
|
||||||
|
}
|
||||||
|
None => app
|
||||||
|
.interactive()
|
||||||
|
.interactive_search(&repositories)?
|
||||||
|
.ok_or(anyhow::anyhow!("failed to find a repository"))?,
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::debug!("selected repo: {}", repo.to_rel_path().display());
|
||||||
|
|
||||||
|
let wt = app.worktree();
|
||||||
|
let (_project_path, bare_path) = wt.paths(&repo);
|
||||||
|
|
||||||
|
// Step 3: Ensure bare clone exists
|
||||||
|
if !bare_path.exists() {
|
||||||
|
if std::io::stdout().is_terminal() && !self.no_shell {
|
||||||
|
let mut wrap_cmd =
|
||||||
|
InlineCommand::new(format!("cloning: {}", repo.to_rel_path().display()));
|
||||||
|
let wt = app.worktree();
|
||||||
|
let repo_clone = repo.clone();
|
||||||
|
let bare_path_clone = bare_path.clone();
|
||||||
|
wrap_cmd
|
||||||
|
.execute(move || async move {
|
||||||
|
wt.ensure_bare_clone(&repo_clone, &bare_path_clone).await?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
} else {
|
||||||
|
eprintln!("bare-cloning repository...");
|
||||||
|
wt.ensure_bare_clone(&repo, &bare_path).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: List branches
|
||||||
|
let branches = app.worktree().list_branches(&bare_path).await?;
|
||||||
|
|
||||||
|
if branches.is_empty() {
|
||||||
|
anyhow::bail!("no branches found for {}", repo.to_rel_path().display());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Select branch
|
||||||
|
let branch = match &self.branch {
|
||||||
|
Some(b) => {
|
||||||
|
if !branches.contains(b) {
|
||||||
|
anyhow::bail!(
|
||||||
|
"branch '{}' not found. Available branches: {}",
|
||||||
|
b,
|
||||||
|
branches.join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
b.clone()
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let items: Vec<StringItem> =
|
||||||
|
branches.into_iter().map(StringItem).collect();
|
||||||
|
|
||||||
|
let selected = app
|
||||||
|
.interactive()
|
||||||
|
.interactive_search_items(&items)?
|
||||||
|
.ok_or(anyhow::anyhow!("no branch selected"))?;
|
||||||
|
|
||||||
|
selected.0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 6: Create worktree at <project_path>/<sanitized_branch>/
|
||||||
|
let sanitized = sanitize_branch_name(&branch);
|
||||||
|
let (project_path, _) = app.worktree().paths(&repo);
|
||||||
|
let worktree_path = project_path.join(&sanitized);
|
||||||
|
|
||||||
|
if !worktree_path.exists() {
|
||||||
|
if std::io::stdout().is_terminal() && !self.no_shell {
|
||||||
|
let mut wrap_cmd =
|
||||||
|
InlineCommand::new(format!("creating worktree: {}", &branch));
|
||||||
|
let wt = app.worktree();
|
||||||
|
let bare_path = bare_path.clone();
|
||||||
|
let worktree_path = worktree_path.clone();
|
||||||
|
let branch = branch.clone();
|
||||||
|
wrap_cmd
|
||||||
|
.execute(move || async move {
|
||||||
|
wt.add_worktree(&bare_path, &worktree_path, &branch)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
} else {
|
||||||
|
eprintln!("creating worktree for branch '{}'...", &branch);
|
||||||
|
app.worktree()
|
||||||
|
.add_worktree(&bare_path, &worktree_path, &branch)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::info!("worktree already exists at {}", worktree_path.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 7: Enter shell or print path
|
||||||
|
if !self.no_shell {
|
||||||
|
app.shell().spawn_shell_at(&worktree_path).await?;
|
||||||
|
} else {
|
||||||
|
println!("{}", worktree_path.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
119
crates/gitnow/src/components.rs
Normal file
119
crates/gitnow/src/components.rs
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
|
||||||
|
|
||||||
|
pub mod inline_command;
|
||||||
|
pub mod spinner;
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
pub enum Msg {
|
||||||
|
Quit,
|
||||||
|
Tick,
|
||||||
|
Success,
|
||||||
|
Failure(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Command {
|
||||||
|
func: Box<CommandFunc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Command {
|
||||||
|
pub fn new<T: FnOnce(&Dispatch) -> Option<Msg> + 'static>(f: T) -> Self {
|
||||||
|
Self { func: Box::new(f) }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn execute(self, dispatch: &Dispatch) -> Option<Msg> {
|
||||||
|
(self.func)(dispatch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait IntoCommand {
|
||||||
|
fn into_command(self) -> Command;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoCommand for () {
|
||||||
|
fn into_command(self) -> Command {
|
||||||
|
Command::new(|_| None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoCommand for Command {
|
||||||
|
fn into_command(self) -> Command {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoCommand for Msg {
|
||||||
|
fn into_command(self) -> Command {
|
||||||
|
Command::new(|_| Some(self))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type CommandFunc = dyn FnOnce(&Dispatch) -> Option<Msg>;
|
||||||
|
|
||||||
|
pub fn create_dispatch() -> (Dispatch, Receiver) {
|
||||||
|
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
|
||||||
|
(Dispatch { sender: tx }, Receiver { receiver: rx })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Dispatch {
|
||||||
|
sender: UnboundedSender<Msg>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dispatch {
|
||||||
|
pub fn send(&self, msg: Msg) {
|
||||||
|
if let Err(e) = self.sender.send(msg) {
|
||||||
|
tracing::warn!("failed to send event: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Receiver {
|
||||||
|
receiver: UnboundedReceiver<Msg>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Receiver {
|
||||||
|
pub async fn next(&mut self) -> Option<Msg> {
|
||||||
|
self.receiver.recv().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct BatchCommand {
|
||||||
|
commands: Vec<Command>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BatchCommand {
|
||||||
|
pub fn with(&mut self, cmd: impl IntoCommand) -> &mut Self {
|
||||||
|
self.commands.push(cmd.into_command());
|
||||||
|
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoCommand for Vec<Command> {
|
||||||
|
fn into_command(self) -> Command {
|
||||||
|
BatchCommand::from(self).into_command()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Vec<Command>> for BatchCommand {
|
||||||
|
fn from(value: Vec<Command>) -> Self {
|
||||||
|
BatchCommand { commands: value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoCommand for BatchCommand {
|
||||||
|
fn into_command(self) -> Command {
|
||||||
|
Command::new(|dispatch| {
|
||||||
|
for command in self.commands {
|
||||||
|
let msg = command.execute(dispatch);
|
||||||
|
if let Some(msg) = msg {
|
||||||
|
dispatch.send(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
193
crates/gitnow/src/components/inline_command.rs
Normal file
193
crates/gitnow/src/components/inline_command.rs
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crossterm::event::{EventStream, KeyCode};
|
||||||
|
use futures::{FutureExt, StreamExt};
|
||||||
|
use ratatui::{
|
||||||
|
TerminalOptions, Viewport, crossterm,
|
||||||
|
prelude::*,
|
||||||
|
widgets::{Block, Padding},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::components::BatchCommand;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
Dispatch, IntoCommand, Msg, Receiver, create_dispatch,
|
||||||
|
spinner::{Spinner, SpinnerState},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct InlineCommand {
|
||||||
|
spinner: SpinnerState,
|
||||||
|
heading: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InlineCommand {
|
||||||
|
pub fn new(heading: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
spinner: SpinnerState::default(),
|
||||||
|
heading: heading.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute<F, Fut>(&mut self, func: F) -> anyhow::Result<()>
|
||||||
|
where
|
||||||
|
F: FnOnce() -> Fut + Send + Sync + 'static,
|
||||||
|
Fut: futures::Future<Output = anyhow::Result<()>> + Send + 'static,
|
||||||
|
{
|
||||||
|
tracing::trace!("starting inline terminal");
|
||||||
|
|
||||||
|
let mut terminal = ratatui::init_with_options(TerminalOptions {
|
||||||
|
viewport: Viewport::Inline(3),
|
||||||
|
});
|
||||||
|
|
||||||
|
let (dispatch, mut receiver) = create_dispatch();
|
||||||
|
let mut event_stream = crossterm::event::EventStream::new();
|
||||||
|
let guard = TerminalGuard;
|
||||||
|
|
||||||
|
tokio::spawn({
|
||||||
|
let dispatch = dispatch.clone();
|
||||||
|
|
||||||
|
async move {
|
||||||
|
match func().await {
|
||||||
|
Ok(_) => dispatch.send(Msg::Success),
|
||||||
|
Err(e) => dispatch.send(Msg::Failure(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if self
|
||||||
|
.update(&mut terminal, &dispatch, &mut receiver, &mut event_stream)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
terminal.draw(|f| {
|
||||||
|
let buf = f.buffer_mut();
|
||||||
|
buf.reset();
|
||||||
|
})?;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(guard);
|
||||||
|
|
||||||
|
println!();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update(
|
||||||
|
&mut self,
|
||||||
|
terminal: &mut ratatui::Terminal<impl Backend>,
|
||||||
|
dispatch: &Dispatch,
|
||||||
|
receiver: &mut Receiver,
|
||||||
|
event_stream: &mut EventStream,
|
||||||
|
) -> anyhow::Result<bool> {
|
||||||
|
let input_event = event_stream.next().fuse();
|
||||||
|
let next_msg = receiver.next().fuse();
|
||||||
|
|
||||||
|
const FRAMES_PER_SECOND: f32 = 60.0;
|
||||||
|
const TICK_RATE: f32 = 20.0;
|
||||||
|
|
||||||
|
let period_frame = Duration::from_secs_f32(1.0 / FRAMES_PER_SECOND);
|
||||||
|
let mut interval_frames = tokio::time::interval(period_frame);
|
||||||
|
let period_tick = Duration::from_secs_f32(1.0 / TICK_RATE);
|
||||||
|
let mut interval_ticks = tokio::time::interval(period_tick);
|
||||||
|
|
||||||
|
let msg = tokio::select! {
|
||||||
|
_ = interval_frames.tick() => {
|
||||||
|
terminal.draw(|frame| self.draw(frame))?;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
_ = interval_ticks.tick() => {
|
||||||
|
Some(Msg::Tick)
|
||||||
|
}
|
||||||
|
msg = next_msg => {
|
||||||
|
msg
|
||||||
|
}
|
||||||
|
input = input_event => {
|
||||||
|
if let Some(Ok(input)) = input {
|
||||||
|
self.handle_key_event(input)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(msg) = msg {
|
||||||
|
if Msg::Quit == msg {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cmd = self.update_state(msg);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let msg = cmd.into_command().execute(dispatch);
|
||||||
|
|
||||||
|
match msg {
|
||||||
|
Some(Msg::Quit) => return Ok(true),
|
||||||
|
Some(msg) => {
|
||||||
|
cmd = self.update_state(msg);
|
||||||
|
}
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(&mut self, frame: &mut Frame<'_>) {
|
||||||
|
let spinner = Spinner::new(Span::from(&self.heading));
|
||||||
|
|
||||||
|
let block = Block::new().padding(Padding::symmetric(2, 1));
|
||||||
|
|
||||||
|
StatefulWidget::render(
|
||||||
|
spinner.block(block),
|
||||||
|
frame.area(),
|
||||||
|
frame.buffer_mut(),
|
||||||
|
&mut self.spinner,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_key_event(&mut self, event: crossterm::event::Event) -> Option<Msg> {
|
||||||
|
if let crossterm::event::Event::Key(key) = event {
|
||||||
|
return match key.code {
|
||||||
|
KeyCode::Esc => Some(Msg::Quit),
|
||||||
|
KeyCode::Char('c') => Some(Msg::Quit),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_state(&mut self, msg: Msg) -> impl IntoCommand {
|
||||||
|
tracing::debug!("handling message: {:?}", msg);
|
||||||
|
|
||||||
|
let mut batch = BatchCommand::default();
|
||||||
|
|
||||||
|
match msg {
|
||||||
|
Msg::Quit => {}
|
||||||
|
Msg::Tick => {}
|
||||||
|
Msg::Success => return Msg::Quit.into_command(),
|
||||||
|
Msg::Failure(f) => {
|
||||||
|
tracing::error!("command failed: {}", f);
|
||||||
|
return Msg::Quit.into_command();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
batch.with(self.spinner.update(&msg));
|
||||||
|
|
||||||
|
batch.into_command()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct TerminalGuard;
|
||||||
|
|
||||||
|
impl Drop for TerminalGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
tracing::trace!("restoring inline terminal");
|
||||||
|
ratatui::restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
86
crates/gitnow/src/components/spinner.rs
Normal file
86
crates/gitnow/src/components/spinner.rs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use ratatui::{
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Paragraph, StatefulWidget, Widget},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{BatchCommand, IntoCommand, Msg};
|
||||||
|
|
||||||
|
pub struct Spinner<'a> {
|
||||||
|
span: Span<'a>,
|
||||||
|
block: Option<Block<'a>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Spinner<'a> {
|
||||||
|
pub fn new(span: Span<'a>) -> Self {
|
||||||
|
Self { span, block: None }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn block(mut self, block: Block<'a>) -> Self {
|
||||||
|
self.block = Some(block);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StatefulWidget for Spinner<'_> {
|
||||||
|
type State = SpinnerState;
|
||||||
|
|
||||||
|
fn render(
|
||||||
|
self,
|
||||||
|
area: ratatui::prelude::Rect,
|
||||||
|
buf: &mut ratatui::prelude::Buffer,
|
||||||
|
state: &mut Self::State,
|
||||||
|
) {
|
||||||
|
let frame = MINIDOT_FRAMES
|
||||||
|
.get((state.frame) % MINIDOT_FRAMES.len())
|
||||||
|
.expect("to find a valid static frame");
|
||||||
|
|
||||||
|
let line = Line::from(vec![Span::from(*frame), Span::from(" "), self.span]);
|
||||||
|
|
||||||
|
let para = Paragraph::new(vec![line]);
|
||||||
|
let para = if let Some(block) = self.block {
|
||||||
|
para.block(block)
|
||||||
|
} else {
|
||||||
|
para
|
||||||
|
};
|
||||||
|
|
||||||
|
para.render(area, buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SpinnerState {
|
||||||
|
last_event: Instant,
|
||||||
|
interval: Duration,
|
||||||
|
frame: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SpinnerState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
last_event: Instant::now(),
|
||||||
|
interval: Duration::from_millis(1000 / 12),
|
||||||
|
frame: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MINIDOT_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||||
|
|
||||||
|
impl SpinnerState {
|
||||||
|
pub fn update(&mut self, _msg: &Msg) -> impl IntoCommand {
|
||||||
|
let batch = BatchCommand::default();
|
||||||
|
|
||||||
|
let now = Instant::now();
|
||||||
|
if now.duration_since(self.last_event) >= self.interval {
|
||||||
|
self.last_event = now;
|
||||||
|
self.next_state();
|
||||||
|
}
|
||||||
|
|
||||||
|
batch
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_state(&mut self) {
|
||||||
|
self.frame = self.frame.wrapping_add(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
|
|||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub settings: Settings,
|
pub settings: Settings,
|
||||||
@@ -12,13 +12,110 @@ pub struct Config {
|
|||||||
pub providers: Providers,
|
pub providers: Providers,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Clone)]
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
|
#[serde(default)]
|
||||||
|
pub projects: Projects,
|
||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub cache: Cache,
|
pub cache: Cache,
|
||||||
|
|
||||||
|
pub post_clone_command: Option<PostCloneCommand>,
|
||||||
|
pub post_update_command: Option<PostUpdateCommand>,
|
||||||
|
|
||||||
|
/// Minijinja template for the clone command.
|
||||||
|
/// Default: "git clone {{ ssh_url }} {{ path }}"
|
||||||
|
pub clone_command: Option<String>,
|
||||||
|
|
||||||
|
/// Worktree configuration.
|
||||||
|
#[serde(default)]
|
||||||
|
pub worktree: Option<WorktreeSettings>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
|
||||||
|
pub struct WorktreeSettings {
|
||||||
|
/// Template for bare-cloning a repository.
|
||||||
|
/// Default: "git clone --bare {{ ssh_url }} {{ bare_path }}"
|
||||||
|
pub clone_command: Option<String>,
|
||||||
|
|
||||||
|
/// Template for adding a worktree.
|
||||||
|
/// Default: "git -C {{ bare_path }} worktree add {{ worktree_path }} {{ branch }}"
|
||||||
|
pub add_command: Option<String>,
|
||||||
|
|
||||||
|
/// Template for listing branches.
|
||||||
|
/// Default: "git -C {{ bare_path }} branch -r --format=%(refname:short)"
|
||||||
|
pub list_branches_command: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum PostUpdateCommand {
|
||||||
|
Single(String),
|
||||||
|
Multiple(Vec<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostUpdateCommand {
|
||||||
|
pub fn get_commands(&self) -> Vec<String> {
|
||||||
|
match self.clone() {
|
||||||
|
PostUpdateCommand::Single(item) => vec![item],
|
||||||
|
PostUpdateCommand::Multiple(items) => items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum PostCloneCommand {
|
||||||
|
Single(String),
|
||||||
|
Multiple(Vec<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostCloneCommand {
|
||||||
|
pub fn get_commands(&self) -> Vec<String> {
|
||||||
|
match self.clone() {
|
||||||
|
PostCloneCommand::Single(item) => vec![item],
|
||||||
|
PostCloneCommand::Multiple(items) => items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Clone)]
|
||||||
|
pub struct Projects {
|
||||||
|
pub directory: ProjectLocation,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct ProjectLocation(PathBuf);
|
||||||
|
|
||||||
|
impl From<PathBuf> for ProjectLocation {
|
||||||
|
fn from(value: PathBuf) -> Self {
|
||||||
|
Self(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ProjectLocation> for PathBuf {
|
||||||
|
fn from(value: ProjectLocation) -> Self {
|
||||||
|
value.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ProjectLocation {
|
||||||
|
fn default() -> Self {
|
||||||
|
let home = dirs::home_dir().unwrap_or_default();
|
||||||
|
|
||||||
|
Self(home.join("git"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::Deref for ProjectLocation {
|
||||||
|
type Target = PathBuf;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Clone)]
|
||||||
pub struct Cache {
|
pub struct Cache {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub location: CacheLocation,
|
pub location: CacheLocation,
|
||||||
@@ -50,7 +147,7 @@ impl Default for CacheLocation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
|
||||||
#[serde(untagged)]
|
#[serde(untagged)]
|
||||||
pub enum CacheDuration {
|
pub enum CacheDuration {
|
||||||
Enabled(bool),
|
Enabled(bool),
|
||||||
@@ -74,7 +171,7 @@ impl CacheDuration {
|
|||||||
hours,
|
hours,
|
||||||
minutes,
|
minutes,
|
||||||
} => Some(
|
} => Some(
|
||||||
std::time::Duration::from_days(*days)
|
std::time::Duration::from_hours(*days * 24)
|
||||||
+ std::time::Duration::from_hours(*hours)
|
+ std::time::Duration::from_hours(*hours)
|
||||||
+ std::time::Duration::from_mins(*minutes),
|
+ std::time::Duration::from_mins(*minutes),
|
||||||
),
|
),
|
||||||
@@ -85,14 +182,14 @@ impl CacheDuration {
|
|||||||
impl Default for CacheDuration {
|
impl Default for CacheDuration {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::Precise {
|
Self::Precise {
|
||||||
days: 1,
|
days: 7,
|
||||||
hours: 0,
|
hours: 0,
|
||||||
minutes: 0,
|
minutes: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Clone)]
|
||||||
pub struct Providers {
|
pub struct Providers {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub github: Vec<GitHub>,
|
pub github: Vec<GitHub>,
|
||||||
@@ -100,7 +197,7 @@ pub struct Providers {
|
|||||||
pub gitea: Vec<Gitea>,
|
pub gitea: Vec<Gitea>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
|
||||||
pub struct GitHub {
|
pub struct GitHub {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub url: Option<String>,
|
pub url: Option<String>,
|
||||||
@@ -116,7 +213,7 @@ pub struct GitHub {
|
|||||||
pub organisations: Vec<GitHubOrganisation>,
|
pub organisations: Vec<GitHubOrganisation>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
|
||||||
pub struct GitHubUser(String);
|
pub struct GitHubUser(String);
|
||||||
|
|
||||||
impl From<GitHubUser> for String {
|
impl From<GitHubUser> for String {
|
||||||
@@ -131,7 +228,7 @@ impl<'a> From<&'a GitHubUser> for &'a str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
|
||||||
pub struct GitHubOrganisation(String);
|
pub struct GitHubOrganisation(String);
|
||||||
|
|
||||||
impl From<GitHubOrganisation> for String {
|
impl From<GitHubOrganisation> for String {
|
||||||
@@ -146,7 +243,7 @@ impl<'a> From<&'a GitHubOrganisation> for &'a str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
|
||||||
pub struct Gitea {
|
pub struct Gitea {
|
||||||
pub url: String,
|
pub url: String,
|
||||||
|
|
||||||
@@ -162,21 +259,21 @@ pub struct Gitea {
|
|||||||
pub organisations: Vec<GiteaOrganisation>,
|
pub organisations: Vec<GiteaOrganisation>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
|
||||||
#[serde(untagged)]
|
#[serde(untagged)]
|
||||||
pub enum GiteaAccessToken {
|
pub enum GiteaAccessToken {
|
||||||
Direct(String),
|
Direct(String),
|
||||||
Env { env: String },
|
Env { env: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
|
||||||
#[serde(untagged)]
|
#[serde(untagged)]
|
||||||
pub enum GitHubAccessToken {
|
pub enum GitHubAccessToken {
|
||||||
Direct(String),
|
Direct(String),
|
||||||
Env { env: String },
|
Env { env: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
|
||||||
pub struct GiteaUser(String);
|
pub struct GiteaUser(String);
|
||||||
|
|
||||||
impl From<GiteaUser> for String {
|
impl From<GiteaUser> for String {
|
||||||
@@ -191,7 +288,7 @@ impl<'a> From<&'a GiteaUser> for &'a str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
|
||||||
pub struct GiteaOrganisation(String);
|
pub struct GiteaOrganisation(String);
|
||||||
impl From<GiteaOrganisation> for String {
|
impl From<GiteaOrganisation> for String {
|
||||||
fn from(value: GiteaOrganisation) -> Self {
|
fn from(value: GiteaOrganisation) -> Self {
|
||||||
@@ -231,6 +328,9 @@ mod test {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_can_parse_config() -> anyhow::Result<()> {
|
fn test_can_parse_config() -> anyhow::Result<()> {
|
||||||
let content = r#"
|
let content = r#"
|
||||||
|
[settings]
|
||||||
|
projects = { directory = "git" }
|
||||||
|
|
||||||
[settings.cache]
|
[settings.cache]
|
||||||
location = ".cache/gitnow"
|
location = ".cache/gitnow"
|
||||||
duration = { days = 2 }
|
duration = { days = 2 }
|
||||||
@@ -316,7 +416,14 @@ mod test {
|
|||||||
hours: 0,
|
hours: 0,
|
||||||
minutes: 0
|
minutes: 0
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
projects: Projects {
|
||||||
|
directory: PathBuf::from("git").into()
|
||||||
|
},
|
||||||
|
post_update_command: None,
|
||||||
|
post_clone_command: None,
|
||||||
|
clone_command: None,
|
||||||
|
worktree: None,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
config
|
config
|
||||||
@@ -340,7 +447,12 @@ mod test {
|
|||||||
gitea: vec![]
|
gitea: vec![]
|
||||||
},
|
},
|
||||||
settings: Settings {
|
settings: Settings {
|
||||||
cache: Cache::default()
|
cache: Cache::default(),
|
||||||
|
projects: Projects::default(),
|
||||||
|
post_update_command: None,
|
||||||
|
post_clone_command: None,
|
||||||
|
clone_command: None,
|
||||||
|
worktree: None,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
config
|
config
|
||||||
@@ -348,4 +460,54 @@ mod test {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_can_parse_config_with_clone_command() -> anyhow::Result<()> {
|
||||||
|
let content = r#"
|
||||||
|
[settings]
|
||||||
|
projects = { directory = "git" }
|
||||||
|
clone_command = "jj git clone {{ ssh_url }} {{ path }}"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let config = Config::from_string(content)?;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
config.settings.clone_command,
|
||||||
|
Some("jj git clone {{ ssh_url }} {{ path }}".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_can_parse_config_with_worktree() -> anyhow::Result<()> {
|
||||||
|
let content = r#"
|
||||||
|
[settings]
|
||||||
|
projects = { directory = "git" }
|
||||||
|
|
||||||
|
[settings.worktree]
|
||||||
|
clone_command = "jj git clone {{ ssh_url }} {{ bare_path }}"
|
||||||
|
add_command = "jj workspace add --name {{ branch }} {{ worktree_path }}"
|
||||||
|
list_branches_command = "jj -R {{ bare_path }} branch list"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let config = Config::from_string(content)?;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
config.settings.worktree,
|
||||||
|
Some(WorktreeSettings {
|
||||||
|
clone_command: Some(
|
||||||
|
"jj git clone {{ ssh_url }} {{ bare_path }}".to_string()
|
||||||
|
),
|
||||||
|
add_command: Some(
|
||||||
|
"jj workspace add --name {{ branch }} {{ worktree_path }}".to_string()
|
||||||
|
),
|
||||||
|
list_branches_command: Some(
|
||||||
|
"jj -R {{ bare_path }} branch list".to_string()
|
||||||
|
),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
83
crates/gitnow/src/custom_command.rs
Normal file
83
crates/gitnow/src/custom_command.rs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::{app::App, config::Config};
|
||||||
|
|
||||||
|
pub struct CustomCommand {
|
||||||
|
config: Config,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CustomCommand {
|
||||||
|
pub fn new(config: Config) -> Self {
|
||||||
|
Self { config }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute_post_clone_command(&self, path: &Path) -> anyhow::Result<()> {
|
||||||
|
if let Some(post_clone_command) = &self.config.settings.post_clone_command {
|
||||||
|
for command in post_clone_command.get_commands() {
|
||||||
|
let command_parts = command.split(' ').collect::<Vec<_>>();
|
||||||
|
let Some((first, rest)) = command_parts.split_first() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut cmd = tokio::process::Command::new(first);
|
||||||
|
cmd.args(rest).current_dir(path);
|
||||||
|
|
||||||
|
eprintln!("running command: {}", command);
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
path = path.display().to_string(),
|
||||||
|
cmd = command,
|
||||||
|
"running custom post clone command"
|
||||||
|
);
|
||||||
|
let output = cmd.output().await?;
|
||||||
|
let stdout = std::str::from_utf8(&output.stdout)?;
|
||||||
|
tracing::info!(
|
||||||
|
stdout = stdout,
|
||||||
|
"finished running custom post clone command"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute_post_update_command(&self, path: &Path) -> anyhow::Result<()> {
|
||||||
|
if let Some(post_update_command) = &self.config.settings.post_update_command {
|
||||||
|
for command in post_update_command.get_commands() {
|
||||||
|
let command_parts = command.split(' ').collect::<Vec<_>>();
|
||||||
|
let Some((first, rest)) = command_parts.split_first() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut cmd = tokio::process::Command::new(first);
|
||||||
|
cmd.args(rest).current_dir(path);
|
||||||
|
|
||||||
|
eprintln!("running command: {}", command);
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
path = path.display().to_string(),
|
||||||
|
cmd = command,
|
||||||
|
"running custom post update command"
|
||||||
|
);
|
||||||
|
let output = cmd.output().await?;
|
||||||
|
let stdout = std::str::from_utf8(&output.stdout)?;
|
||||||
|
tracing::info!(
|
||||||
|
stdout = stdout,
|
||||||
|
"finished running custom post update command"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait CustomCommandApp {
|
||||||
|
fn custom_command(&self) -> CustomCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CustomCommandApp for App {
|
||||||
|
fn custom_command(&self) -> CustomCommand {
|
||||||
|
CustomCommand::new(self.config.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
34
crates/gitnow/src/fuzzy_matcher.rs
Normal file
34
crates/gitnow/src/fuzzy_matcher.rs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
use nucleo_matcher::{pattern::Pattern, Matcher};
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
|
||||||
|
pub struct FuzzyMatcher {}
|
||||||
|
|
||||||
|
impl FuzzyMatcher {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn match_pattern<'a>(&self, pattern: &'a str, items: &'a [&'a str]) -> Vec<&'a str> {
|
||||||
|
let pat = Pattern::new(
|
||||||
|
pattern,
|
||||||
|
nucleo_matcher::pattern::CaseMatching::Ignore,
|
||||||
|
nucleo_matcher::pattern::Normalization::Smart,
|
||||||
|
nucleo_matcher::pattern::AtomKind::Fuzzy,
|
||||||
|
);
|
||||||
|
let mut matcher = Matcher::new(nucleo_matcher::Config::DEFAULT);
|
||||||
|
let res = pat.match_list(items, &mut matcher);
|
||||||
|
|
||||||
|
res.into_iter().map(|(item, _)| *item).collect::<Vec<_>>()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait FuzzyMatcherApp {
|
||||||
|
fn fuzzy_matcher(&self) -> FuzzyMatcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FuzzyMatcherApp for &'static App {
|
||||||
|
fn fuzzy_matcher(&self) -> FuzzyMatcher {
|
||||||
|
FuzzyMatcher::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
92
crates/gitnow/src/git_clone.rs
Normal file
92
crates/gitnow/src/git_clone.rs
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::{app::App, git_provider::Repository, template_command};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct GitClone {
|
||||||
|
app: &'static App,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GitClone {
|
||||||
|
pub fn new(app: &'static App) -> Self {
|
||||||
|
Self { app }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn clone_repo(
|
||||||
|
&self,
|
||||||
|
repository: &Repository,
|
||||||
|
force_refresh: bool,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let project_path = self
|
||||||
|
.app
|
||||||
|
.config
|
||||||
|
.settings
|
||||||
|
.projects
|
||||||
|
.directory
|
||||||
|
.join(repository.to_rel_path());
|
||||||
|
|
||||||
|
if force_refresh {
|
||||||
|
tokio::fs::remove_dir_all(&project_path).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if project_path.exists() {
|
||||||
|
tracing::info!(
|
||||||
|
"project: {} already exists, skipping clone",
|
||||||
|
repository.to_rel_path().display()
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let template = self
|
||||||
|
.app
|
||||||
|
.config
|
||||||
|
.settings
|
||||||
|
.clone_command
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or(template_command::DEFAULT_CLONE_COMMAND);
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"cloning: {} into {}",
|
||||||
|
repository.ssh_url.as_str(),
|
||||||
|
&project_path.display().to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let path_str = project_path.display().to_string();
|
||||||
|
let context = HashMap::from([
|
||||||
|
("ssh_url", repository.ssh_url.as_str()),
|
||||||
|
("path", path_str.as_str()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let output = template_command::render_and_execute(template, context).await?;
|
||||||
|
match output.status.success() {
|
||||||
|
true => tracing::debug!(
|
||||||
|
"cloned {} into {}",
|
||||||
|
repository.ssh_url.as_str(),
|
||||||
|
&project_path.display().to_string(),
|
||||||
|
),
|
||||||
|
false => {
|
||||||
|
let stdout = std::str::from_utf8(&output.stdout).unwrap_or_default();
|
||||||
|
let stderr = std::str::from_utf8(&output.stderr).unwrap_or_default();
|
||||||
|
tracing::error!(
|
||||||
|
"failed to clone {} into {}, with output: {}, err: {}",
|
||||||
|
repository.ssh_url.as_str(),
|
||||||
|
&project_path.display().to_string(),
|
||||||
|
stdout,
|
||||||
|
stderr
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait GitCloneApp {
|
||||||
|
fn git_clone(&self) -> GitClone;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GitCloneApp for &'static App {
|
||||||
|
fn git_clone(&self) -> GitClone {
|
||||||
|
GitClone::new(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use gitea_rs::apis::configuration::Configuration;
|
use gitea_client::apis::configuration::Configuration;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use crate::{app::App, config::GiteaAccessToken};
|
use crate::{app::App, config::GiteaAccessToken};
|
||||||
@@ -68,9 +68,9 @@ impl GiteaProvider {
|
|||||||
&self,
|
&self,
|
||||||
config: &Configuration,
|
config: &Configuration,
|
||||||
page: usize,
|
page: usize,
|
||||||
) -> anyhow::Result<Vec<gitea_rs::models::Repository>> {
|
) -> anyhow::Result<Vec<gitea_client::models::Repository>> {
|
||||||
let repos =
|
let repos =
|
||||||
gitea_rs::apis::user_api::user_current_list_repos(config, Some(page as i32), None)
|
gitea_client::apis::user_api::user_current_list_repos(config, Some(page as i32), None)
|
||||||
.await
|
.await
|
||||||
.context("failed to fetch repos for users")?;
|
.context("failed to fetch repos for users")?;
|
||||||
|
|
||||||
@@ -125,9 +125,9 @@ impl GiteaProvider {
|
|||||||
user: &str,
|
user: &str,
|
||||||
config: &Configuration,
|
config: &Configuration,
|
||||||
page: usize,
|
page: usize,
|
||||||
) -> anyhow::Result<Vec<gitea_rs::models::Repository>> {
|
) -> anyhow::Result<Vec<gitea_client::models::Repository>> {
|
||||||
let repos =
|
let repos =
|
||||||
gitea_rs::apis::user_api::user_list_repos(config, user, Some(page as i32), None)
|
gitea_client::apis::user_api::user_list_repos(config, user, Some(page as i32), None)
|
||||||
.await
|
.await
|
||||||
.context("failed to fetch repos for users")?;
|
.context("failed to fetch repos for users")?;
|
||||||
|
|
||||||
@@ -184,8 +184,8 @@ impl GiteaProvider {
|
|||||||
organisation: &str,
|
organisation: &str,
|
||||||
config: &Configuration,
|
config: &Configuration,
|
||||||
page: usize,
|
page: usize,
|
||||||
) -> anyhow::Result<Vec<gitea_rs::models::Repository>> {
|
) -> anyhow::Result<Vec<gitea_client::models::Repository>> {
|
||||||
let repos = gitea_rs::apis::organization_api::org_list_repos(
|
let repos = gitea_client::apis::organization_api::org_list_repos(
|
||||||
config,
|
config,
|
||||||
organisation,
|
organisation,
|
||||||
Some(page as i32),
|
Some(page as i32),
|
||||||
@@ -202,7 +202,7 @@ impl GiteaProvider {
|
|||||||
api: &str,
|
api: &str,
|
||||||
access_token: Option<&GiteaAccessToken>,
|
access_token: Option<&GiteaAccessToken>,
|
||||||
) -> anyhow::Result<Configuration> {
|
) -> anyhow::Result<Configuration> {
|
||||||
let mut config = gitea_rs::apis::configuration::Configuration::new();
|
let mut config = gitea_client::apis::configuration::Configuration::new();
|
||||||
config.base_path = api.into();
|
config.base_path = api.into();
|
||||||
match access_token {
|
match access_token {
|
||||||
Some(GiteaAccessToken::Env { env }) => {
|
Some(GiteaAccessToken::Env { env }) => {
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
use anyhow::Context;
|
use octocrab::{models::Repository, params::repos::Sort, Octocrab, Page};
|
||||||
use octocrab::{
|
|
||||||
auth::Auth,
|
|
||||||
models::{hooks::Config, Repository},
|
|
||||||
params::repos::Sort,
|
|
||||||
NoSvc, Octocrab, Page,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::{app::App, config::GitHubAccessToken};
|
use crate::{app::App, config::GitHubAccessToken};
|
||||||
|
|
||||||
@@ -159,7 +153,7 @@ impl GitHubProvider {
|
|||||||
|
|
||||||
fn get_client(
|
fn get_client(
|
||||||
&self,
|
&self,
|
||||||
url: Option<&String>,
|
_url: Option<&String>,
|
||||||
access_token: &GitHubAccessToken,
|
access_token: &GitHubAccessToken,
|
||||||
) -> anyhow::Result<Octocrab> {
|
) -> anyhow::Result<Octocrab> {
|
||||||
let client = octocrab::Octocrab::builder()
|
let client = octocrab::Octocrab::builder()
|
||||||
|
|||||||
199
crates/gitnow/src/interactive.rs
Normal file
199
crates/gitnow/src/interactive.rs
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
use app::App;
|
||||||
|
use ratatui::{prelude::*, Terminal};
|
||||||
|
|
||||||
|
use crate::git_provider::Repository;
|
||||||
|
|
||||||
|
pub trait Searchable: Clone {
|
||||||
|
fn display_label(&self) -> String;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Searchable for Repository {
|
||||||
|
fn display_label(&self) -> String {
|
||||||
|
self.to_rel_path().display().to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct StringItem(pub String);
|
||||||
|
|
||||||
|
impl Searchable for StringItem {
|
||||||
|
fn display_label(&self) -> String {
|
||||||
|
self.0.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Interactive {
|
||||||
|
app: &'static crate::app::App,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Interactive {
|
||||||
|
pub fn new(app: &'static crate::app::App) -> Self {
|
||||||
|
Self { app }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn interactive_search(
|
||||||
|
&mut self,
|
||||||
|
repositories: &[Repository],
|
||||||
|
) -> anyhow::Result<Option<Repository>> {
|
||||||
|
self.interactive_search_items(repositories)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn interactive_search_items<T: Searchable>(
|
||||||
|
&mut self,
|
||||||
|
items: &[T],
|
||||||
|
) -> anyhow::Result<Option<T>> {
|
||||||
|
let backend = TermwizBackend::new().map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
||||||
|
let terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
|
App::new(self.app, items).run(terminal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait InteractiveApp {
|
||||||
|
fn interactive(&self) -> Interactive;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InteractiveApp for &'static crate::app::App {
|
||||||
|
fn interactive(&self) -> Interactive {
|
||||||
|
Interactive::new(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod app {
|
||||||
|
use crossterm::event::KeyModifiers;
|
||||||
|
use ratatui::{
|
||||||
|
crossterm::event::{self, Event, KeyCode},
|
||||||
|
layout::{Constraint, Layout},
|
||||||
|
prelude::TermwizBackend,
|
||||||
|
style::{Style, Stylize},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{ListItem, ListState, Paragraph, StatefulWidget},
|
||||||
|
Frame, Terminal,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::fuzzy_matcher::FuzzyMatcherApp;
|
||||||
|
|
||||||
|
use super::Searchable;
|
||||||
|
|
||||||
|
pub struct App<'a, T: Searchable> {
|
||||||
|
app: &'static crate::app::App,
|
||||||
|
items: &'a [T],
|
||||||
|
current_search: String,
|
||||||
|
matched_items: Vec<T>,
|
||||||
|
list: ListState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, T: Searchable> App<'a, T> {
|
||||||
|
pub fn new(app: &'static crate::app::App, items: &'a [T]) -> Self {
|
||||||
|
Self {
|
||||||
|
app,
|
||||||
|
items,
|
||||||
|
current_search: String::default(),
|
||||||
|
matched_items: Vec::default(),
|
||||||
|
list: ListState::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_matched_items(&mut self) {
|
||||||
|
let labels: Vec<String> = self.items.iter().map(|i| i.display_label()).collect();
|
||||||
|
let label_refs: Vec<&str> = labels.iter().map(|s| s.as_str()).collect();
|
||||||
|
|
||||||
|
let matched_keys = self
|
||||||
|
.app
|
||||||
|
.fuzzy_matcher()
|
||||||
|
.match_pattern(&self.current_search, &label_refs);
|
||||||
|
|
||||||
|
self.matched_items = matched_keys
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|key| {
|
||||||
|
self.items
|
||||||
|
.iter()
|
||||||
|
.find(|i| i.display_label() == key)
|
||||||
|
.cloned()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if self.list.selected().is_none() {
|
||||||
|
self.list.select_first();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(
|
||||||
|
mut self,
|
||||||
|
mut terminal: Terminal<TermwizBackend>,
|
||||||
|
) -> anyhow::Result<Option<T>> {
|
||||||
|
self.update_matched_items();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
terminal.draw(|frame| self.draw(frame))?;
|
||||||
|
|
||||||
|
if let Event::Key(key) = event::read()? {
|
||||||
|
if let KeyCode::Char('c') = key.code
|
||||||
|
&& key.modifiers.contains(KeyModifiers::CONTROL)
|
||||||
|
{
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char(letter) => {
|
||||||
|
self.current_search.push(letter);
|
||||||
|
self.update_matched_items();
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
if !self.current_search.is_empty() {
|
||||||
|
let _ =
|
||||||
|
self.current_search.remove(self.current_search.len() - 1);
|
||||||
|
self.update_matched_items();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Esc => {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if let Some(selected) = self.list.selected()
|
||||||
|
&& let Some(item) =
|
||||||
|
self.matched_items.get(selected).cloned()
|
||||||
|
{
|
||||||
|
terminal.resize(ratatui::layout::Rect::ZERO)?;
|
||||||
|
return Ok(Some(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
KeyCode::Up => self.list.select_next(),
|
||||||
|
KeyCode::Down => self.list.select_previous(),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(&mut self, frame: &mut Frame) {
|
||||||
|
let vertical = Layout::vertical([Constraint::Percentage(100), Constraint::Min(1)]);
|
||||||
|
let [list_area, input_area] = vertical.areas(frame.area());
|
||||||
|
|
||||||
|
let display_items: Vec<String> =
|
||||||
|
self.matched_items.iter().map(|i| i.display_label()).collect();
|
||||||
|
|
||||||
|
let list_items: Vec<ListItem> =
|
||||||
|
display_items.into_iter().map(ListItem::from).collect();
|
||||||
|
|
||||||
|
let list = ratatui::widgets::List::new(list_items)
|
||||||
|
.direction(ratatui::widgets::ListDirection::BottomToTop)
|
||||||
|
.scroll_padding(3)
|
||||||
|
.highlight_symbol("> ")
|
||||||
|
.highlight_spacing(ratatui::widgets::HighlightSpacing::Always)
|
||||||
|
.highlight_style(Style::default().bold().white());
|
||||||
|
|
||||||
|
StatefulWidget::render(list, list_area, frame.buffer_mut(), &mut self.list);
|
||||||
|
|
||||||
|
let input = Paragraph::new(Line::from(vec![
|
||||||
|
Span::from("> ").blue(),
|
||||||
|
Span::from(self.current_search.as_str()),
|
||||||
|
Span::from(" ").on_white(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
frame.render_widget(input, input_area);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,57 +1,125 @@
|
|||||||
#![feature(duration_constructors)]
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use commands::root::RootCommand;
|
use commands::{
|
||||||
|
clone::CloneCommand, root::RootCommand, shell::Shell, update::Update,
|
||||||
|
worktree::WorktreeCommand,
|
||||||
|
};
|
||||||
use config::Config;
|
use config::Config;
|
||||||
|
use tracing::level_filters::LevelFilter;
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
mod app;
|
mod app;
|
||||||
mod cache;
|
mod cache;
|
||||||
mod cache_codec;
|
mod cache_codec;
|
||||||
mod commands;
|
mod commands;
|
||||||
|
mod components;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod custom_command;
|
||||||
|
mod fuzzy_matcher;
|
||||||
|
mod git_clone;
|
||||||
mod git_provider;
|
mod git_provider;
|
||||||
|
mod interactive;
|
||||||
mod projects_list;
|
mod projects_list;
|
||||||
|
mod shell;
|
||||||
|
mod template_command;
|
||||||
|
mod worktree;
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(author, version, about, long_about = Some("Navigate git projects at the speed of thought"))]
|
#[command(author, version, about, long_about = Some("Navigate git projects at the speed of thought"))]
|
||||||
struct Command {
|
struct Command {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Option<Commands>,
|
command: Option<Commands>,
|
||||||
|
|
||||||
|
/// Path to config file (default: ~/.config/gitnow/gitnow.toml, or $GITNOW_CONFIG)
|
||||||
|
#[arg(long = "config", short = 'c', global = true)]
|
||||||
|
config: Option<PathBuf>,
|
||||||
|
|
||||||
|
#[arg()]
|
||||||
|
search: Option<String>,
|
||||||
|
|
||||||
|
#[arg(long = "no-cache", default_value = "false")]
|
||||||
|
no_cache: bool,
|
||||||
|
|
||||||
|
#[arg(long = "no-clone", default_value = "false")]
|
||||||
|
no_clone: bool,
|
||||||
|
|
||||||
|
#[arg(long = "no-shell", default_value = "false")]
|
||||||
|
no_shell: bool,
|
||||||
|
|
||||||
|
#[arg(long = "force-refresh", default_value = "false")]
|
||||||
|
force_refresh: bool,
|
||||||
|
|
||||||
|
#[arg(long = "force-cache-update", default_value = "false")]
|
||||||
|
force_cache_update: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum Commands {
|
enum Commands {
|
||||||
Hello {},
|
Init(Shell),
|
||||||
|
Update(Update),
|
||||||
|
Clone(CloneCommand),
|
||||||
|
Worktree(WorktreeCommand),
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CONFIG_PATH: &str = ".config/gitnow/gitnow.toml";
|
const DEFAULT_CONFIG_PATH: &str = ".config/gitnow/gitnow.toml";
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
dotenv::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
tracing_subscriber::fmt::init();
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
EnvFilter::builder()
|
||||||
|
.with_default_directive(LevelFilter::ERROR.into())
|
||||||
|
.from_env_lossy(),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let cli = Command::parse();
|
||||||
|
tracing::debug!("Starting cli");
|
||||||
|
|
||||||
|
let config_path = if let Some(path) = &cli.config {
|
||||||
|
path.clone()
|
||||||
|
} else {
|
||||||
let home =
|
let home =
|
||||||
std::env::var("HOME").context("HOME was not found, are you using a proper shell?")?;
|
std::env::var("HOME").context("HOME was not found, are you using a proper shell?")?;
|
||||||
let default_config_path = PathBuf::from(home).join(DEFAULT_CONFIG_PATH);
|
let default_config_path = PathBuf::from(home).join(DEFAULT_CONFIG_PATH);
|
||||||
let config_path = std::env::var("GITNOW_CONFIG")
|
std::env::var("GITNOW_CONFIG")
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
.unwrap_or(default_config_path);
|
.unwrap_or(default_config_path)
|
||||||
|
};
|
||||||
|
|
||||||
let config = Config::from_file(&config_path).await?;
|
let config = Config::from_file(&config_path).await?;
|
||||||
|
|
||||||
let app = app::App::new_static(config).await?;
|
let app = app::App::new_static(config).await?;
|
||||||
|
|
||||||
let cli = Command::parse();
|
|
||||||
tracing::debug!("Starting cli");
|
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Some(_) => todo!(),
|
Some(cmd) => match cmd {
|
||||||
|
Commands::Init(mut shell) => {
|
||||||
|
shell.execute().await?;
|
||||||
|
}
|
||||||
|
Commands::Update(mut update) => {
|
||||||
|
update.execute(app).await?;
|
||||||
|
}
|
||||||
|
Commands::Clone(mut clone) => {
|
||||||
|
clone.execute(app).await?;
|
||||||
|
}
|
||||||
|
Commands::Worktree(mut wt) => {
|
||||||
|
wt.execute(app).await?;
|
||||||
|
}
|
||||||
|
},
|
||||||
None => {
|
None => {
|
||||||
RootCommand::new(app).execute().await?;
|
RootCommand::new(app)
|
||||||
|
.execute(
|
||||||
|
cli.search.as_ref(),
|
||||||
|
!cli.no_cache,
|
||||||
|
!cli.no_clone,
|
||||||
|
!cli.no_shell,
|
||||||
|
cli.force_refresh,
|
||||||
|
cli.force_cache_update,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,22 @@
|
|||||||
|
#[cfg(not(feature = "example"))]
|
||||||
|
pub use implementation::*;
|
||||||
|
|
||||||
|
#[cfg(feature = "example")]
|
||||||
|
pub use example_projects::*;
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
|
||||||
|
pub trait ProjectsListApp {
|
||||||
|
fn projects_list(&self) -> ProjectsList;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProjectsListApp for &'static App {
|
||||||
|
fn projects_list(&self) -> ProjectsList {
|
||||||
|
ProjectsList::new(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod implementation {
|
||||||
use crate::{
|
use crate::{
|
||||||
app::App,
|
app::App,
|
||||||
git_provider::{
|
git_provider::{
|
||||||
@@ -73,7 +92,10 @@ impl ProjectsList {
|
|||||||
for github in self.app.config.providers.github.iter() {
|
for github in self.app.config.providers.github.iter() {
|
||||||
if let Some(_user) = &github.current_user {
|
if let Some(_user) = &github.current_user {
|
||||||
let mut repos = github_provider
|
let mut repos = github_provider
|
||||||
.list_repositories_for_current_user(github.url.as_ref(), &github.access_token)
|
.list_repositories_for_current_user(
|
||||||
|
github.url.as_ref(),
|
||||||
|
&github.access_token,
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
repositories.append(&mut repos);
|
repositories.append(&mut repos);
|
||||||
@@ -107,13 +129,7 @@ impl ProjectsList {
|
|||||||
Ok(repositories)
|
Ok(repositories)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait ProjectsListApp {
|
|
||||||
fn projects_list(&self) -> ProjectsList;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProjectsListApp for &'static App {
|
#[cfg(feature = "example")]
|
||||||
fn projects_list(&self) -> ProjectsList {
|
mod example_projects;
|
||||||
ProjectsList::new(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
84
crates/gitnow/src/projects_list/example_projects.rs
Normal file
84
crates/gitnow/src/projects_list/example_projects.rs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
use crate::{app::App, git_provider::Repository};
|
||||||
|
|
||||||
|
pub struct ProjectsList {}
|
||||||
|
|
||||||
|
impl ProjectsList {
|
||||||
|
pub fn new(_app: &'static App) -> Self {
|
||||||
|
Self {}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_projects(&self) -> anyhow::Result<Vec<Repository>> {
|
||||||
|
Ok(self.from_strings([
|
||||||
|
"github.com/kjuulh/gitnow",
|
||||||
|
"github.com/kjuulh/gitnow-client",
|
||||||
|
"github.com/kjuulh/crunch",
|
||||||
|
"git.front.kjuulh.io/kjuulh/gitnow",
|
||||||
|
"git.front.kjuulh.io/kjuulh/gitnow-client",
|
||||||
|
"git.front.kjuulh.io/kjuulh/cuddle",
|
||||||
|
"git.front.kjuulh.io/kjuulh/buckle",
|
||||||
|
"git.front.kjuulh.io/kjuulh/books",
|
||||||
|
"git.front.kjuulh.io/kjuulh/blog-deployment",
|
||||||
|
"git.front.kjuulh.io/kjuulh/blog",
|
||||||
|
"git.front.kjuulh.io/kjuulh/bitfield",
|
||||||
|
"git.front.kjuulh.io/kjuulh/bitebuds-deployment",
|
||||||
|
"git.front.kjuulh.io/kjuulh/bitebuds",
|
||||||
|
"git.front.kjuulh.io/kjuulh/beerday",
|
||||||
|
"git.front.kjuulh.io/kjuulh/bearing",
|
||||||
|
"git.front.kjuulh.io/kjuulh/basic-webserver",
|
||||||
|
"git.front.kjuulh.io/kjuulh/backup",
|
||||||
|
"git.front.kjuulh.io/kjuulh/backstage",
|
||||||
|
"git.front.kjuulh.io/kjuulh/autom8-calendar-integration",
|
||||||
|
"git.front.kjuulh.io/kjuulh/astronvim",
|
||||||
|
"git.front.kjuulh.io/kjuulh/artifacts",
|
||||||
|
"git.front.kjuulh.io/kjuulh/articles",
|
||||||
|
"git.front.kjuulh.io/kjuulh/acc-server",
|
||||||
|
"git.front.kjuulh.io/kjuulh/_cargo-index",
|
||||||
|
"git.front.kjuulh.io/keep-up/keep-up-example",
|
||||||
|
"git.front.kjuulh.io/keep-up/keep-up",
|
||||||
|
"git.front.kjuulh.io/experiments/wasm-bin",
|
||||||
|
"git.front.kjuulh.io/dotfiles/doom",
|
||||||
|
"git.front.kjuulh.io/danskebank/testssl.sh",
|
||||||
|
"git.front.kjuulh.io/clank/kubernetes-state",
|
||||||
|
"git.front.kjuulh.io/clank/kubernetes-init",
|
||||||
|
"git.front.kjuulh.io/clank/blog",
|
||||||
|
"git.front.kjuulh.io/cibus/deployments",
|
||||||
|
"git.front.kjuulh.io/butikkaerlighilsen/client",
|
||||||
|
"git.front.kjuulh.io/bevy/bevy",
|
||||||
|
"git.front.kjuulh.io/OpenFood/openfood",
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_strings(
|
||||||
|
&self,
|
||||||
|
repos_into: impl IntoIterator<Item = impl Into<Repository>>,
|
||||||
|
) -> Vec<Repository> {
|
||||||
|
let repos = repos_into
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| item.into())
|
||||||
|
.collect::<Vec<Repository>>();
|
||||||
|
|
||||||
|
repos
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&str> for Repository {
|
||||||
|
fn from(value: &str) -> Self {
|
||||||
|
let values = value.split("/").collect::<Vec<_>>();
|
||||||
|
if values.len() != 3 {
|
||||||
|
panic!("value: '{value}' isn't a valid provider/owner/repository")
|
||||||
|
}
|
||||||
|
|
||||||
|
let (provider, owner, name) = (
|
||||||
|
values.get(0).unwrap(),
|
||||||
|
values.get(1).unwrap(),
|
||||||
|
values.get(2).unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
provider: provider.to_string(),
|
||||||
|
owner: owner.to_string(),
|
||||||
|
repo_name: name.to_string(),
|
||||||
|
ssh_url: format!("ssh://git@{provider}/{owner}/{name}.git"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
71
crates/gitnow/src/shell.rs
Normal file
71
crates/gitnow/src/shell.rs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
|
|
||||||
|
use crate::{app::App, git_provider::Repository};
|
||||||
|
|
||||||
|
pub struct Shell {
|
||||||
|
app: &'static App,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Shell {
|
||||||
|
pub fn new(app: &'static App) -> Self {
|
||||||
|
Self { app }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn spawn_shell(&self, repository: &Repository) -> anyhow::Result<()> {
|
||||||
|
let project_path = self
|
||||||
|
.app
|
||||||
|
.config
|
||||||
|
.settings
|
||||||
|
.projects
|
||||||
|
.directory
|
||||||
|
.join(repository.to_rel_path());
|
||||||
|
|
||||||
|
self.spawn_shell_at(&project_path).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn spawn_shell_at(&self, path: &Path) -> anyhow::Result<()> {
|
||||||
|
if !path.exists() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"project path: {} does not exists, it is either a file, or hasn't been cloned",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let shell = std::env::var("SHELL")
|
||||||
|
.context("failed to find SHELL variable, required for spawning embedded shells")?;
|
||||||
|
|
||||||
|
let mut shell_cmd = tokio::process::Command::new(shell);
|
||||||
|
shell_cmd.current_dir(path);
|
||||||
|
|
||||||
|
let mut process = shell_cmd.spawn().context("failed to spawn child session")?;
|
||||||
|
|
||||||
|
let status = process.wait().await?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
tracing::warn!(
|
||||||
|
"child session returned non-zero, or missing return code: {}",
|
||||||
|
status.code().unwrap_or_default()
|
||||||
|
);
|
||||||
|
anyhow::bail!(
|
||||||
|
"child shell session failed with exit: {}",
|
||||||
|
status.code().unwrap_or(-1)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::debug!("child session returned 0 exit code");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait ShellApp {
|
||||||
|
fn shell(&self) -> Shell;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ShellApp for &'static App {
|
||||||
|
fn shell(&self) -> Shell {
|
||||||
|
Shell::new(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
146
crates/gitnow/src/template_command.rs
Normal file
146
crates/gitnow/src/template_command.rs
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
|
|
||||||
|
pub const DEFAULT_CLONE_COMMAND: &str = "git clone {{ ssh_url }} {{ path }}";
|
||||||
|
pub const DEFAULT_WORKTREE_CLONE_COMMAND: &str =
|
||||||
|
"git clone --bare {{ ssh_url }} {{ bare_path }}";
|
||||||
|
pub const DEFAULT_WORKTREE_ADD_COMMAND: &str =
|
||||||
|
"git -C {{ bare_path }} worktree add {{ worktree_path }} {{ branch }}";
|
||||||
|
pub const DEFAULT_LIST_BRANCHES_COMMAND: &str =
|
||||||
|
"git -C {{ bare_path }} branch --format=%(refname:short)";
|
||||||
|
|
||||||
|
pub async fn render_and_execute(
|
||||||
|
template: &str,
|
||||||
|
context: HashMap<&str, &str>,
|
||||||
|
) -> anyhow::Result<std::process::Output> {
|
||||||
|
let (program, args) = render_command_parts(template, &context)?;
|
||||||
|
|
||||||
|
tracing::debug!("executing: {} {}", program, args.join(" "));
|
||||||
|
|
||||||
|
let output = tokio::process::Command::new(&program)
|
||||||
|
.args(&args)
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("failed to execute: {} {}", program, args.join(" ")))?;
|
||||||
|
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_command_parts(
|
||||||
|
template: &str,
|
||||||
|
context: &HashMap<&str, &str>,
|
||||||
|
) -> anyhow::Result<(String, Vec<String>)> {
|
||||||
|
let env = minijinja::Environment::new();
|
||||||
|
let rendered = env
|
||||||
|
.render_str(template, context)
|
||||||
|
.context("failed to render command template")?;
|
||||||
|
|
||||||
|
let parts =
|
||||||
|
shell_words::split(&rendered).context("failed to parse rendered command as shell words")?;
|
||||||
|
|
||||||
|
let (program, args) = parts
|
||||||
|
.split_first()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("command template rendered to empty string"))?;
|
||||||
|
|
||||||
|
Ok((program.clone(), args.to_vec()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_clone_command() {
|
||||||
|
let context = HashMap::from([
|
||||||
|
("ssh_url", "ssh://git@github.com/owner/repo.git"),
|
||||||
|
("path", "/home/user/git/github.com/owner/repo"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let (program, args) = render_command_parts(DEFAULT_CLONE_COMMAND, &context).unwrap();
|
||||||
|
assert_eq!(program, "git");
|
||||||
|
assert_eq!(
|
||||||
|
args,
|
||||||
|
vec![
|
||||||
|
"clone",
|
||||||
|
"ssh://git@github.com/owner/repo.git",
|
||||||
|
"/home/user/git/github.com/owner/repo"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_jj_clone_command() {
|
||||||
|
let template = "jj git clone {{ ssh_url }} {{ path }}";
|
||||||
|
let context = HashMap::from([
|
||||||
|
("ssh_url", "ssh://git@github.com/owner/repo.git"),
|
||||||
|
("path", "/home/user/git/github.com/owner/repo"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let (program, args) = render_command_parts(template, &context).unwrap();
|
||||||
|
assert_eq!(program, "jj");
|
||||||
|
assert_eq!(
|
||||||
|
args,
|
||||||
|
vec![
|
||||||
|
"git",
|
||||||
|
"clone",
|
||||||
|
"ssh://git@github.com/owner/repo.git",
|
||||||
|
"/home/user/git/github.com/owner/repo"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_worktree_clone_command() {
|
||||||
|
let context = HashMap::from([
|
||||||
|
("ssh_url", "ssh://git@github.com/owner/repo.git"),
|
||||||
|
(
|
||||||
|
"bare_path",
|
||||||
|
"/home/user/git/github.com/owner/repo/.bare",
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let (program, args) =
|
||||||
|
render_command_parts(DEFAULT_WORKTREE_CLONE_COMMAND, &context).unwrap();
|
||||||
|
assert_eq!(program, "git");
|
||||||
|
assert_eq!(
|
||||||
|
args,
|
||||||
|
vec![
|
||||||
|
"clone",
|
||||||
|
"--bare",
|
||||||
|
"ssh://git@github.com/owner/repo.git",
|
||||||
|
"/home/user/git/github.com/owner/repo/.bare"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_worktree_add_command() {
|
||||||
|
let context = HashMap::from([
|
||||||
|
(
|
||||||
|
"bare_path",
|
||||||
|
"/home/user/git/github.com/owner/repo/.bare",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"worktree_path",
|
||||||
|
"/home/user/git/github.com/owner/repo/feature-x",
|
||||||
|
),
|
||||||
|
("branch", "feature/x"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let (program, args) =
|
||||||
|
render_command_parts(DEFAULT_WORKTREE_ADD_COMMAND, &context).unwrap();
|
||||||
|
assert_eq!(program, "git");
|
||||||
|
assert_eq!(
|
||||||
|
args,
|
||||||
|
vec![
|
||||||
|
"-C",
|
||||||
|
"/home/user/git/github.com/owner/repo/.bare",
|
||||||
|
"worktree",
|
||||||
|
"add",
|
||||||
|
"/home/user/git/github.com/owner/repo/feature-x",
|
||||||
|
"feature/x"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
182
crates/gitnow/src/worktree.rs
Normal file
182
crates/gitnow/src/worktree.rs
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::{app::App, git_provider::Repository, template_command};
|
||||||
|
|
||||||
|
pub struct Worktree {
|
||||||
|
app: &'static App,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Worktree {
|
||||||
|
pub fn new(app: &'static App) -> Self {
|
||||||
|
Self { app }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the project path and bare path for a repository in worktree mode.
|
||||||
|
/// Layout: <project_path>/.bare/ for the bare clone,
|
||||||
|
/// <project_path>/<branch>/ for each worktree.
|
||||||
|
pub fn paths(&self, repository: &Repository) -> (PathBuf, PathBuf) {
|
||||||
|
let project_path = self
|
||||||
|
.app
|
||||||
|
.config
|
||||||
|
.settings
|
||||||
|
.projects
|
||||||
|
.directory
|
||||||
|
.join(repository.to_rel_path());
|
||||||
|
let bare_path = project_path.join(".bare");
|
||||||
|
(project_path, bare_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensures a bare clone exists at `<project_path>/.bare/`.
|
||||||
|
/// Skips if already present.
|
||||||
|
pub async fn ensure_bare_clone(
|
||||||
|
&self,
|
||||||
|
repository: &Repository,
|
||||||
|
bare_path: &Path,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
if bare_path.exists() {
|
||||||
|
tracing::info!("bare clone already exists at {}", bare_path.display());
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let template = self
|
||||||
|
.app
|
||||||
|
.config
|
||||||
|
.settings
|
||||||
|
.worktree
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|w| w.clone_command.as_deref())
|
||||||
|
.unwrap_or(template_command::DEFAULT_WORKTREE_CLONE_COMMAND);
|
||||||
|
|
||||||
|
let bare_path_str = bare_path.display().to_string();
|
||||||
|
let context = HashMap::from([
|
||||||
|
("ssh_url", repository.ssh_url.as_str()),
|
||||||
|
("bare_path", bare_path_str.as_str()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"bare-cloning {} into {}",
|
||||||
|
repository.ssh_url.as_str(),
|
||||||
|
bare_path.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
let output = template_command::render_and_execute(template, context).await?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = std::str::from_utf8(&output.stderr).unwrap_or_default();
|
||||||
|
anyhow::bail!("failed to bare-clone: {}", stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_branches(&self, bare_path: &Path) -> anyhow::Result<Vec<String>> {
|
||||||
|
let template = self
|
||||||
|
.app
|
||||||
|
.config
|
||||||
|
.settings
|
||||||
|
.worktree
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|w| w.list_branches_command.as_deref())
|
||||||
|
.unwrap_or(template_command::DEFAULT_LIST_BRANCHES_COMMAND);
|
||||||
|
|
||||||
|
let bare_path_str = bare_path.display().to_string();
|
||||||
|
let context = HashMap::from([("bare_path", bare_path_str.as_str())]);
|
||||||
|
|
||||||
|
let output = template_command::render_and_execute(template, context).await?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = std::str::from_utf8(&output.stderr).unwrap_or_default();
|
||||||
|
anyhow::bail!("failed to list branches: {}", stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
let stdout = std::str::from_utf8(&output.stdout)?;
|
||||||
|
let branches: Vec<String> = stdout
|
||||||
|
.lines()
|
||||||
|
.map(|l| l.trim())
|
||||||
|
.filter(|l| !l.is_empty())
|
||||||
|
.filter(|l| !l.contains("HEAD"))
|
||||||
|
// Strip origin/ prefix if present (for non-bare repos or custom commands)
|
||||||
|
.map(|l| l.strip_prefix("origin/").unwrap_or(l).to_string())
|
||||||
|
.collect::<BTreeSet<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(branches)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_worktree(
|
||||||
|
&self,
|
||||||
|
bare_path: &Path,
|
||||||
|
worktree_path: &Path,
|
||||||
|
branch: &str,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let template = self
|
||||||
|
.app
|
||||||
|
.config
|
||||||
|
.settings
|
||||||
|
.worktree
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|w| w.add_command.as_deref())
|
||||||
|
.unwrap_or(template_command::DEFAULT_WORKTREE_ADD_COMMAND);
|
||||||
|
|
||||||
|
let bare_path_str = bare_path.display().to_string();
|
||||||
|
let worktree_path_str = worktree_path.display().to_string();
|
||||||
|
let context = HashMap::from([
|
||||||
|
("bare_path", bare_path_str.as_str()),
|
||||||
|
("worktree_path", worktree_path_str.as_str()),
|
||||||
|
("branch", branch),
|
||||||
|
]);
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"creating worktree for branch '{}' at {}",
|
||||||
|
branch,
|
||||||
|
worktree_path.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
let output = template_command::render_and_execute(template, context).await?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = std::str::from_utf8(&output.stderr).unwrap_or_default();
|
||||||
|
anyhow::bail!("failed to create worktree: {}", stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sanitize_branch_name(branch: &str) -> String {
|
||||||
|
let sanitized = branch.replace('/', "-");
|
||||||
|
if let Some(stripped) = sanitized.strip_prefix('.') {
|
||||||
|
format!("_{stripped}")
|
||||||
|
} else {
|
||||||
|
sanitized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait WorktreeApp {
|
||||||
|
fn worktree(&self) -> Worktree;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorktreeApp for &'static App {
|
||||||
|
fn worktree(&self) -> Worktree {
|
||||||
|
Worktree::new(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sanitize_branch_name() {
|
||||||
|
assert_eq!(sanitize_branch_name("feature/login"), "feature-login");
|
||||||
|
assert_eq!(sanitize_branch_name("main"), "main");
|
||||||
|
assert_eq!(
|
||||||
|
sanitize_branch_name("fix/nested/path"),
|
||||||
|
"fix-nested-path"
|
||||||
|
);
|
||||||
|
assert_eq!(sanitize_branch_name(".hidden"), "_hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
11
cuddle.yaml
11
cuddle.yaml
@@ -6,6 +6,9 @@ vars:
|
|||||||
service: "gitnow"
|
service: "gitnow"
|
||||||
registry: kasperhermansen
|
registry: kasperhermansen
|
||||||
|
|
||||||
|
rust:
|
||||||
|
publish: {}
|
||||||
|
|
||||||
please:
|
please:
|
||||||
project:
|
project:
|
||||||
owner: kjuulh
|
owner: kjuulh
|
||||||
@@ -15,3 +18,11 @@ please:
|
|||||||
api_url: "https://git.front.kjuulh.io"
|
api_url: "https://git.front.kjuulh.io"
|
||||||
actions:
|
actions:
|
||||||
rust:
|
rust:
|
||||||
|
|
||||||
|
scripts:
|
||||||
|
record:
|
||||||
|
type: shell
|
||||||
|
update-gifs:
|
||||||
|
type: shell
|
||||||
|
install:
|
||||||
|
type: shell
|
||||||
|
|||||||
34
examples/config/git-worktrees.toml
Normal file
34
examples/config/git-worktrees.toml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Example: git with worktree support
|
||||||
|
#
|
||||||
|
# Uses bare repositories for worktrees. Directory layout:
|
||||||
|
#
|
||||||
|
# ~/git/github.com/owner/repo/
|
||||||
|
# ├── .bare/ # bare clone
|
||||||
|
# ├── main/ # worktree
|
||||||
|
# └── feature-x/ # worktree
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# gitnow worktree # pick repo, then pick branch
|
||||||
|
# gitnow worktree myrepo -b main
|
||||||
|
|
||||||
|
[settings]
|
||||||
|
# Normal clone (used by `gitnow` without worktree subcommand)
|
||||||
|
# clone_command = "git clone {{ ssh_url }} {{ path }}"
|
||||||
|
|
||||||
|
[settings.worktree]
|
||||||
|
# All of these are the defaults — shown here for reference.
|
||||||
|
# You only need [settings.worktree] if you want to override them.
|
||||||
|
|
||||||
|
# Bare clone for worktree repos
|
||||||
|
clone_command = "git clone --bare {{ ssh_url }} {{ bare_path }}"
|
||||||
|
|
||||||
|
# Create a worktree from the bare repo
|
||||||
|
add_command = "git -C {{ bare_path }} worktree add {{ worktree_path }} {{ branch }}"
|
||||||
|
|
||||||
|
# List branches in the bare repo
|
||||||
|
list_branches_command = "git -C {{ bare_path }} branch --format=%(refname:short)"
|
||||||
|
|
||||||
|
[[providers.github]]
|
||||||
|
current_user = "your-user"
|
||||||
|
access_token = { env = "GITHUB_ACCESS_TOKEN" }
|
||||||
|
organisations = ["your-org"]
|
||||||
@@ -1,7 +1,31 @@
|
|||||||
|
[settings]
|
||||||
|
# Runs after a project is fetched for the first time, either a single string, or multiple in an array
|
||||||
|
post_clone_command = "jj git init --colocate"
|
||||||
|
# Runs after a project is jumped to if it already exists.
|
||||||
|
post_update_command = ["git pull", "jj git fetch"]
|
||||||
|
|
||||||
[[providers.github]]
|
[[providers.github]]
|
||||||
|
# Who is the user running the clone command
|
||||||
|
current_user = "kjuulh"
|
||||||
|
# How to authenticate to github
|
||||||
|
access_token = { env = "GITHUB_ACCESS_TOKEN" }
|
||||||
|
|
||||||
|
# Which users to index
|
||||||
users = ["kjuulh"]
|
users = ["kjuulh"]
|
||||||
|
# Which organisations to index
|
||||||
organisations = ["lunarway"]
|
organisations = ["lunarway"]
|
||||||
|
|
||||||
[[providers.gitea]]
|
[[providers.gitea]]
|
||||||
|
# WHich gitea instance to authenticate against
|
||||||
|
url = "https://git.front.kjuulh.io/api/v1"
|
||||||
|
|
||||||
|
# How to authenticate to gitea
|
||||||
|
current_user = "kjuulh"
|
||||||
|
|
||||||
|
# WHich token to use to authenticate
|
||||||
|
access_token = { env = "GITEA_ACCESS_TOKEN" }
|
||||||
|
|
||||||
|
# Which users to index
|
||||||
users = ["kjuulh"]
|
users = ["kjuulh"]
|
||||||
|
# Which organisations to index
|
||||||
organisation = ["noorgplease"]
|
organisation = ["noorgplease"]
|
||||||
|
|||||||
42
examples/config/jj-worktrees.toml
Normal file
42
examples/config/jj-worktrees.toml
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Example: jj (Jujutsu) with workspace support
|
||||||
|
#
|
||||||
|
# Uses jj for both normal clones and worktrees/workspaces.
|
||||||
|
#
|
||||||
|
# Normal clone (`gitnow`):
|
||||||
|
# ~/git/github.com/owner/repo/ # jj git clone
|
||||||
|
#
|
||||||
|
# Worktree/workspace (`gitnow worktree`):
|
||||||
|
# ~/git/github.com/owner/repo/
|
||||||
|
# ├── .bare/ # jj git clone (used as the main repo)
|
||||||
|
# ├── main/ # jj workspace
|
||||||
|
# └── feature-x/ # jj workspace
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# gitnow # clone with jj, enter repo
|
||||||
|
# gitnow worktree # pick repo, then pick branch/workspace
|
||||||
|
# gitnow worktree myrepo -b main
|
||||||
|
|
||||||
|
[settings]
|
||||||
|
# Use jj for normal clones
|
||||||
|
clone_command = "jj git clone {{ ssh_url }} {{ path }}"
|
||||||
|
|
||||||
|
# Runs after a project is fetched for the first time
|
||||||
|
post_clone_command = "jj git fetch --all-remotes"
|
||||||
|
|
||||||
|
# Runs when jumping to an already-cloned project
|
||||||
|
post_update_command = "jj git fetch --all-remotes"
|
||||||
|
|
||||||
|
[settings.worktree]
|
||||||
|
# Clone the repo for worktree use
|
||||||
|
clone_command = "jj git clone {{ ssh_url }} {{ bare_path }}"
|
||||||
|
|
||||||
|
# Create a new jj workspace for the selected branch
|
||||||
|
add_command = "jj -R {{ bare_path }} workspace add --name {{ branch }} {{ worktree_path }}"
|
||||||
|
|
||||||
|
# List bookmarks from the jj repo (one name per line)
|
||||||
|
list_branches_command = "jj -R {{ bare_path }} bookmark list -T 'name ++ \"\\n\"'"
|
||||||
|
|
||||||
|
[[providers.github]]
|
||||||
|
current_user = "your-user"
|
||||||
|
access_token = { env = "GITHUB_ACCESS_TOKEN" }
|
||||||
|
organisations = ["your-org"]
|
||||||
6
mise.toml
Normal file
6
mise.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[tools]
|
||||||
|
rust = "1.93"
|
||||||
|
cargo = "latest"
|
||||||
|
|
||||||
|
[tasks.install]
|
||||||
|
run = "cargo install --path ./crates/gitnow"
|
||||||
5
scripts/install.sh
Executable file
5
scripts/install.sh
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env zsh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cargo install --path crates/gitnow
|
||||||
13
scripts/record.sh
Executable file
13
scripts/record.sh
Executable file
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env zsh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Loop through each file in the folder
|
||||||
|
for file in "vhs"/*; do
|
||||||
|
# Check if it is a file (not a directory)
|
||||||
|
if [[ -f "$file" ]]; then
|
||||||
|
echo "Recording: $file"
|
||||||
|
|
||||||
|
vhs "./$file"
|
||||||
|
fi
|
||||||
|
done
|
||||||
11
scripts/update-gifs.sh
Executable file
11
scripts/update-gifs.sh
Executable file
@@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env zsh
|
||||||
|
|
||||||
|
rm -r assets/gifs
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cargo build --features example && clear
|
||||||
|
|
||||||
|
cuddle x record
|
||||||
|
mkdir -p assets/gifs
|
||||||
|
mv target/vhs/* assets/gifs
|
||||||
17
vhs/example.vhs
Normal file
17
vhs/example.vhs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
Output "target/vhs/example.gif"
|
||||||
|
Set Theme "Dracula"
|
||||||
|
Set Width 1200
|
||||||
|
Set Height 1000
|
||||||
|
Hide
|
||||||
|
Type "./target/debug/gitnow --no-cache --force-refresh"
|
||||||
|
Enter
|
||||||
|
Show
|
||||||
|
Sleep 2s
|
||||||
|
Type@500ms "bevy"
|
||||||
|
Sleep 1s
|
||||||
|
Enter
|
||||||
|
Sleep 10s
|
||||||
|
Type "echo 'I am now in bevy!'"
|
||||||
|
Enter
|
||||||
|
Sleep 5s
|
||||||
|
Sleep 2s
|
||||||
Reference in New Issue
Block a user