107 Commits

Author SHA1 Message Date
5157ddfe8c fix(deps): update tokio-prost monorepo to 0.14.0
Some checks failed
continuous-integration/drone/push Build encountered an error
continuous-integration/drone/pr Build encountered an error
2026-03-01 01:31:52 +00:00
38a51f3aa7 feat: add worktree / workspaces implementation
Some checks failed
continuous-integration/drone/pr Build encountered an error
continuous-integration/drone/push Build encountered an error
Signed-off-by: kjuulh <contact@kjuulh.io>
2026-02-28 17:12:22 +01:00
43aac23453 feat: install
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-02-10 12:40:51 +01:00
f90dc1e8d5 feat: remove nightly features
Some checks failed
continuous-integration/drone/pr Build encountered an error
continuous-integration/drone/push Build encountered an error
2026-02-02 14:35:51 +01:00
aef7b65e0c feat: bogus commit
Some checks failed
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build encountered an error
2025-11-13 02:10:31 +01:00
313b971c74 feat: link to website 2025-11-13 02:04:51 +01:00
167d71024d fix(deps): update rust crate bytes to v1.10.1
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-03-06 01:41:55 +00:00
cuddle-please
e2102bfd99 chore(release): 0.3.5
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-03-04 12:16:38 +00:00
716adfab8b feat: allow clone all
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-03-04 13:11:37 +01:00
c77a9dcbef chore(deps): update all dependencies
Some checks failed
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is failing
2025-03-03 05:39:38 +00:00
7077c623ca chore(deps): update all dependencies
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-02-21 01:43:38 +00:00
80614fa667 fix(deps): update rust crate serde to v1.0.218
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-02-20 05:41:56 +00:00
6ff93b100a chore(deps): update rust crate clap to v4.5.29
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-02-13 05:36:34 +00:00
76b617fc2d fix(deps): update tokio-prost monorepo to v0.13.5
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-02-13 01:38:36 +00:00
d699432f14 fix(deps): update rust crate termwiz to 0.23.0
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-02-11 01:41:18 +00:00
5c46cca96f fix(deps): update all dependencies
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-02-06 01:41:02 +00:00
8d071981a3 fix(deps): update rust crate uuid to v1.13.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-02-05 05:43:47 +00:00
8f9e40b98f fix(deps): update all dependencies
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-02-04 01:41:54 +00:00
98d4402461 fix(deps): update rust crate async-trait to v0.1.86
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-02-01 05:40:38 +00:00
816a65a04c fix(deps): update rust crate uuid to v1.12.1
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-01-21 05:41:33 +00:00
d893e7e21a chore(deps): update rust crate clap to v4.5.27
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-01-21 01:38:57 +00:00
2a3c4fbb83 fix(deps): update rust crate octocrab to 0.43.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-01-18 01:38:50 +00:00
9d459f10d5 fix(deps): update rust crate uuid to v1.12.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-01-15 01:44:24 +00:00
5aa5702272 fix(deps): update rust crate dirs to v6
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-01-13 01:37:39 +00:00
45b45420f8 fix(deps): update rust crate uuid to v1.11.1
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-01-10 05:33:24 +00:00
b49da1ca71 chore(deps): update rust crate clap to v4.5.26
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-01-10 01:34:25 +00:00
3c63992632 chore(deps): update rust crate tokio to v1.43.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-01-09 01:36:46 +00:00
aaf9cd414e chore: update example
All checks were successful
continuous-integration/drone/push Build is passing
2025-01-08 23:03:41 +01:00
0f30aaadf5 chore(release): v0.3.4 (#57)
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
chore(release): 0.3.4

Co-authored-by: cuddle-please <bot@cuddle.sh>
Reviewed-on: https://git.front.kjuulh.io/kjuulh/gitnow/pulls/57
2025-01-08 22:55:40 +01:00
84941de931 fix: use correct post clone command
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-01-08 22:53:22 +01:00
aa1ef7041e fix: tests for config
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-01-08 22:49:13 +01:00
2f501c0efd feat/add-post-clone-command
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is passing
2025-01-08 22:47:23 +01:00
962425c515 add ability to specify multiple commands
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-01-08 22:42:35 +01:00
708f8d6388 feat: add ability to specify custom command
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-01-08 22:32:39 +01:00
cuddle-please
5e97f68a77 chore(release): 0.3.3
Some checks failed
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is failing
2025-01-07 22:22:43 +00:00
eb684ef8e9 chore: replace dotenv with dotenvy, a slightly more maintained version of the same library (#50)
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: https://git.front.kjuulh.io/kjuulh/gitnow/pulls/50
Co-authored-by: kjuulh <contact@kjuulh.io>
Co-committed-by: kjuulh <contact@kjuulh.io>
2025-01-07 23:21:32 +01:00
176dc9f094 chore(release): v0.3.2 (#45)
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
chore(release): 0.3.3

Co-authored-by: cuddle-please <bot@cuddle.sh>
Reviewed-on: https://git.front.kjuulh.io/kjuulh/gitnow/pulls/45
2025-01-07 23:13:59 +01:00
1b9f8d96fc fix: cargo.lock (#49)
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: https://git.front.kjuulh.io/kjuulh/gitnow/pulls/49
Co-authored-by: kjuulh <contact@kjuulh.io>
Co-committed-by: kjuulh <contact@kjuulh.io>
2025-01-07 22:47:49 +01:00
cuddle-please
69d008feb6 chore(release): 0.3.2
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2025-01-07 22:36:46 +01:00
ada020e283 feat: allow ctrl+c to exit application
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2025-01-07 22:36:46 +01:00
708180f9a2 fix(deps): update rust crate async-trait to v0.1.85
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-01-07 01:35:21 +00:00
33ba27bf77 chore(release): v0.3.1 (#43)
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
chore(release): 0.3.1

Co-authored-by: cuddle-please <bot@cuddle.sh>
Reviewed-on: https://git.front.kjuulh.io/kjuulh/gitnow/pulls/43
2025-01-02 09:47:09 +01:00
74a1daac4c chore: enable publish
All checks were successful
continuous-integration/drone/push Build is passing
2025-01-02 09:44:37 +01:00
3bd243b45c chore(release): v0.3.0 (#15)
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
chore(release): 0.3.0

Co-authored-by: cuddle-please <bot@cuddle.sh>
Reviewed-on: https://git.front.kjuulh.io/kjuulh/gitnow/pulls/15
2025-01-02 09:41:06 +01:00
15620da103 feat: add small help to see how much time is left in cache
All checks were successful
continuous-integration/drone/push Build is passing
Signed-off-by: kjuulh <contact@kjuulh.io>
2025-01-01 22:24:27 +01:00
a228ec82a3 chore: bump default cache duration to 7 days
All checks were successful
continuous-integration/drone/push Build is passing
Signed-off-by: kjuulh <contact@kjuulh.io>
2025-01-01 22:01:49 +01:00
503680d81c fix(deps): update rust crate serde to v1.0.217
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-12-28 01:36:56 +00:00
d13dc5b0a2 chore(deps): update rust crate anyhow to v1.0.95
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-12-23 01:39:03 +00:00
a1a993957b fix(deps): update rust crate serde to v1.0.216
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-12-11 05:32:18 +00:00
916beffb6d fix(deps): update tokio-prost monorepo to v0.13.4
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-12-07 01:38:34 +00:00
291d056dd7 chore(deps): update rust crate clap to v4.5.23
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-12-06 01:42:46 +00:00
dfacf96967 chore(deps): update all dependencies
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-12-04 01:44:09 +00:00
3524847d9f chore(deps): update rust crate tracing-subscriber to v0.3.19
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-11-30 02:14:09 +00:00
48b77deb5c fix(deps): update rust crate bytes to v1.9.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-11-29 01:30:54 +00:00
c3f626cce0 chore(deps): update rust crate tracing to v0.1.41
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-11-28 01:38:56 +00:00
52d522107d fix(deps): update all dependencies
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-11-23 01:38:40 +00:00
4dd8e58122 fix(deps): update rust crate octocrab to 0.42.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-11-15 01:31:30 +00:00
aa399e11c1 chore(deps): update rust crate clap to v4.5.21
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-11-14 01:32:34 +00:00
e670dc0384 fix(deps): update rust crate serde to v1.0.215
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-11-12 01:29:59 +00:00
8eb2970f5d chore(deps): update rust crate tokio to v1.41.1
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-11-08 01:31:47 +00:00
f94ef8e919 chore(deps): update rust crate anyhow to v1.0.93
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-11-07 01:31:24 +00:00
62c9166677 fix(deps): update rust crate url to v2.5.3
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-11-05 01:29:20 +00:00
5e859f99a9 chore(deps): update rust crate anyhow to v1.0.92
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-11-02 01:35:49 +00:00
4d78609839 fix(deps): update rust crate serde to v1.0.214
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-10-29 01:38:16 +00:00
90b7fe9b03 fix(deps): update rust crate serde to v1.0.213
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-10-23 04:39:58 +00:00
79ad2e872c chore(deps): update all dependencies to v1.0.91
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-10-23 00:39:42 +00:00
ab963600f3 fix(deps): update all dependencies
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-10-22 00:31:58 +00:00
1aab9c2970 chore(deps): update rust crate anyhow to v1.0.90
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-10-19 00:35:28 +00:00
9dfcb52010 fix(deps): update all dependencies
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-10-17 00:36:47 +00:00
659f2247ff fix(deps): update rust crate octocrab to v0.41.1
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-10-11 00:32:54 +00:00
76f82f9060 chore(deps): update rust crate clap to v4.5.20
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-10-09 00:34:52 +00:00
bdfd001458 fix(deps): update rust crate futures to v0.3.31
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-10-06 00:31:44 +00:00
f4a2b52c72 chore(deps): update rust crate clap to v4.5.19
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-10-02 00:34:42 +00:00
aa78933f0e fix(deps): update rust crate octocrab to 0.41.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-10-01 00:34:35 +00:00
b242128d52 chore(release): v0.2.3 (#12)
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
chore(release): 0.2.3

Co-authored-by: cuddle-please <bot@cuddle.sh>
Reviewed-on: https://git.front.kjuulh.io/kjuulh/gitnow/pulls/12
2024-09-26 21:41:11 +02:00
17cb06904f feat: add update command
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-26 21:36:03 +02:00
e3292b0c73 fix(deps): update rust crate async-trait to v0.1.83
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2024-09-25 00:35:40 +00:00
cc70131101 feat: only do clone if not exists
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-24 08:38:13 +02:00
784c7303a5 fix(deps): update rust crate octocrab to 0.40.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-09-24 00:35:26 +00:00
bf6d637095 chore(release): v0.2.2 (#10)
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
chore(release): 0.2.2

Co-authored-by: cuddle-please <bot@cuddle.sh>
Reviewed-on: https://git.front.kjuulh.io/kjuulh/gitnow/pulls/10
2024-09-23 22:44:13 +02:00
7231c85448 add docs
All checks were successful
continuous-integration/drone/push Build is passing
main@origin
2024-09-23 22:42:01 +02:00
b5c3c9bac9 chore: add license
Signed-off-by: kjuulh <contact@kjuulh.io>
2024-09-23 22:42:01 +02:00
1a2958a6e3 chore: update to gitea-client 2024-09-23 22:42:01 +02:00
1f5ad2a216 chore: add publish 2024-09-23 22:42:01 +02:00
cuddle-please
f117f74130 chore(release): 0.2.1 2024-09-23 22:39:38 +02:00
4c91bb7242 chore(release): v0.2.1 (#5)
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
chore(release): 0.2.1

Co-authored-by: cuddle-please <bot@cuddle.sh>
Reviewed-on: https://git.front.kjuulh.io/kjuulh/gitnow/pulls/5
2024-09-23 22:34:40 +02:00
348e448ce9 feat: use termwiz as backend as that enables a ptty, which can be cleaned up nicely
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-23 22:16:19 +02:00
f0f81f8a0b feat: add errout for interactive for script support and atty for clean output
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-23 21:35:10 +02:00
c9aacf0ecd feat: add clone spinner
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-23 19:45:34 +02:00
866a8b4c52 chore: update gif to include spinner
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-23 00:20:26 +02:00
5900482b56 feat: add spinner around download
Some checks failed
continuous-integration/drone/push Build is failing
2024-09-23 00:18:47 +02:00
96d97a8167 chore: clean up ui
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-22 16:34:20 +02:00
c2faf6d0b6 feat: spawn a subshell for session
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-22 16:25:44 +02:00
a330e4454e feat: implement git clone
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-22 15:58:28 +02:00
1eee1d9f3e chore: build in cuddle instead of vhs
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-22 15:11:23 +02:00
fbe030aba0 chore: build first then run
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-22 15:08:29 +02:00
fc66692f37 chore: clear screen after build
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-22 15:01:44 +02:00
bc4ebed1f7 chore: fix warnings
Some checks failed
continuous-integration/drone/push Build was killed
2024-09-22 14:59:44 +02:00
bee38a9d12 chore: update theme for vhs
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-22 14:57:19 +02:00
e2be9ba59a feat: include vhs demo
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-22 14:55:41 +02:00
5401f3707d feat: add interactive search
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-22 14:09:10 +02:00
d0a5da0946 fix(deps): update tokio-prost monorepo to v0.13.3
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-09-22 00:39:49 +00:00
8410453921 chore(deps): update rust crate clap to v4.5.18
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-09-21 00:56:50 +00:00
c7b9f75a0b fix(deps): update rust crate bytes to v1.7.2
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-09-19 00:32:38 +00:00
43a7196cf8 chore(deps): update rust crate pretty_assertions to v1.4.1
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-09-16 00:29:45 +00:00
39e1fea36f chore: refactor fuzzy match into own function
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-15 22:36:39 +02:00
c2dfd020bf chore: cleanup warnings
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-15 22:12:42 +02:00
41 changed files with 4467 additions and 811 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
target/
.cuddle/
.DS_Store

View File

@@ -6,6 +6,163 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [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
### Added

2505
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,14 +3,13 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "0.2.0"
version = "0.3.5"
[workspace.dependencies]
gitnow = { path = "crates/gitnow" }
anyhow = { version = "1" }
tokio = { version = "1", features = ["full"] }
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"] }
dotenv = { version = "0.15" }
dotenvy = { version = "0.15.7" }

View File

@@ -1,7 +1,27 @@
# 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.
![example gif](./assets/gifs/example.gif)
## 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?
1. Navigate to github.com
@@ -18,3 +38,75 @@ With gitnow
1. `git now`
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.
## 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

View File

@@ -1 +0,0 @@
/target

View File

@@ -1,8 +1,15 @@
[package]
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
publish = true
[dependencies]
anyhow.workspace = true
@@ -10,21 +17,31 @@ tokio.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
clap.workspace = true
dotenv.workspace = true
dotenvy.workspace = true
serde = { version = "1.0.197", features = ["derive"] }
uuid = { version = "1.7.0", features = ["v4"] }
async-trait = "0.1.82"
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"
octocrab = "0.39.0"
dirs = "5.0.1"
prost = "0.13.2"
prost-types = "0.13.2"
octocrab = "0.43.0"
dirs = "6.0.0"
prost = "0.14.0"
prost-types = "0.14.0"
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]
pretty_assertions = "1.4.0"
[features]
example = []

View 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
}

View File

@@ -79,6 +79,11 @@ impl Cache {
tracing::debug!("cache has expired");
return Ok(None);
}
tracing::debug!(
"cache is valid for: {} mins",
cache_duration.saturating_sub(file_modified_last).as_secs() / 60
);
}
}

View File

@@ -1 +1,84 @@
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(())
}
}
}

View File

@@ -1,7 +1,16 @@
use nucleo_matcher::{pattern::Pattern, Matcher, Utf32Str};
use std::{collections::BTreeMap, io::IsTerminal};
use crate::{
app::App, cache::CacheApp, fuzzy_matcher::FuzzyMatcherApp, projects_list::ProjectsListApp,
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)]
@@ -14,10 +23,20 @@ impl RootCommand {
Self { app }
}
pub async fn execute(&mut self, search: Option<impl Into<String>>) -> anyhow::Result<()> {
pub async fn execute(
&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");
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,
None => {
tracing::info!("finding repositories...");
@@ -27,39 +46,145 @@ impl RootCommand {
repositories
}
};
let needle = match search {
Some(needle) => needle.into(),
None => todo!(),
}
} 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
};
let haystack = repositories
.iter()
.map(|r| r.to_rel_path().display().to_string())
.collect::<Vec<_>>();
let repo = match search {
Some(needle) => {
let matched_repos = self
.app
.fuzzy_matcher()
.match_repositories(&needle.into(), &repositories);
let haystack = haystack.as_str_vec();
let repo = matched_repos
.first()
.ok_or(anyhow::anyhow!("failed to find repository"))?;
tracing::debug!("selected repo: {}", repo.to_rel_path().display());
let res = self.app.fuzzy_matcher().match_pattern(&needle, &haystack);
repo.to_owned()
}
None => {
let repo = self
.app
.interactive()
.interactive_search(&repositories)?
.ok_or(anyhow::anyhow!("failed to find a repository"))?;
let res = res.iter().take(10).rev().collect::<Vec<_>>();
tracing::debug!("selected repo: {}", repo.to_rel_path().display());
for repo in res {
tracing::debug!("repo: {:?}", repo);
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(())
}
}
trait StringExt {
fn as_str_vec<'a>(&'a self) -> Vec<&'a str>;
fn as_str_vec(&self) -> Vec<&str>;
}
impl StringExt for Vec<String> {
fn as_str_vec<'a>(&'a self) -> Vec<&'a str> {
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
}
}

