59 Commits

Author SHA1 Message Date
772aa1ea42 feat: replace dependencies and enable build on macos
Some checks failed
Release / release (push) Failing after 1m47s
Signed-off-by: kjuulh <contact@kjuulh.io>
2026-03-20 17:29:11 +01:00
f0b8ffdb9b feat: swap order
Some checks failed
Release / release (push) Failing after 2m37s
2026-03-20 17:12:01 +01:00
5813c5ca21 feat: use local package instead of octocrab
Some checks failed
Release / release (push) Failing after 2m54s
2026-03-20 17:05:36 +01:00
746ed6c9e3 feat: removed openssl
Some checks failed
Release / release (push) Failing after 3m7s
2026-03-20 16:27:27 +01:00
3e70200c66 feat: replace rsutls
Some checks failed
Release / release (push) Failing after 3m9s
2026-03-20 16:06:12 +01:00
a898d3ceac feat: update image
Some checks failed
Release / release (push) Failing after 3m21s
2026-03-20 15:53:36 +01:00
536157c5f5 feat: mise should run in build
Some checks failed
Release / release (push) Failing after 5s
2026-03-20 15:52:10 +01:00
6a49c1eedc fix: add ca-certificates and pkg-config to release Dockerfile
Some checks failed
Release / release (push) Failing after 1m10s
Add missing ca-certificates (for curl/mise install), pkg-config and
libssl-dev (for cargo builds). Remove redundant cargo tool from
mise.toml since it ships with rust.
2026-03-20 15:47:08 +01:00
fbedae4159 feat: add project metadata with .gitnow.json
Some checks failed
Release / release (push) Failing after 5s
Store creation time, template, and repository info in a .gitnow.json
file when projects are created via CLI. Project listing now shows
human-readable relative times (e.g. "3 days ago") and sorts by
creation date. Metadata is updated when adding repos to existing
projects and gracefully ignored for pre-existing projects.
2026-03-20 15:36:57 +01:00
b8424d595b fix: install mise to /usr/local/bin in release Dockerfile
Some checks failed
continuous-integration/drone/push Build encountered an error
Release / release (push) Failing after 6s
Use MISE_INSTALL_PATH to place mise on the default PATH, fixing
"mise: not found" in Docker builds.
2026-03-20 15:31:01 +01:00
e10ff5d9fc feat: add release Docker image and Gitea Actions workflow
Some checks failed
continuous-integration/drone/push Build encountered an error
Release / release (push) Failing after 1m6s
Add Dockerfile.release using mise to install toolchain, Gitea Actions
workflow with snapshot dry-run on main pushes and full release on tags,
and mise release-docker task for local testing.
2026-03-20 15:28:00 +01:00
be8faa6a36 feat: add goreleaser config for cross-platform releases
Some checks failed
continuous-integration/drone/push Build encountered an error
Set up GoReleaser with Rust builder (cargo-zigbuild) targeting
linux/darwin x amd64/arm64, Gitea release publishing, and Homebrew
cask generation. Add mise tasks for test, release, and snapshot builds.
2026-03-20 15:22:24 +01:00
f3cc24c9fc docs: add projects and shell integration sections to README
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-03-20 14:41:54 +01:00
b5394a6b26 feat: add chooser file mechanism for shell directory switching
Some checks failed
continuous-integration/drone/push Build encountered an error
Replace stdout-based path capture with a temporary chooser file that the
shell wrapper reads after gitnow exits. Commands that select a directory
write to the file; commands that don't (e.g. project delete) leave it
empty, so the shell only cd's when appropriate. The chooser file path
can be set via --chooser-file flag or GITNOW_CHOOSER_FILE env var.
2026-03-20 14:39:42 +01:00
681993c379 refactor: eliminate code duplication across gitnow codebase
Some checks failed
continuous-integration/drone/push Build encountered an error
- Unify PostCloneCommand/PostUpdateCommand into single CommandList enum
- Add string_newtype! macro for config newtype wrappers
- Extract load_repositories() helper replacing 4 duplicated cache-or-fetch patterns
- Extract clone_repos_into() eliminating duplicated concurrent clone logic
- Merge TemplateEntry/ProjectEntry into single DirEntry struct
- Unify directory resolution and listing helpers in project command
- Simplify RepositoryMatcher, remove unused StringExt trait
2026-03-20 13:24:05 +01:00
bebb55e873 feat: add template bootstrapping for project create
Some checks failed
continuous-integration/drone/push Build encountered an error
Adds --template/-t flag to `gitnow project create` that copies files
from a template directory into new projects. Templates are discovered
from ~/.gitnow/templates/ (configurable via settings.project.templates_directory).
Includes a default template with a SPEC.md scaffold.
2026-03-20 13:08:15 +01:00
ad0f29826b feat: add project add command for cloning repos into existing projects
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-03-20 13:03:06 +01:00
fe26d71266 feat: add project command for scratch-pad multi-repo workspaces
Some checks failed
continuous-integration/drone/push Build encountered an error
Adds `gitnow project` with create, delete, and open subcommands.
Create allows multi-selecting repositories to clone into a shared
project directory. Includes multi-select TUI support in interactive module.
2026-03-20 12:53:50 +01: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
34 changed files with 3601 additions and 1735 deletions

View File

@@ -0,0 +1,30 @@
name: Release
on:
push:
branches:
- main
tags:
- "v*"
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build release image
run: docker build -f Dockerfile.release -t gitnow-release .
- name: Snapshot (dry run)
if: startsWith(github.ref, 'refs/tags/') == false
run: docker run --rm gitnow-release release-snapshot
- name: Release
if: startsWith(github.ref, 'refs/tags/')
run: |
docker run --rm \
-e GITEA_TOKEN=${{ secrets.GITEA_TOKEN }} \
gitnow-release release

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
target/ target/
.cuddle/ .cuddle/
.DS_Store .DS_Store
dist/

60
.goreleaser.yaml Normal file
View File

@@ -0,0 +1,60 @@
version: 2
before:
hooks:
- cargo install --locked cargo-zigbuild
builds:
- id: gitnow
builder: rust
binary: gitnow
dir: .
targets:
- aarch64-apple-darwin
- x86_64-unknown-linux-gnu
- aarch64-unknown-linux-gnu
flags:
- --release
- -p=gitnow
archives:
- id: gitnow
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
checksum:
name_template: "checksums.txt"
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
- "^ci:"
gitea_urls:
api: https://git.kjuulh.io/api/v1
download: https://git.kjuulh.io
skip_tls_verify: false
release:
gitea:
owner: kjuulh
name: gitnow
draft: false
prerelease: auto
name_template: "v{{ .Version }}"
mode: keep-existing
homebrew_casks:
- name: gitnow
binaries:
- gitnow
repository:
owner: kjuulh
name: homebrew-tap
token: "{{ .Env.GITEA_TOKEN }}"
url:
template: "https://git.kjuulh.io/kjuulh/gitnow/releases/download/{{ .Tag }}/{{ .ArtifactName }}"
homepage: "https://gitnow-client.prod.kjuulh.app"
description: "Git Now is a utility for easily navigating git projects from common upstream providers."

View File

@@ -6,6 +6,64 @@ 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 ## [0.3.0] - 2025-01-01
### Added ### Added

2401
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2" resolver = "2"
[workspace.package] [workspace.package]
version = "0.3.0" version = "0.3.5"
[workspace.dependencies] [workspace.dependencies]
@@ -12,4 +12,4 @@ tokio = { version = "1", features = ["full"] }
tracing = { version = "0.1", features = ["log"] } tracing = { version = "0.1", features = ["log"] }
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } 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" }

23
Dockerfile.release Normal file
View File

@@ -0,0 +1,23 @@
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
git \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# Install mise to a location already on PATH
ENV MISE_INSTALL_PATH="/usr/local/bin/mise"
RUN curl https://mise.run | sh
WORKDIR /build
COPY mise.toml .
RUN mise trust && mise install
COPY . .
ENV MISE_YES=1
ENV MISE_TRUSTED_CONFIG_PATHS="/build"
ENTRYPOINT ["mise", "run"]
CMD ["release"]

142
README.md
View File