View 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(())
}
}

View 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(())
}
}

View 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(())
}
}

View 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(())
}
}

View 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
})
}
}

View 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();
}
}

View 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);
}
}

View File

@@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
use anyhow::Context;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct Config {
#[serde(default)]
pub settings: Settings,
@@ -12,13 +12,110 @@ pub struct Config {
pub providers: Providers,
}
#[derive(Debug, Default, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Clone)]
pub struct Settings {
#[serde(default)]
pub projects: Projects,
#[serde(default)]
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 {
#[serde(default)]
pub location: CacheLocation,
@@ -50,7 +147,7 @@ impl Default for CacheLocation {
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
#[serde(untagged)]
pub enum CacheDuration {
Enabled(bool),
@@ -74,7 +171,7 @@ impl CacheDuration {
hours,
minutes,
} => Some(
std::time::Duration::from_days(*days)
std::time::Duration::from_hours(*days * 24)
+ std::time::Duration::from_hours(*hours)
+ std::time::Duration::from_mins(*minutes),
),
@@ -85,14 +182,14 @@ impl CacheDuration {
impl Default for CacheDuration {
fn default() -> Self {
Self::Precise {
days: 1,
days: 7,
hours: 0,
minutes: 0,
}
}
}
#[derive(Debug, Default, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Clone)]
pub struct Providers {
#[serde(default)]
pub github: Vec<GitHub>,
@@ -100,7 +197,7 @@ pub struct Providers {
pub gitea: Vec<Gitea>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct GitHub {
#[serde(default)]
pub url: Option<String>,
@@ -116,7 +213,7 @@ pub struct GitHub {
pub organisations: Vec<GitHubOrganisation>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct GitHubUser(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);
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 url: String,
@@ -162,21 +259,21 @@ pub struct Gitea {
pub organisations: Vec<GiteaOrganisation>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
#[serde(untagged)]
pub enum GiteaAccessToken {
Direct(String),
Env { env: String },
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
#[serde(untagged)]
pub enum GitHubAccessToken {
Direct(String),
Env { env: String },
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct GiteaUser(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);
impl From<GiteaOrganisation> for String {
fn from(value: GiteaOrganisation) -> Self {
@@ -231,6 +328,9 @@ mod test {
#[test]
fn test_can_parse_config() -> anyhow::Result<()> {
let content = r#"
[settings]
projects = { directory = "git" }
[settings.cache]
location = ".cache/gitnow"
duration = { days = 2 }
@@ -316,7 +416,14 @@ mod test {
hours: 0,
minutes: 0
}
}
},
projects: Projects {
directory: PathBuf::from("git").into()
},
post_update_command: None,
post_clone_command: None,
clone_command: None,
worktree: None,
}
},
config
@@ -340,7 +447,12 @@ mod test {
gitea: vec![]
},
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
@@ -348,4 +460,54 @@ mod test {
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(())
}
}

View 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())
}
}

View File

@@ -11,7 +11,7 @@ impl FuzzyMatcher {
pub fn match_pattern<'a>(&self, pattern: &'a str, items: &'a [&'a str]) -> Vec<&'a str> {
let pat = Pattern::new(
&pattern,
pattern,
nucleo_matcher::pattern::CaseMatching::Ignore,
nucleo_matcher::pattern::Normalization::Smart,
nucleo_matcher::pattern::AtomKind::Fuzzy,
@@ -19,7 +19,7 @@ impl FuzzyMatcher {
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<_>>()
res.into_iter().map(|(item, _)| *item).collect::<Vec<_>>()
}
}

View 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)
}
}

View File

@@ -1,5 +1,5 @@
use anyhow::Context;
use gitea_rs::apis::configuration::Configuration;
use gitea_client::apis::configuration::Configuration;
use url::Url;
use crate::{app::App, config::GiteaAccessToken};
@@ -68,9 +68,9 @@ impl GiteaProvider {
&self,
config: &Configuration,
page: usize,
) -> anyhow::Result<Vec<gitea_rs::models::Repository>> {
) -> anyhow::Result<Vec<gitea_client::models::Repository>> {
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
.context("failed to fetch repos for users")?;
@@ -125,9 +125,9 @@ impl GiteaProvider {
user: &str,
config: &Configuration,
page: usize,
) -> anyhow::Result<Vec<gitea_rs::models::Repository>> {
) -> anyhow::Result<Vec<gitea_client::models::Repository>> {
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
.context("failed to fetch repos for users")?;
@@ -184,8 +184,8 @@ impl GiteaProvider {
organisation: &str,
config: &Configuration,
page: usize,
) -> anyhow::Result<Vec<gitea_rs::models::Repository>> {
let repos = gitea_rs::apis::organization_api::org_list_repos(
) -> anyhow::Result<Vec<gitea_client::models::Repository>> {
let repos = gitea_client::apis::organization_api::org_list_repos(
config,
organisation,
Some(page as i32),
@@ -202,7 +202,7 @@ impl GiteaProvider {
api: &str,
access_token: Option<&GiteaAccessToken>,
) -> 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();
match access_token {
Some(GiteaAccessToken::Env { env }) => {

View File

@@ -1,10 +1,4 @@
use anyhow::Context;
use octocrab::{
auth::Auth,
models::{hooks::Config, Repository},
params::repos::Sort,
NoSvc, Octocrab, Page,
};
use octocrab::{models::Repository, params::repos::Sort, Octocrab, Page};
use crate::{app::App, config::GitHubAccessToken};
@@ -159,7 +153,7 @@ impl GitHubProvider {
fn get_client(
&self,
url: Option<&String>,
_url: Option<&String>,
access_token: &GitHubAccessToken,
) -> anyhow::Result<Octocrab> {
let client = octocrab::Octocrab::builder()

View 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);
}
}
}

View File

@@ -1,20 +1,30 @@
#![feature(duration_constructors)]
use std::path::PathBuf;
use anyhow::Context;
use clap::{Parser, Subcommand};
use commands::root::RootCommand;
use commands::{
clone::CloneCommand, root::RootCommand, shell::Shell, update::Update,
worktree::WorktreeCommand,
};
use config::Config;
use tracing::level_filters::LevelFilter;
use tracing_subscriber::EnvFilter;
mod app;
mod cache;
mod cache_codec;
mod commands;
mod components;
mod config;
mod custom_command;
mod fuzzy_matcher;
mod git_clone;
mod git_provider;
mod interactive;
mod projects_list;
mod shell;
mod template_command;
mod worktree;
#[derive(Parser)]
#[command(author, version, about, long_about = Some("Navigate git projects at the speed of thought"))]
@@ -22,40 +32,94 @@ struct Command {
#[command(subcommand)]
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)]
enum Commands {
Hello {},
Init(Shell),
Update(Update),
Clone(CloneCommand),
Worktree(WorktreeCommand),
}
const DEFAULT_CONFIG_PATH: &str = ".config/gitnow/gitnow.toml";
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenv::dotenv().ok();
tracing_subscriber::fmt::init();
dotenvy::dotenv().ok();
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 =
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 config_path = std::env::var("GITNOW_CONFIG")
std::env::var("GITNOW_CONFIG")
.map(PathBuf::from)
.unwrap_or(default_config_path);
.unwrap_or(default_config_path)
};
let config = Config::from_file(&config_path).await?;
let app = app::App::new_static(config).await?;
let cli = Command::parse();
tracing::debug!("Starting cli");
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 => {
RootCommand::new(app).execute(cli.search.as_ref()).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?;
}
}

View File

@@ -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::{
app::App,
git_provider::{
@@ -73,7 +92,10 @@ impl ProjectsList {
for github in self.app.config.providers.github.iter() {
if let Some(_user) = &github.current_user {
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?;
repositories.append(&mut repos);
@@ -107,13 +129,7 @@ impl ProjectsList {
Ok(repositories)
}
}
pub trait ProjectsListApp {
fn projects_list(&self) -> ProjectsList;
}
impl ProjectsListApp for &'static App {
fn projects_list(&self) -> ProjectsList {
ProjectsList::new(self)
}
}
#[cfg(feature = "example")]
mod example_projects;

View 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"),
}
}
}

View 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)
}
}

View 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"
]
);
}
}

View 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");
}
}

View File

@@ -6,6 +6,9 @@ vars:
service: "gitnow"
registry: kasperhermansen
rust:
publish: {}
please:
project:
owner: kjuulh
@@ -15,3 +18,11 @@ please:
api_url: "https://git.front.kjuulh.io"
actions:
rust:
scripts:
record:
type: shell
update-gifs:
type: shell
install:
type: shell

View 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"]

View File

@@ -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]]
# 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"]
# Which organisations to index
organisations = ["lunarway"]
[[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"]
# Which organisations to index
organisation = ["noorgplease"]

View 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
View 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
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env zsh
set -e
cargo install --path crates/gitnow

13
scripts/record.sh Executable file
View 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
View 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
View 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