@@ -1,5 +1,7 @@
# 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.
![example gif](./assets/gifs/example.gif) ![example gif](./assets/gifs/example.gif)
@@ -36,3 +38,143 @@ 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`.
### Projects
gitnow supports scratch-pad projects that group multiple repositories into a single directory. This is useful when working on features that span several repos.
```bash
# Create a new project (interactive repo selection)
gitnow project create my-feature
# Create from a template
gitnow project create my-feature -t default
# Open an existing project (interactive selection)
gitnow project
# Open by name
gitnow project my-feature
# Add more repos to a project
gitnow project add my-feature
# Delete a project
gitnow project delete my-feature
```
Project directories live at `~/.gitnow/projects/` by default. Templates live at `~/.gitnow/templates/`. Both are configurable:
```toml
[settings.project]
directory = "~/.gitnow/projects"
templates_directory = "~/.gitnow/templates"
```
Commands that navigate to a directory (`gitnow`, `gitnow project`, `gitnow project create`, `gitnow worktree`) will `cd` you there when using the shell integration. Commands that don't produce a path (`project add`, `project delete`, `update`) run normally without changing your directory.
### Shell integration
The recommended way to use gitnow is with shell integration, which uses a **chooser file** to communicate the selected path back to your shell:
```bash
eval $(gitnow init zsh)
git-now # or gn
```
When you run `git-now`, the shell wrapper:
1. Creates a temporary chooser file
2. Runs `gitnow` with the `GITNOW_CHOOSER_FILE` env var pointing to it
3. If gitnow writes a path to the file, the wrapper `cd`s there
4. If the file is empty (e.g. after `git-now project delete`), no `cd` happens
This works uniformly for all subcommands:
```bash
git-now # pick a repo and cd there
git-now project # pick a project and cd there
git-now project create foo # create project and cd there
git-now project delete foo # deletes project, no cd
git-now worktree # pick repo+branch worktree, cd there
```
You can also set the chooser file manually for scripting:
```bash
GITNOW_CHOOSER_FILE=/tmp/choice gitnow project
# or
gitnow --chooser-file /tmp/choice project
```

View File

@@ -1,7 +1,7 @@
[package] [package]
name = "gitnow" name = "gitnow"
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." 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 = "2021" edition = "2024"
readme = "../../README.md" readme = "../../README.md"
repository = "https://github.com/kjuulh/gitnow" repository = "https://github.com/kjuulh/gitnow"
homepage = "https://gitnow-client.prod.kjuulh.app" homepage = "https://gitnow-client.prod.kjuulh.app"
@@ -17,17 +17,16 @@ 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-client = { version = "1.22.1" } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-webpki-roots", "json"] }
url = "2.5.2" url = "2.5.2"
octocrab = "0.42.0" dirs = "6.0.0"
dirs = "5.0.1"
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"
@@ -35,7 +34,12 @@ nucleo-matcher = "0.3.1"
ratatui = { version = "0.29.0", features = ["termwiz"] } ratatui = { version = "0.29.0", features = ["termwiz"] }
crossterm = { version = "0.28.0", features = ["event-stream"] } crossterm = { version = "0.28.0", features = ["event-stream"] }
futures = "0.3.30" futures = "0.3.30"
termwiz = "0.22.0" termwiz = "0.23.0"
regex = "1.11.1"
minijinja = "2"
shell-words = "1"
chrono = { version = "0.4", default-features = false, features = ["std", "now", "serde"] }
serde_json = "1"
[dev-dependencies] [dev-dependencies]
pretty_assertions = "1.4.0" pretty_assertions = "1.4.0"

View File

@@ -4,19 +4,30 @@ function git-now {
nohup gitnow update > /dev/null 2>&1 & nohup gitnow update > /dev/null 2>&1 &
) )
# Find the repository of choice # Create a temporary chooser file
choice=$(gitnow "$@" --no-shell) local chooser_file
if [[ $? -ne 0 ]]; then chooser_file="$(mktemp)"
return $?
# Run gitnow with the chooser file
GITNOW_CHOOSER_FILE="$chooser_file" gitnow "$@"
local exit_code=$?
if [[ $exit_code -ne 0 ]]; then
rm -f "$chooser_file"
return $exit_code
fi fi
# Enter local repository path # If the chooser file has content, cd to the chosen path
cd "$(echo "$choice" | tail --lines 1)" if [[ -s "$chooser_file" ]]; then
local target
target="$(cat "$chooser_file")"
rm -f "$chooser_file"
cd "$target"
else
rm -f "$chooser_file"
fi
} }
function gn { function gn {
git-now "$@" git-now "$@"
if [[ $? -ne 0 ]]; then
return $?
fi
} }

View File

@@ -105,6 +105,22 @@ impl Cache {
} }
} }
/// Load repositories using the cache if available, otherwise fetch and update cache.
pub async fn load_repositories(app: &'static App, use_cache: bool) -> anyhow::Result<Vec<Repository>> {
use crate::projects_list::ProjectsListApp;
if use_cache {
if let Some(repos) = app.cache().get().await? {
return Ok(repos);
}
}
tracing::info!("fetching repositories...");
let repositories = app.projects_list().get_projects().await?;
app.cache().update(&repositories).await?;
Ok(repositories)
}
pub trait CacheApp { pub trait CacheApp {
fn cache(&self) -> Cache; fn cache(&self) -> Cache;
} }

View File

@@ -0,0 +1,34 @@
use std::path::{Path, PathBuf};
/// Manages an optional chooser file that the shell wrapper reads after gitnow
/// exits. When active, the selected directory path is written to the file
/// instead of being printed to stdout.
#[derive(Debug, Default)]
pub struct Chooser {
path: Option<PathBuf>,
}
impl Chooser {
pub fn new(path: PathBuf) -> Self {
Self { path: Some(path) }
}
/// Returns `true` when a chooser file has been configured.
pub fn is_active(&self) -> bool {
self.path.is_some()
}
/// Write `dir` to the chooser file. If no chooser file is configured the
/// path is printed to stdout (preserving the old `--no-shell` behaviour).
pub fn set(&self, dir: &Path) -> anyhow::Result<()> {
match &self.path {
Some(chooser_path) => {
std::fs::write(chooser_path, dir.display().to_string())?;
}
None => {
println!("{}", dir.display());
}
}
Ok(())
}
}

View File

@@ -1,3 +1,85 @@
pub mod project;
pub mod root; pub mod root;
pub mod shell; pub mod shell;
pub mod update; 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

@@ -0,0 +1,515 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::{
app::App,
cache::load_repositories,
chooser::Chooser,
custom_command::CustomCommandApp,
interactive::{InteractiveApp, Searchable},
project_metadata::{ProjectMetadata, RepoEntry},
shell::ShellApp,
template_command,
};
#[derive(clap::Parser)]
pub struct ProjectCommand {
#[command(subcommand)]
command: Option<ProjectSubcommand>,
/// Search string to filter existing projects
#[arg()]
search: Option<String>,
/// Skip spawning a shell in the project directory
#[arg(long = "no-shell", default_value = "false")]
no_shell: bool,
}
#[derive(clap::Subcommand)]
enum ProjectSubcommand {
/// Create a new project with selected repositories
Create(ProjectCreateCommand),
/// Add repositories to an existing project
Add(ProjectAddCommand),
/// Delete an existing project
Delete(ProjectDeleteCommand),
}
#[derive(clap::Parser)]
pub struct ProjectCreateCommand {
/// Project name (will be used as directory name)
#[arg()]
name: Option<String>,
/// Bootstrap from a template in the templates directory
#[arg(long = "template", short = 't')]
template: Option<String>,
/// Skip cache when fetching repositories
#[arg(long = "no-cache", default_value = "false")]
no_cache: bool,
/// Skip spawning a shell in the project directory
#[arg(long = "no-shell", default_value = "false")]
no_shell: bool,
}
#[derive(clap::Parser)]
pub struct ProjectAddCommand {
/// Project name to add repositories to
#[arg()]
name: Option<String>,
/// Skip cache when fetching repositories
#[arg(long = "no-cache", default_value = "false")]
no_cache: bool,
}
#[derive(clap::Parser)]
pub struct ProjectDeleteCommand {
/// Project name to delete
#[arg()]
name: Option<String>,
/// Skip confirmation prompt
#[arg(long = "force", short = 'f', default_value = "false")]
force: bool,
}
// --- Shared helpers ---
/// A named directory entry usable in interactive search.
#[derive(Clone)]
struct DirEntry {
name: String,
path: PathBuf,
metadata: Option<ProjectMetadata>,
}
impl Searchable for DirEntry {
fn display_label(&self) -> String {
match &self.metadata {
Some(meta) => format!("{} ({})", self.name, meta.created_ago()),
None => self.name.clone(),
}
}
}
/// Resolve a config directory path, expanding `~` to the home directory.
/// Falls back to `default` if the config value is `None`.
fn resolve_dir(configured: Option<&str>, default: &str) -> PathBuf {
if let Some(dir) = configured {
let path = PathBuf::from(dir);
if let Ok(stripped) = path.strip_prefix("~") {
return dirs::home_dir().unwrap_or_default().join(stripped);
}
return path;
}
dirs::home_dir().unwrap_or_default().join(default)
}
fn get_projects_dir(app: &'static App) -> PathBuf {
let configured = app
.config
.settings
.project
.as_ref()
.and_then(|p| p.directory.as_deref());
resolve_dir(configured, ".gitnow/projects")
}
fn get_templates_dir(app: &'static App) -> PathBuf {
let configured = app
.config
.settings
.project
.as_ref()
.and_then(|p| p.templates_directory.as_deref());
resolve_dir(configured, ".gitnow/templates")
}
/// List subdirectories of `dir` as `DirEntry` items.
/// Projects with metadata are sorted by creation time (most recent first),
/// followed by projects without metadata sorted alphabetically.
fn list_subdirectories(dir: &Path) -> anyhow::Result<Vec<DirEntry>> {
if !dir.exists() {
return Ok(Vec::new());
}
let mut entries = Vec::new();
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
let path = entry.path();
let metadata = ProjectMetadata::load(&path);
entries.push(DirEntry {
name: entry.file_name().to_string_lossy().to_string(),
path,
metadata,
});
}
}
entries.sort_by(|a, b| {
match (&a.metadata, &b.metadata) {
// Both have metadata: most recent first
(Some(a_meta), Some(b_meta)) => b_meta.created_at.cmp(&a_meta.created_at),
// Metadata projects come before non-metadata ones
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
// Both without metadata: alphabetical
(None, None) => a.name.cmp(&b.name),
}
});
Ok(entries)
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> anyhow::Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let dest_path = dst.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_dir_recursive(&entry.path(), &dest_path)?;
} else {
std::fs::copy(entry.path(), &dest_path)?;
}
}
Ok(())
}
/// Clone selected repositories concurrently into `target_dir`.
async fn clone_repos_into(
app: &'static App,
repos: &[crate::git_provider::Repository],
target_dir: &Path,
) -> anyhow::Result<()> {
let clone_template = app
.config
.settings
.clone_command
.as_deref()
.unwrap_or(template_command::DEFAULT_CLONE_COMMAND);
let concurrency_limit = Arc::new(tokio::sync::Semaphore::new(5));
let mut handles = Vec::new();
for repo in repos {
let repo = repo.clone();
let target_dir = target_dir.to_path_buf();
let clone_template = clone_template.to_string();
let concurrency = Arc::clone(&concurrency_limit);
let custom_command = app.custom_command();
let handle = tokio::spawn(async move {
let _permit = concurrency.acquire().await?;
let clone_path = target_dir.join(&repo.repo_name);
if clone_path.exists() {
eprintln!(" {} already exists, skipping", repo.repo_name);
return Ok::<(), anyhow::Error>(());
}
eprintln!(" cloning {}...", repo.to_rel_path().display());
let path_str = clone_path.display().to_string();
let context = HashMap::from([
("ssh_url", repo.ssh_url.as_str()),
("path", path_str.as_str()),
]);
let output = template_command::render_and_execute(&clone_template, context).await?;
if !output.status.success() {
let stderr = std::str::from_utf8(&output.stderr).unwrap_or_default();
anyhow::bail!("failed to clone {}: {}", repo.repo_name, stderr);
}
custom_command
.execute_post_clone_command(&clone_path)
.await?;
Ok(())
});
handles.push(handle);
}
let results = futures::future::join_all(handles).await;
for res in results {
match res {
Ok(Ok(())) => {}
Ok(Err(e)) => {
tracing::error!("clone error: {}", e);
eprintln!("error: {}", e);
}
Err(e) => {
tracing::error!("task error: {}", e);
eprintln!("error: {}", e);
}
}
}
Ok(())
}
/// Helper to select an existing project, either by name or interactively.
fn select_project(
app: &'static App,
name: Option<String>,
projects: &[DirEntry],
) -> anyhow::Result<DirEntry> {
match name {
Some(name) => projects
.iter()
.find(|p| p.name == name)
.ok_or_else(|| anyhow::anyhow!("project '{}' not found", name))
.cloned(),
None => app
.interactive()
.interactive_search_items(projects)?
.ok_or_else(|| anyhow::anyhow!("no project selected")),
}
}
// --- Command implementations ---
impl ProjectCommand {
pub async fn execute(&mut self, app: &'static App, chooser: &Chooser) -> anyhow::Result<()> {
match self.command.take() {
Some(ProjectSubcommand::Create(mut create)) => create.execute(app, chooser).await,
Some(ProjectSubcommand::Add(mut add)) => add.execute(app).await,
Some(ProjectSubcommand::Delete(mut delete)) => delete.execute(app).await,
None => self.open_existing(app, chooser).await,
}
}
async fn open_existing(&self, app: &'static App, chooser: &Chooser) -> anyhow::Result<()> {
let projects_dir = get_projects_dir(app);
let projects = list_subdirectories(&projects_dir)?;
if projects.is_empty() {
anyhow::bail!(
"no projects found in {}. Use 'gitnow project create' to create one.",
projects_dir.display()
);
}
let project = match &self.search {
Some(needle) => {
let matched = projects
.iter()
.find(|p| p.name.contains(needle.as_str()))
.or_else(|| {
projects.iter().find(|p| {
p.name
.to_lowercase()
.contains(&needle.to_lowercase())
})
})
.ok_or(anyhow::anyhow!(
"no project matching '{}' found",
needle
))?
.clone();
matched
}
None => app
.interactive()
.interactive_search_items(&projects)?
.ok_or(anyhow::anyhow!("no project selected"))?,
};
if !self.no_shell && !chooser.is_active() {
app.shell().spawn_shell_at(&project.path).await?;
} else {
chooser.set(&project.path)?;
}
Ok(())
}
}
impl ProjectCreateCommand {
async fn execute(&mut self, app: &'static App, chooser: &Chooser) -> anyhow::Result<()> {
let name = match self.name.take() {
Some(n) => n,
None => {
eprint!("Project name: ");
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let trimmed = input.trim().to_string();
if trimmed.is_empty() {
anyhow::bail!("project name cannot be empty");
}
trimmed
}
};
let dir_name = name
.replace(' ', "-")
.replace('/', "-")
.to_lowercase();
let projects_dir = get_projects_dir(app);
let project_path = projects_dir.join(&dir_name);
if project_path.exists() {
anyhow::bail!(
"project '{}' already exists at {}",
dir_name,
project_path.display()
);
}
let repositories = load_repositories(app, !self.no_cache).await?;
eprintln!("Select repositories (Tab to toggle, Enter to confirm):");
let selected_repos = app
.interactive()
.interactive_multi_search(&repositories)?;
if selected_repos.is_empty() {
anyhow::bail!("no repositories selected");
}
tokio::fs::create_dir_all(&project_path).await?;
clone_repos_into(app, &selected_repos, &project_path).await?;
// Apply template if requested
let templates_dir = get_templates_dir(app);
let template = match self.template.take() {
Some(name) => {
let templates = list_subdirectories(&templates_dir)?;
Some(
templates
.into_iter()
.find(|t| t.name == name)
.ok_or_else(|| {
anyhow::anyhow!(
"template '{}' not found in {}",
name,
templates_dir.display()
)
})?,
)
}
None => {
let templates = list_subdirectories(&templates_dir)?;
if !templates.is_empty() {
eprintln!("Select a project template (Esc to skip):");
app.interactive().interactive_search_items(&templates)?
} else {
None
}
}
};
let template_name = if let Some(template) = template {
eprintln!(" applying template '{}'...", template.name);
copy_dir_recursive(&template.path, &project_path)?;
Some(template.name.clone())
} else {
None
};
let repo_entries: Vec<RepoEntry> = selected_repos.iter().map(RepoEntry::from).collect();
let metadata = ProjectMetadata::new(dir_name.clone(), template_name, repo_entries);
metadata.save(&project_path)?;
eprintln!(
"project '{}' created at {} with {} repositories",
dir_name,
project_path.display(),
selected_repos.len()
);
if !self.no_shell && !chooser.is_active() {
app.shell().spawn_shell_at(&project_path).await?;
} else {
chooser.set(&project_path)?;
}
Ok(())
}
}
impl ProjectAddCommand {
async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> {
let projects_dir = get_projects_dir(app);
let projects = list_subdirectories(&projects_dir)?;
if projects.is_empty() {
anyhow::bail!(
"no projects found in {}. Use 'gitnow project create' to create one.",
projects_dir.display()
);
}
let project = select_project(app, self.name.take(), &projects)?;
let repositories = load_repositories(app, !self.no_cache).await?;
eprintln!("Select repositories to add (Tab to toggle, Enter to confirm):");
let selected_repos = app
.interactive()
.interactive_multi_search(&repositories)?;
if selected_repos.is_empty() {
anyhow::bail!("no repositories selected");
}
clone_repos_into(app, &selected_repos, &project.path).await?;
if let Some(mut metadata) = ProjectMetadata::load(&project.path) {
let new_entries: Vec<RepoEntry> = selected_repos.iter().map(RepoEntry::from).collect();
metadata.add_repositories(new_entries);
metadata.save(&project.path)?;
}
eprintln!(
"added {} repositories to project '{}'",
selected_repos.len(),
project.name
);
Ok(())
}
}
impl ProjectDeleteCommand {
async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> {
let projects_dir = get_projects_dir(app);
let projects = list_subdirectories(&projects_dir)?;
if projects.is_empty() {
anyhow::bail!("no projects found in {}", projects_dir.display());
}
let project = select_project(app, self.name.take(), &projects)?;
if !self.force {
eprint!(
"Delete project '{}' at {}? [y/N] ",
project.name,
project.path.display()
);
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if !input.trim().eq_ignore_ascii_case("y") {
eprintln!("aborted");
return Ok(());
}
}
tokio::fs::remove_dir_all(&project.path).await?;
eprintln!("deleted project '{}'", project.name);
Ok(())
}
}

View File

@@ -2,8 +2,10 @@ use std::{collections::BTreeMap, io::IsTerminal};
use crate::{ use crate::{
app::App, app::App,
cache::CacheApp, cache::{load_repositories, CacheApp},
chooser::Chooser,
components::inline_command::InlineCommand, components::inline_command::InlineCommand,
custom_command::CustomCommandApp,
fuzzy_matcher::{FuzzyMatcher, FuzzyMatcherApp}, fuzzy_matcher::{FuzzyMatcher, FuzzyMatcherApp},
git_clone::GitCloneApp, git_clone::GitCloneApp,
git_provider::Repository, git_provider::Repository,
@@ -30,32 +32,17 @@ impl RootCommand {
shell: bool, shell: bool,
force_refresh: bool, force_refresh: bool,
force_cache_update: bool, force_cache_update: bool,
chooser: &Chooser,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
tracing::debug!("executing"); tracing::debug!("executing");
let repositories = if !force_cache_update { let repositories = if force_cache_update {
if cache {
match self.app.cache().get().await? {
Some(repos) => repos,
None => {
tracing::info!("finding repositories...");
let repositories = self.app.projects_list().get_projects().await?;
self.app.cache().update(&repositories).await?;
repositories
}
}
} else {
self.app.projects_list().get_projects().await?
}
} else {
tracing::info!("forcing cache update..."); tracing::info!("forcing cache update...");
let repositories = self.app.projects_list().get_projects().await?; let repositories = self.app.projects_list().get_projects().await?;
self.app.cache().update(&repositories).await?; self.app.cache().update(&repositories).await?;
repositories repositories
} else {
load_repositories(self.app, cache).await?
}; };
let repo = match search { let repo = match search {
@@ -110,70 +97,50 @@ impl RootCommand {
} else { } else {
eprintln!("cloning repository..."); eprintln!("cloning repository...");
git_clone.clone_repo(&repo, force_refresh).await?; git_clone.clone_repo(&repo, force_refresh).await?;
self.app
.custom_command()
.execute_post_clone_command(&project_path)
.await?;
} }
} else { } else {
tracing::info!("skipping clone for repo: {}", &repo.to_rel_path().display()); tracing::info!("skipping clone for repo: {}", &repo.to_rel_path().display());
} }
} else { } else {
tracing::info!("repository already exists"); tracing::info!("repository already exists");
self.app
.custom_command()
.execute_post_update_command(&project_path)
.await?;
} }
if shell { if shell {
self.app.shell().spawn_shell(&repo).await?; self.app.shell().spawn_shell(&repo).await?;
} else { } else {
tracing::info!("skipping shell for repo: {}", &repo.to_rel_path().display()); tracing::info!("skipping shell for repo: {}", &repo.to_rel_path().display());
println!( chooser.set(&self.app.config.settings.projects.directory.join(repo.to_rel_path()))?;
"{}",
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 { pub trait RepositoryMatcher {
fn match_repositories(&self, pattern: &str, repositories: &[Repository]) -> Vec<Repository>; fn match_repositories(&self, pattern: &str, repositories: &[Repository]) -> Vec<Repository>;
} }
impl RepositoryMatcher for FuzzyMatcher { impl RepositoryMatcher for FuzzyMatcher {
fn match_repositories(&self, pattern: &str, repositories: &[Repository]) -> Vec<Repository> { fn match_repositories(&self, pattern: &str, repositories: &[Repository]) -> Vec<Repository> {
let haystack = repositories let haystack: BTreeMap<String, &Repository> = repositories
.iter() .iter()
.map(|r| (r.to_rel_path().display().to_string(), r)) .map(|r| (r.to_rel_path().display().to_string(), r))
.collect::<BTreeMap<_, _>>(); .collect();
let haystack_keys = haystack.keys().collect::<Vec<_>>(); let keys: Vec<&str> = haystack.keys().map(|s| s.as_str()).collect();
let haystack_keys = haystack_keys.as_str_vec();
let res = self.match_pattern(pattern, &haystack_keys); self.match_pattern(pattern, &keys)
let matched_repos = res
.into_iter() .into_iter()
.filter_map(|repo_key| haystack.get(repo_key).map(|r| (*r).to_owned())) .filter_map(|key| haystack.get(key).map(|r| (*r).to_owned()))
.collect::<Vec<_>>(); .collect()
matched_repos
} }
} }

View File

@@ -0,0 +1,154 @@
use std::io::IsTerminal;
use crate::{
app::App,
cache::load_repositories,
chooser::Chooser,
components::inline_command::InlineCommand,
fuzzy_matcher::FuzzyMatcherApp,
interactive::{InteractiveApp, StringItem},
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, chooser: &Chooser) -> anyhow::Result<()> {
// Step 1: Load repositories
let repositories = load_repositories(app, !self.no_cache).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 && !chooser.is_active() {
app.shell().spawn_shell_at(&worktree_path).await?;
} else {
chooser.set(&worktree_path)?;
}
Ok(())
}
}

View File

@@ -3,18 +3,16 @@ use std::time::Duration;
use crossterm::event::{EventStream, KeyCode}; use crossterm::event::{EventStream, KeyCode};
use futures::{FutureExt, StreamExt}; use futures::{FutureExt, StreamExt};
use ratatui::{ use ratatui::{
crossterm, TerminalOptions, Viewport, crossterm,
prelude::*, prelude::*,
widgets::{Block, Padding}, widgets::{Block, Padding},
TerminalOptions, Viewport,
}; };
use crate::components::BatchCommand; use crate::components::BatchCommand;
use super::{ use super::{
create_dispatch, Dispatch, IntoCommand, Msg, Receiver, create_dispatch,
spinner::{Spinner, SpinnerState}, spinner::{Spinner, SpinnerState},
Dispatch, IntoCommand, Msg, Receiver,
}; };
pub struct InlineCommand { pub struct InlineCommand {
@@ -120,7 +118,7 @@ impl InlineCommand {
return Ok(true); return Ok(true);
} }
let mut cmd = self.update_state(&msg); let mut cmd = self.update_state(msg);
loop { loop {
let msg = cmd.into_command().execute(dispatch); let msg = cmd.into_command().execute(dispatch);
@@ -128,7 +126,7 @@ impl InlineCommand {
match msg { match msg {
Some(Msg::Quit) => return Ok(true), Some(Msg::Quit) => return Ok(true),
Some(msg) => { Some(msg) => {
cmd = self.update_state(&msg); cmd = self.update_state(msg);
} }
None => break, None => break,
} }
@@ -163,7 +161,7 @@ impl InlineCommand {
None None
} }
fn update_state(&mut self, msg: &Msg) -> impl IntoCommand { fn update_state(&mut self, msg: Msg) -> impl IntoCommand {
tracing::debug!("handling message: {:?}", msg); tracing::debug!("handling message: {:?}", msg);
let mut batch = BatchCommand::default(); let mut batch = BatchCommand::default();
@@ -178,7 +176,7 @@ impl InlineCommand {
} }
} }
batch.with(self.spinner.update(msg)); batch.with(self.spinner.update(&msg));
batch.into_command() batch.into_command()
} }

View File

@@ -23,7 +23,7 @@ impl<'a> Spinner<'a> {
} }
} }
impl<'a> StatefulWidget for Spinner<'a> { impl StatefulWidget for Spinner<'_> {
type State = SpinnerState; type State = SpinnerState;
fn render( fn render(

View File

@@ -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,16 +12,79 @@ 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)] #[serde(default)]
pub projects: Projects, 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>,
/// Project scratch-pad configuration.
#[serde(default)]
pub project: Option<ProjectSettings>,
} }
#[derive(Debug, Default, Serialize, Deserialize, PartialEq)] #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct ProjectSettings {
/// Directory where projects are stored.
/// Default: "~/.gitnow/projects"
pub directory: Option<String>,
/// Directory containing project templates.
/// Each subdirectory is a template whose files are copied into new projects.
/// Default: "~/.gitnow/templates"
pub templates_directory: Option<String>,
}
#[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>,
}
/// A list of shell commands that can be specified as a single string or an array in TOML.
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
#[serde(untagged)]
pub enum CommandList {
Single(String),
Multiple(Vec<String>),
}
impl CommandList {
pub fn get_commands(&self) -> Vec<String> {
match self.clone() {
CommandList::Single(item) => vec![item],
CommandList::Multiple(items) => items,
}
}
}
/// Backwards-compatible type aliases.
pub type PostCloneCommand = CommandList;
pub type PostUpdateCommand = CommandList;
#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Clone)]
pub struct Projects { pub struct Projects {
pub directory: ProjectLocation, pub directory: ProjectLocation,
} }
@@ -57,7 +120,7 @@ impl std::ops::Deref for ProjectLocation {
} }
} }
#[derive(Debug, Default, Serialize, Deserialize, PartialEq)] #[derive(Debug, Default, Serialize, Deserialize, PartialEq, Clone)]
pub struct Cache { pub struct Cache {
#[serde(default)] #[serde(default)]
pub location: CacheLocation, pub location: CacheLocation,
@@ -89,7 +152,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),
@@ -113,7 +176,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),
), ),
@@ -131,7 +194,7 @@ impl Default for CacheDuration {
} }
} }
#[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>,
@@ -139,7 +202,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>,
@@ -155,37 +218,30 @@ pub struct GitHub {
pub organisations: Vec<GitHubOrganisation>, pub organisations: Vec<GitHubOrganisation>,
} }
#[derive(Debug, Serialize, Deserialize, PartialEq)] /// Generates a newtype wrapper around `String` with `From` impls for owned and borrowed access.
pub struct GitHubUser(String); macro_rules! string_newtype {
($name:ident) => {
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct $name(String);
impl From<GitHubUser> for String { impl From<$name> for String {
fn from(value: GitHubUser) -> Self { fn from(value: $name) -> Self {
value.0 value.0
} }
} }
impl<'a> From<&'a GitHubUser> for &'a str { impl<'a> From<&'a $name> for &'a str {
fn from(value: &'a GitHubUser) -> Self { fn from(value: &'a $name) -> Self {
value.0.as_str() value.0.as_str()
} }
} }
};
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct GitHubOrganisation(String);
impl From<GitHubOrganisation> for String {
fn from(value: GitHubOrganisation) -> Self {
value.0
}
} }
impl<'a> From<&'a GitHubOrganisation> for &'a str { string_newtype!(GitHubUser);
fn from(value: &'a GitHubOrganisation) -> Self { string_newtype!(GitHubOrganisation);
value.0.as_str()
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq)] #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct Gitea { pub struct Gitea {
pub url: String, pub url: String,
@@ -201,48 +257,22 @@ 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)] string_newtype!(GiteaUser);
pub struct GiteaUser(String); string_newtype!(GiteaOrganisation);
impl From<GiteaUser> for String {
fn from(value: GiteaUser) -> Self {
value.0
}
}
impl<'a> From<&'a GiteaUser> for &'a str {
fn from(value: &'a GiteaUser) -> Self {
value.0.as_str()
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct GiteaOrganisation(String);
impl From<GiteaOrganisation> for String {
fn from(value: GiteaOrganisation) -> Self {
value.0
}
}
impl<'a> From<&'a GiteaOrganisation> for &'a str {
fn from(value: &'a GiteaOrganisation) -> Self {
value.0.as_str()
}
}
impl Config { impl Config {
pub async fn from_file(file_path: &Path) -> anyhow::Result<Config> { pub async fn from_file(file_path: &Path) -> anyhow::Result<Config> {
@@ -361,7 +391,12 @@ mod test {
}, },
projects: Projects { projects: Projects {
directory: PathBuf::from("git").into() directory: PathBuf::from("git").into()
} },
post_update_command: None,
post_clone_command: None,
clone_command: None,
worktree: None,
project: None,
} }
}, },
config config
@@ -386,7 +421,12 @@ mod test {
}, },
settings: Settings { settings: Settings {
cache: Cache::default(), cache: Cache::default(),
projects: Projects::default() projects: Projects::default(),
post_update_command: None,
post_clone_command: None,
clone_command: None,
worktree: None,
project: None,
} }
}, },
config config
@@ -394,4 +434,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(())
}
} }

View File

@@ -0,0 +1,75 @@
use std::path::Path;
use crate::{app::App, config::CommandList};
pub struct CustomCommand {
post_clone: Option<CommandList>,
post_update: Option<CommandList>,
}
impl CustomCommand {
pub fn new(app: &App) -> Self {
Self {
post_clone: app.config.settings.post_clone_command.clone(),
post_update: app.config.settings.post_update_command.clone(),
}
}
async fn execute_commands(
commands: &CommandList,
path: &Path,
label: &str,
) -> anyhow::Result<()> {
for command in commands.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 {} command",
label
);
let output = cmd.output().await?;
let stdout = std::str::from_utf8(&output.stdout)?;
tracing::info!(
stdout = stdout,
"finished running custom {} command",
label
);
}
Ok(())
}
pub async fn execute_post_clone_command(&self, path: &Path) -> anyhow::Result<()> {
if let Some(ref cmds) = self.post_clone {
Self::execute_commands(cmds, path, "post clone").await?;
}
Ok(())
}
pub async fn execute_post_update_command(&self, path: &Path) -> anyhow::Result<()> {
if let Some(ref cmds) = self.post_update {
Self::execute_commands(cmds, path, "post update").await?;
}
Ok(())
}
}
pub trait CustomCommandApp {
fn custom_command(&self) -> CustomCommand;
}
impl CustomCommandApp for App {
fn custom_command(&self) -> CustomCommand {
CustomCommand::new(self)
}
}

View File

@@ -1,4 +1,6 @@
use crate::{app::App, git_provider::Repository}; use std::collections::HashMap;
use crate::{app::App, git_provider::Repository, template_command};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct GitClone { pub struct GitClone {
@@ -35,20 +37,27 @@ impl GitClone {
return Ok(()); return Ok(());
} }
let template = self
.app
.config
.settings
.clone_command
.as_deref()
.unwrap_or(template_command::DEFAULT_CLONE_COMMAND);
tracing::info!( tracing::info!(
"cloning: {} into {}", "cloning: {} into {}",
repository.ssh_url.as_str(), repository.ssh_url.as_str(),
&project_path.display().to_string(), &project_path.display().to_string(),
); );
let mut cmd = tokio::process::Command::new("git"); let path_str = project_path.display().to_string();
cmd.args([ let context = HashMap::from([
"clone", ("ssh_url", repository.ssh_url.as_str()),
repository.ssh_url.as_str(), ("path", path_str.as_str()),
&project_path.display().to_string(),
]); ]);
let output = cmd.output().await?; let output = template_command::render_and_execute(template, context).await?;
match output.status.success() { match output.status.success() {
true => tracing::debug!( true => tracing::debug!(
"cloned {} into {}", "cloned {} into {}",

View File

@@ -1,18 +1,34 @@
use anyhow::Context; use anyhow::Context;
use gitea_client::apis::configuration::Configuration; use serde::Deserialize;
use url::Url; use url::Url;
use crate::{app::App, config::GiteaAccessToken}; use crate::{app::App, config::GiteaAccessToken};
#[derive(Debug, Deserialize)]
struct GiteaRepo {
name: Option<String>,
ssh_url: Option<String>,
owner: Option<GiteaUser>,
}
#[derive(Debug, Deserialize)]
struct GiteaUser {
login: Option<String>,
}
#[derive(Debug)] #[derive(Debug)]
pub struct GiteaProvider { pub struct GiteaProvider {
#[allow(dead_code)] #[allow(dead_code)]
app: &'static App, app: &'static App,
client: reqwest::Client,
} }
impl GiteaProvider { impl GiteaProvider {
pub fn new(app: &'static App) -> GiteaProvider { pub fn new(app: &'static App) -> GiteaProvider {
GiteaProvider { app } GiteaProvider {
app,
client: reqwest::Client::new(),
}
} }
pub async fn list_repositories_for_current_user( pub async fn list_repositories_for_current_user(
@@ -22,39 +38,23 @@ impl GiteaProvider {
) -> anyhow::Result<Vec<super::Repository>> { ) -> anyhow::Result<Vec<super::Repository>> {
tracing::debug!("fetching gitea repositories for current user"); tracing::debug!("fetching gitea repositories for current user");
let config = self.get_config(api, access_token)?;
let mut repositories = Vec::new(); let mut repositories = Vec::new();
let mut page = 1; let mut page = 1;
loop { loop {
let mut repos = self let repos: Vec<GiteaRepo> = self
.list_repositories_for_current_user_with_page(&config, page) .request(&format!("{api}/user/repos"), access_token, page)
.await?; .await?;
if repos.is_empty() { if repos.is_empty() {
break; break;
} }
repositories.append(&mut repos); repositories.extend(repos);
page += 1; page += 1;
} }
let provider = &Self::get_domain(api)?; let provider = &Self::get_domain(api)?;
Ok(to_repositories(provider, repositories))
Ok(repositories
.into_iter()
.map(|repo| super::Repository {
provider: provider.into(),
owner: repo
.owner
.map(|user| user.login.unwrap_or_default())
.unwrap_or_default(),
repo_name: repo.name.unwrap_or_default(),
ssh_url: repo
.ssh_url
.expect("ssh url to be set for a gitea repository"),
})
.collect())
} }
fn get_domain(api: &str) -> anyhow::Result<String> { fn get_domain(api: &str) -> anyhow::Result<String> {
@@ -64,19 +64,6 @@ impl GiteaProvider {
Ok(provider.into()) Ok(provider.into())
} }
async fn list_repositories_for_current_user_with_page(
&self,
config: &Configuration,
page: usize,
) -> anyhow::Result<Vec<gitea_client::models::Repository>> {
let repos =
gitea_client::apis::user_api::user_current_list_repos(config, Some(page as i32), None)
.await
.context("failed to fetch repos for users")?;
Ok(repos)
}
pub async fn list_repositories_for_user( pub async fn list_repositories_for_user(
&self, &self,
user: &str, user: &str,
@@ -85,53 +72,27 @@ impl GiteaProvider {
) -> anyhow::Result<Vec<super::Repository>> { ) -> anyhow::Result<Vec<super::Repository>> {
tracing::debug!(user = user, "fetching gitea repositories for user"); tracing::debug!(user = user, "fetching gitea repositories for user");
let config = self.get_config(api, access_token)?;
let mut repositories = Vec::new(); let mut repositories = Vec::new();
let mut page = 1; let mut page = 1;
loop { loop {
let mut repos = self let repos: Vec<GiteaRepo> = self
.list_repositories_for_user_with_page(user, &config, page) .request(
&format!("{api}/users/{user}/repos"),
access_token,
page,
)
.await?; .await?;
if repos.is_empty() { if repos.is_empty() {
break; break;
} }
repositories.append(&mut repos); repositories.extend(repos);
page += 1; page += 1;
} }
let provider = &Self::get_domain(api)?; let provider = &Self::get_domain(api)?;
Ok(to_repositories(provider, repositories))
Ok(repositories
.into_iter()
.map(|repo| super::Repository {
provider: provider.into(),
owner: repo
.owner
.map(|user| user.login.unwrap_or_default())
.unwrap_or_default(),
repo_name: repo.name.unwrap_or_default(),
ssh_url: repo
.ssh_url
.expect("ssh url to be set for gitea repository"),
})
.collect())
}
pub async fn list_repositories_for_user_with_page(
&self,
user: &str,
config: &Configuration,
page: usize,
) -> anyhow::Result<Vec<gitea_client::models::Repository>> {
let repos =
gitea_client::apis::user_api::user_list_repos(config, user, Some(page as i32), None)
.await
.context("failed to fetch repos for users")?;
Ok(repos)
} }
pub async fn list_repositories_for_organisation( pub async fn list_repositories_for_organisation(
@@ -144,26 +105,63 @@ impl GiteaProvider {
organisation = organisation, organisation = organisation,
"fetching gitea repositories for organisation" "fetching gitea repositories for organisation"
); );
let config = self.get_config(api, access_token)?;
let mut repositories = Vec::new(); let mut repositories = Vec::new();
let mut page = 1; let mut page = 1;
loop { loop {
let mut repos = self let repos: Vec<GiteaRepo> = self
.list_repositories_for_organisation_with_page(organisation, &config, page) .request(
&format!("{api}/orgs/{organisation}/repos"),
access_token,
page,
)
.await?; .await?;
if repos.is_empty() { if repos.is_empty() {
break; break;
} }
repositories.append(&mut repos); repositories.extend(repos);
page += 1; page += 1;
} }
let provider = &Self::get_domain(api)?; let provider = &Self::get_domain(api)?;
Ok(to_repositories(provider, repositories))
}
Ok(repositories async fn request<T: serde::de::DeserializeOwned>(
&self,
url: &str,
access_token: Option<&GiteaAccessToken>,
page: usize,
) -> anyhow::Result<T> {
let mut req = self.client.get(url).query(&[("page", page.to_string())]);
match access_token {
Some(GiteaAccessToken::Env { env }) => {
let token =
std::env::var(env).context(format!("{env} didn't have a valid value"))?;
req = req.basic_auth("", Some(token));
}
Some(GiteaAccessToken::Direct(var)) => {
req = req.bearer_auth(var);
}
None => {}
}
req.send()
.await
.context("failed to send request")?
.error_for_status()
.context("request failed")?
.json()
.await
.context("failed to parse response")
}
}
fn to_repositories(provider: &str, repos: Vec<GiteaRepo>) -> Vec<super::Repository> {
repos
.into_iter() .into_iter()
.map(|repo| super::Repository { .map(|repo| super::Repository {
provider: provider.into(), provider: provider.into(),
@@ -174,51 +172,9 @@ impl GiteaProvider {
repo_name: repo.name.unwrap_or_default(), repo_name: repo.name.unwrap_or_default(),
ssh_url: repo ssh_url: repo
.ssh_url .ssh_url
.expect("ssh url to be set for gitea repository"), .expect("ssh url to be set for a gitea repository"),
}) })
.collect()) .collect()
}
pub async fn list_repositories_for_organisation_with_page(
&self,
organisation: &str,
config: &Configuration,
page: usize,
) -> anyhow::Result<Vec<gitea_client::models::Repository>> {
let repos = gitea_client::apis::organization_api::org_list_repos(
config,
organisation,
Some(page as i32),
None,
)
.await
.context("failed to fetch repos for users")?;
Ok(repos)
}
fn get_config(
&self,
api: &str,
access_token: Option<&GiteaAccessToken>,
) -> anyhow::Result<Configuration> {
let mut config = gitea_client::apis::configuration::Configuration::new();
config.base_path = api.into();
match access_token {
Some(GiteaAccessToken::Env { env }) => {
let token =
std::env::var(env).context(format!("{env} didn't have a valid value"))?;
config.basic_auth = Some(("".into(), Some(token)));
}
Some(GiteaAccessToken::Direct(var)) => {
config.bearer_access_token = Some(var.to_owned());
}
None => {}
}
Ok(config)
}
} }
pub trait GiteaProviderApp { pub trait GiteaProviderApp {

View File

@@ -1,7 +1,20 @@
use octocrab::{models::Repository, params::repos::Sort, Octocrab, Page}; use reqwest::header::{ACCEPT, AUTHORIZATION, USER_AGENT};
use serde::Deserialize;
use crate::{app::App, config::GitHubAccessToken}; use crate::{app::App, config::GitHubAccessToken};
#[derive(Deserialize)]
struct GitHubRepo {
name: String,
owner: Option<GitHubOwner>,
ssh_url: Option<String>,
}
#[derive(Deserialize)]
struct GitHubOwner {
login: String,
}
pub struct GitHubProvider { pub struct GitHubProvider {
#[allow(dead_code)] #[allow(dead_code)]
app: &'static App, app: &'static App,
@@ -19,30 +32,17 @@ impl GitHubProvider {
) -> anyhow::Result<Vec<super::Repository>> { ) -> anyhow::Result<Vec<super::Repository>> {
tracing::debug!("fetching github repositories for current user"); tracing::debug!("fetching github repositories for current user");
let client = self.get_client(url, access_token)?; let client = self.get_client(access_token)?;
let base = self.api_base(url);
let current_page = client let repos: Vec<GitHubRepo> = self
.current() .paginate(
.list_repos_for_authenticated_user() &client,
.type_("all") &format!("{base}/user/repos?type=all&sort=full_name&per_page=100"),
.per_page(100) )
.sort("full_name")
.send()
.await?; .await?;
let repos = self.unfold_pages(client, current_page).await?; Ok(self.to_repositories(url, repos))
Ok(repos
.into_iter()
.filter_map(|repo| {
Some(super::Repository {
provider: self.get_url(url),
owner: repo.owner.map(|su| su.login)?,
repo_name: repo.name,
ssh_url: repo.ssh_url?,
})
})
.collect())
} }
pub async fn list_repositories_for_user( pub async fn list_repositories_for_user(
@@ -53,30 +53,17 @@ impl GitHubProvider {
) -> anyhow::Result<Vec<super::Repository>> { ) -> anyhow::Result<Vec<super::Repository>> {
tracing::debug!(user = user, "fetching github repositories for user"); tracing::debug!(user = user, "fetching github repositories for user");
let client = self.get_client(url, access_token)?; let client = self.get_client(access_token)?;
let base = self.api_base(url);
let current_page = client let repos: Vec<GitHubRepo> = self
.users(user) .paginate(
.repos() &client,
.r#type(octocrab::params::users::repos::Type::All) &format!("{base}/users/{user}/repos?type=all&sort=full_name&per_page=100"),
.sort(Sort::FullName) )
.per_page(100)
.send()
.await?; .await?;
let repos = self.unfold_pages(client, current_page).await?; Ok(self.to_repositories(url, repos))
Ok(repos
.into_iter()
.filter_map(|repo| {
Some(super::Repository {
provider: self.get_url(url),
owner: repo.owner.map(|su| su.login)?,
repo_name: repo.name,
ssh_url: repo.ssh_url?,
})
})
.collect())
} }
pub async fn list_repositories_for_organisation( pub async fn list_repositories_for_organisation(
@@ -90,47 +77,66 @@ impl GitHubProvider {
"fetching github repositories for organisation" "fetching github repositories for organisation"
); );
let client = self.get_client(url, access_token)?; let client = self.get_client(access_token)?;
let base = self.api_base(url);
let current_page = client let repos: Vec<GitHubRepo> = self
.orgs(organisation) .paginate(
.list_repos() &client,
.repo_type(Some(octocrab::params::repos::Type::All)) &format!("{base}/orgs/{organisation}/repos?type=all&sort=full_name&per_page=100"),
.sort(Sort::FullName) )
.per_page(100)
.send()
.await?; .await?;
let repos = self.unfold_pages(client, current_page).await?; Ok(self.to_repositories(url, repos))
}
Ok(repos async fn paginate(
&self,
client: &reqwest::Client,
initial_url: &str,
) -> anyhow::Result<Vec<GitHubRepo>> {
let mut repos = Vec::new();
let mut url = Some(initial_url.to_string());
while let Some(current_url) = url {
let resp = client
.get(&current_url)
.send()
.await?
.error_for_status()?;
url = parse_next_link(resp.headers());
let page: Vec<GitHubRepo> = resp.json().await?;
repos.extend(page);
}
Ok(repos)
}
fn to_repositories(
&self,
url: Option<&String>,
repos: Vec<GitHubRepo>,
) -> Vec<super::Repository> {
repos
.into_iter() .into_iter()
.filter_map(|repo| { .filter_map(|repo| {
Some(super::Repository { Some(super::Repository {
provider: self.get_url(url), provider: self.get_url(url),
owner: repo.owner.map(|su| su.login)?, owner: repo.owner.map(|o| o.login)?,
repo_name: repo.name, repo_name: repo.name,
ssh_url: repo.ssh_url?, ssh_url: repo.ssh_url?,
}) })
}) })
.collect()) .collect()
} }
async fn unfold_pages( fn api_base(&self, url: Option<&String>) -> String {
&self, match url {
client: octocrab::Octocrab, Some(u) => format!("{u}/api/v3"),
page: Page<Repository>, None => "https://api.github.com".to_string(),
) -> anyhow::Result<Vec<Repository>> {
let mut current_page = page;
let mut repos = current_page.take_items();
while let Ok(Some(mut new_page)) = client.get_page(&current_page.next).await {
repos.extend(new_page.take_items());
current_page = new_page;
} }
Ok(repos)
} }
fn get_url(&self, url: Option<&String>) -> String { fn get_url(&self, url: Option<&String>) -> String {
@@ -151,15 +157,19 @@ impl GitHubProvider {
} }
} }
fn get_client( fn get_client(&self, access_token: &GitHubAccessToken) -> anyhow::Result<reqwest::Client> {
&self, let token = match access_token {
_url: Option<&String>,
access_token: &GitHubAccessToken,
) -> anyhow::Result<Octocrab> {
let client = octocrab::Octocrab::builder()
.personal_token(match access_token {
GitHubAccessToken::Direct(token) => token.to_owned(), GitHubAccessToken::Direct(token) => token.to_owned(),
GitHubAccessToken::Env { env } => std::env::var(env)?, GitHubAccessToken::Env { env } => std::env::var(env)?,
};
let client = reqwest::Client::builder()
.default_headers({
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(AUTHORIZATION, format!("token {token}").parse()?);
headers.insert(ACCEPT, "application/vnd.github+json".parse()?);
headers.insert(USER_AGENT, "gitnow".parse()?);
headers
}) })
.build()?; .build()?;
@@ -167,6 +177,18 @@ impl GitHubProvider {
} }
} }
fn parse_next_link(headers: &reqwest::header::HeaderMap) -> Option<String> {
let link = headers.get("link")?.to_str().ok()?;
for part in link.split(',') {
let part = part.trim();
if part.ends_with("rel=\"next\"") {
let url = part.split('>').next()?.trim_start_matches('<');
return Some(url.to_string());
}
}
None
}
pub trait GitHubProviderApp { pub trait GitHubProviderApp {
fn github_provider(&self) -> GitHubProvider; fn github_provider(&self) -> GitHubProvider;
} }

View File

@@ -3,6 +3,25 @@ use ratatui::{prelude::*, Terminal};
use crate::git_provider::Repository; 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 { pub struct Interactive {
app: &'static crate::app::App, app: &'static crate::app::App,
} }
@@ -16,12 +35,27 @@ impl Interactive {
&mut self, &mut self,
repositories: &[Repository], repositories: &[Repository],
) -> anyhow::Result<Option<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 backend = TermwizBackend::new().map_err(|e| anyhow::anyhow!(e.to_string()))?;
let terminal = Terminal::new(backend)?; let terminal = Terminal::new(backend)?;
let app_result = App::new(self.app, repositories).run(terminal); App::new(self.app, items).run(terminal)
}
app_result pub fn interactive_multi_search<T: Searchable>(
&mut self,
items: &[T],
) -> anyhow::Result<Vec<T>> {
let backend = TermwizBackend::new().map_err(|e| anyhow::anyhow!(e.to_string()))?;
let terminal = Terminal::new(backend)?;
multi_select::MultiSelectApp::new(self.app, items).run(terminal)
} }
} }
@@ -36,6 +70,7 @@ impl InteractiveApp for &'static crate::app::App {
} }
mod app { mod app {
use crossterm::event::KeyModifiers;
use ratatui::{ use ratatui::{
crossterm::event::{self, Event, KeyCode}, crossterm::event::{self, Event, KeyCode},
layout::{Constraint, Layout}, layout::{Constraint, Layout},
@@ -46,36 +81,47 @@ mod app {
Frame, Terminal, Frame, Terminal,
}; };
use crate::{ use crate::fuzzy_matcher::FuzzyMatcherApp;
commands::root::RepositoryMatcher, fuzzy_matcher::FuzzyMatcherApp, git_provider::Repository,
};
pub struct App<'a> { use super::Searchable;
pub struct App<'a, T: Searchable> {
app: &'static crate::app::App, app: &'static crate::app::App,
repositories: &'a [Repository], items: &'a [T],
current_search: String, current_search: String,
matched_repos: Vec<Repository>, matched_items: Vec<T>,
list: ListState, list: ListState,
} }
impl<'a> App<'a> { impl<'a, T: Searchable> App<'a, T> {
pub fn new(app: &'static crate::app::App, repositories: &'a [Repository]) -> Self { pub fn new(app: &'static crate::app::App, items: &'a [T]) -> Self {
Self { Self {
app, app,
repositories, items,
current_search: String::default(), current_search: String::default(),
matched_repos: Vec::default(), matched_items: Vec::default(),
list: ListState::default(), list: ListState::default(),
} }
} }
fn update_matched_repos(&mut self) { fn update_matched_items(&mut self) {
let res = 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 .app
.fuzzy_matcher() .fuzzy_matcher()
.match_repositories(&self.current_search, self.repositories); .match_pattern(&self.current_search, &label_refs);
self.matched_repos = res; 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() { if self.list.selected().is_none() {
self.list.select_first(); self.list.select_first();
@@ -85,33 +131,41 @@ mod app {
pub fn run( pub fn run(
mut self, mut self,
mut terminal: Terminal<TermwizBackend>, mut terminal: Terminal<TermwizBackend>,
) -> anyhow::Result<Option<Repository>> { ) -> anyhow::Result<Option<T>> {
self.update_matched_repos(); self.update_matched_items();
loop { loop {
terminal.draw(|frame| self.draw(frame))?; terminal.draw(|frame| self.draw(frame))?;
if let Event::Key(key) = event::read()? { 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 { match key.code {
KeyCode::Char(letter) => { KeyCode::Char(letter) => {
self.current_search.push(letter); self.current_search.push(letter);
self.update_matched_repos(); self.update_matched_items();
} }
KeyCode::Backspace => { KeyCode::Backspace => {
if !self.current_search.is_empty() { if !self.current_search.is_empty() {
let _ = self.current_search.remove(self.current_search.len() - 1); let _ =
self.update_matched_repos(); self.current_search.remove(self.current_search.len() - 1);
self.update_matched_items();
} }
} }
KeyCode::Esc => { KeyCode::Esc => {
return Ok(None); return Ok(None);
} }
KeyCode::Enter => { KeyCode::Enter => {
if let Some(selected) = self.list.selected() { if let Some(selected) = self.list.selected()
if let Some(repo) = self.matched_repos.get(selected).cloned() { && let Some(item) =
self.matched_items.get(selected).cloned()
{
terminal.resize(ratatui::layout::Rect::ZERO)?; terminal.resize(ratatui::layout::Rect::ZERO)?;
return Ok(Some(repo)); return Ok(Some(item));
}
} }
return Ok(None); return Ok(None);
@@ -126,33 +180,22 @@ mod app {
fn draw(&mut self, frame: &mut Frame) { fn draw(&mut self, frame: &mut Frame) {
let vertical = Layout::vertical([Constraint::Percentage(100), Constraint::Min(1)]); let vertical = Layout::vertical([Constraint::Percentage(100), Constraint::Min(1)]);
let [repository_area, input_area] = vertical.areas(frame.area()); let [list_area, input_area] = vertical.areas(frame.area());
let repos = &self.matched_repos; let display_items: Vec<String> =
self.matched_items.iter().map(|i| i.display_label()).collect();
let repo_items = repos let list_items: Vec<ListItem> =
.iter() display_items.into_iter().map(ListItem::from).collect();
.map(|r| r.to_rel_path().display().to_string())
.collect::<Vec<_>>();
let repo_list_items = repo_items let list = ratatui::widgets::List::new(list_items)
.into_iter()
.map(ListItem::from)
.collect::<Vec<_>>();
let repo_list = ratatui::widgets::List::new(repo_list_items)
.direction(ratatui::widgets::ListDirection::BottomToTop) .direction(ratatui::widgets::ListDirection::BottomToTop)
.scroll_padding(3) .scroll_padding(3)
.highlight_symbol("> ") .highlight_symbol("> ")
.highlight_spacing(ratatui::widgets::HighlightSpacing::Always) .highlight_spacing(ratatui::widgets::HighlightSpacing::Always)
.highlight_style(Style::default().bold().white()); .highlight_style(Style::default().bold().white());
StatefulWidget::render( StatefulWidget::render(list, list_area, frame.buffer_mut(), &mut self.list);
repo_list,
repository_area,
frame.buffer_mut(),
&mut self.list,
);
let input = Paragraph::new(Line::from(vec![ let input = Paragraph::new(Line::from(vec![
Span::from("> ").blue(), Span::from("> ").blue(),
@@ -164,3 +207,180 @@ mod app {
} }
} }
} }
pub mod multi_select {
use std::collections::HashSet;
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 MultiSelectApp<'a, T: Searchable> {
app: &'static crate::app::App,
items: &'a [T],
current_search: String,
matched_items: Vec<T>,
selected_labels: HashSet<String>,
list: ListState,
}
impl<'a, T: Searchable> MultiSelectApp<'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(),
selected_labels: HashSet::new(),
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();
}
}
fn toggle_current(&mut self) {
if let Some(selected) = self.list.selected() {
if let Some(item) = self.matched_items.get(selected) {
let label = item.display_label();
if !self.selected_labels.remove(&label) {
self.selected_labels.insert(label);
}
}
}
}
pub fn run(
mut self,
mut terminal: Terminal<TermwizBackend>,
) -> anyhow::Result<Vec<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(Vec::new());
}
match key.code {
KeyCode::Tab => {
self.toggle_current();
}
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(Vec::new());
}
KeyCode::Enter => {
terminal.resize(ratatui::layout::Rect::ZERO)?;
let selected: Vec<T> = self
.items
.iter()
.filter(|i| self.selected_labels.contains(&i.display_label()))
.cloned()
.collect();
return Ok(selected);
}
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),
Constraint::Min(1),
]);
let [list_area, input_area, hint_area] = vertical.areas(frame.area());
let list_items: Vec<ListItem> = self
.matched_items
.iter()
.map(|i| {
let label = i.display_label();
let marker = if self.selected_labels.contains(&label) {
"[x] "
} else {
"[ ] "
};
ListItem::from(Line::from(vec![
Span::from(marker).green(),
Span::from(label),
]))
})
.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);
let count = self.selected_labels.len();
let hint = Paragraph::new(Line::from(vec![
Span::from(format!("{count} selected")).dim(),
Span::from(" | Tab: toggle, Enter: confirm").dim(),
]));
frame.render_widget(hint, hint_area);
}
}
}

View File

@@ -1,10 +1,11 @@
#![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, shell::Shell, update::Update}; use commands::{
clone::CloneCommand, project::ProjectCommand, root::RootCommand, shell::Shell, update::Update,
worktree::WorktreeCommand,
};
use config::Config; use config::Config;
use tracing::level_filters::LevelFilter; use tracing::level_filters::LevelFilter;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
@@ -12,15 +13,20 @@ use tracing_subscriber::EnvFilter;
mod app; mod app;
mod cache; mod cache;
mod cache_codec; mod cache_codec;
pub mod chooser;
mod commands; mod commands;
mod components; mod components;
mod config; mod config;
mod custom_command;
mod fuzzy_matcher; mod fuzzy_matcher;
mod git_clone; mod git_clone;
mod git_provider; mod git_provider;
mod interactive; mod interactive;
mod project_metadata;
mod projects_list; mod projects_list;
mod shell; 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"))]
@@ -28,6 +34,10 @@ 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()] #[arg()]
search: Option<String>, search: Option<String>,
@@ -40,6 +50,12 @@ struct Command {
#[arg(long = "no-shell", default_value = "false")] #[arg(long = "no-shell", default_value = "false")]
no_shell: bool, no_shell: bool,
/// Path to a chooser file; if set, the selected directory path is written
/// to this file instead of spawning a shell or printing to stdout.
/// Can also be set via the GITNOW_CHOOSER_FILE environment variable.
#[arg(long = "chooser-file", global = true, env = "GITNOW_CHOOSER_FILE")]
chooser_file: Option<PathBuf>,
#[arg(long = "force-refresh", default_value = "false")] #[arg(long = "force-refresh", default_value = "false")]
force_refresh: bool, force_refresh: bool,
@@ -51,13 +67,17 @@ struct Command {
enum Commands { enum Commands {
Init(Shell), Init(Shell),
Update(Update), Update(Update),
Clone(CloneCommand),
Worktree(WorktreeCommand),
/// Manage scratch-pad projects with multiple repositories
Project(ProjectCommand),
} }
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() tracing_subscriber::fmt()
.with_env_filter( .with_env_filter(
EnvFilter::builder() EnvFilter::builder()
@@ -66,19 +86,33 @@ async fn main() -> anyhow::Result<()> {
) )
.init(); .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(); // When a chooser file is provided, it implies --no-shell behaviour:
tracing::debug!("Starting cli"); // the selected path is written to the file and no interactive shell is
// spawned. The calling shell wrapper is responsible for reading the
// file and changing directory.
let chooser = cli
.chooser_file
.map(chooser::Chooser::new)
.unwrap_or_default();
let no_shell = cli.no_shell || chooser.is_active();
match cli.command { match cli.command {
Some(cmd) => match cmd { Some(cmd) => match cmd {
@@ -88,6 +122,15 @@ async fn main() -> anyhow::Result<()> {
Commands::Update(mut update) => { Commands::Update(mut update) => {
update.execute(app).await?; update.execute(app).await?;
} }
Commands::Clone(mut clone) => {
clone.execute(app).await?;
}
Commands::Worktree(mut wt) => {
wt.execute(app, &chooser).await?;
}
Commands::Project(mut project) => {
project.execute(app, &chooser).await?;
}
}, },
None => { None => {
RootCommand::new(app) RootCommand::new(app)
@@ -95,9 +138,10 @@ async fn main() -> anyhow::Result<()> {
cli.search.as_ref(), cli.search.as_ref(),
!cli.no_cache, !cli.no_cache,
!cli.no_clone, !cli.no_clone,
!cli.no_shell, !no_shell,
cli.force_refresh, cli.force_refresh,
cli.force_cache_update, cli.force_cache_update,
&chooser,
) )
.await?; .await?;
} }

View File

@@ -0,0 +1,124 @@
use std::path::Path;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::git_provider::Repository;
pub const METADATA_FILENAME: &str = ".gitnow.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectMetadata {
pub version: u32,
pub name: String,
pub created_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub template: Option<String>,
pub repositories: Vec<RepoEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RepoEntry {
pub provider: String,
pub owner: String,
pub repo_name: String,
pub ssh_url: String,
}
impl From<&Repository> for RepoEntry {
fn from(repo: &Repository) -> Self {
Self {
provider: repo.provider.clone(),
owner: repo.owner.clone(),
repo_name: repo.repo_name.clone(),
ssh_url: repo.ssh_url.clone(),
}
}
}
impl ProjectMetadata {
pub fn new(
name: String,
template: Option<String>,
repositories: Vec<RepoEntry>,
) -> Self {
Self {
version: 1,
name,
created_at: Utc::now(),
template,
repositories,
}
}
pub fn load(project_dir: &Path) -> Option<Self> {
let path = project_dir.join(METADATA_FILENAME);
let content = std::fs::read_to_string(&path).ok()?;
let metadata: Self = serde_json::from_str(&content).ok()?;
Some(metadata)
}
pub fn save(&self, project_dir: &Path) -> anyhow::Result<()> {
let path = project_dir.join(METADATA_FILENAME);
let content = serde_json::to_string_pretty(self)?;
std::fs::write(path, content)?;
Ok(())
}
pub fn add_repositories(&mut self, repos: Vec<RepoEntry>) {
for repo in repos {
if !self.repositories.iter().any(|r| r.ssh_url == repo.ssh_url) {
self.repositories.push(repo);
}
}
}
pub fn created_ago(&self) -> String {
let duration = Utc::now().signed_duration_since(self.created_at);
let days = duration.num_days();
if days > 365 {
let years = days / 365;
return if years == 1 {
"1 year ago".into()
} else {
format!("{years} years ago")
};
}
if days > 30 {
let months = days / 30;
return if months == 1 {
"1 month ago".into()
} else {
format!("{months} months ago")
};
}
if days > 0 {
return if days == 1 {
"1 day ago".into()
} else {
format!("{days} days ago")
};
}
let hours = duration.num_hours();
if hours > 0 {
return if hours == 1 {
"1 hour ago".into()
} else {
format!("{hours} hours ago")
};
}
let minutes = duration.num_minutes();
if minutes > 0 {
return if minutes == 1 {
"1 minute ago".into()
} else {
format!("{minutes} minutes ago")
};
}
"just now".into()
}
}

View File

@@ -1,3 +1,5 @@
use std::path::Path;
use anyhow::Context; use anyhow::Context;
use crate::{app::App, git_provider::Repository}; use crate::{app::App, git_provider::Repository};
@@ -20,10 +22,14 @@ impl Shell {
.directory .directory
.join(repository.to_rel_path()); .join(repository.to_rel_path());
if !project_path.exists() { self.spawn_shell_at(&project_path).await
}
pub async fn spawn_shell_at(&self, path: &Path) -> anyhow::Result<()> {
if !path.exists() {
anyhow::bail!( anyhow::bail!(
"project path: {} does not exists, it is either a file, or hasn't been cloned", "project path: {} does not exists, it is either a file, or hasn't been cloned",
project_path.display() path.display()
); );
} }
@@ -31,7 +37,7 @@ impl Shell {
.context("failed to find SHELL variable, required for spawning embedded shells")?; .context("failed to find SHELL variable, required for spawning embedded shells")?;
let mut shell_cmd = tokio::process::Command::new(shell); let mut shell_cmd = tokio::process::Command::new(shell);
shell_cmd.current_dir(project_path); shell_cmd.current_dir(path);
let mut process = shell_cmd.spawn().context("failed to spawn child session")?; let mut process = shell_cmd.spawn().context("failed to spawn child session")?;

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

@@ -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]] [[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"]

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

27
mise.toml Normal file
View File

@@ -0,0 +1,27 @@
[tools]
rust = "1.93"
goreleaser = "latest"
zig = "latest"
"cargo:cargo-zigbuild" = "latest"
[tasks.install]
run = "cargo install --path ./crates/gitnow"
[tasks.test]
run = "cargo test --workspace"
description = "Run all workspace tests"
[tasks.release]
run = "goreleaser release --clean"
description = "Create a release using GoReleaser (requires GITEA_TOKEN and a git tag)"
[tasks.release-snapshot]
run = "goreleaser release --snapshot --clean"
description = "Build a local snapshot release without publishing"
[tasks.release-docker]
run = """
docker build -f Dockerfile.release -t gitnow-release .
docker run --rm gitnow-release release-snapshot
"""
description = "Test the release Docker image locally (snapshot mode)"

17
templates/default/SPEC.md Normal file
View File

@@ -0,0 +1,17 @@
# Project Spec
## Overview
<!-- Describe the purpose and goals of this project -->
## Repositories
<!-- List the repositories included and their roles -->
## Architecture
<!-- Describe how the repositories relate to each other -->
## Getting Started
<!-- Steps to get up and running after cloning -->