nhq 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nhq-0.1.0/.gitignore +13 -0
- nhq-0.1.0/LICENSE +21 -0
- nhq-0.1.0/PKG-INFO +174 -0
- nhq-0.1.0/README.md +146 -0
- nhq-0.1.0/nhq/__init__.py +3 -0
- nhq-0.1.0/nhq/__main__.py +3 -0
- nhq-0.1.0/nhq/_cli.py +386 -0
- nhq-0.1.0/nhq/_git.py +67 -0
- nhq-0.1.0/nhq/_store.py +72 -0
- nhq-0.1.0/pyproject.toml +74 -0
nhq-0.1.0/.gitignore
ADDED
nhq-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kentaro Wada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
nhq-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nhq
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Private per-repo notes alongside a git repo, kept out of git and backed up via storage you already sync.
|
|
5
|
+
Project-URL: Homepage, https://github.com/wkentaro/nhq
|
|
6
|
+
Project-URL: Issues, https://github.com/wkentaro/nhq/issues
|
|
7
|
+
Project-URL: Repository, https://github.com/wkentaro/nhq
|
|
8
|
+
Author: Kentaro Wada
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: cli,ghq,git,notes,private
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: POSIX
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Utilities
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: click>=8
|
|
25
|
+
Requires-Dist: rich>=13
|
|
26
|
+
Requires-Dist: typing-extensions>=4
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# nhq
|
|
30
|
+
|
|
31
|
+
[](https://pypi.org/project/nhq/)
|
|
32
|
+
[](https://pypi.org/project/nhq/)
|
|
33
|
+
[](https://github.com/wkentaro/nhq/actions/workflows/test.yml)
|
|
34
|
+
[](https://pypi.org/project/nhq/)
|
|
35
|
+
|
|
36
|
+
ghq for your private per-repo notes: every repo gets a private folder that lives
|
|
37
|
+
in storage you already sync, never in git. A capture tool, not a
|
|
38
|
+
config-distribution tool.
|
|
39
|
+
|
|
40
|
+
`nhq` ("notes headquarters") manages a deterministic symlink from inside a git
|
|
41
|
+
repo to a notes directory kept outside git. While working in a repo (often with
|
|
42
|
+
an AI agent) you accumulate notes, scratch, and artifacts you want beside the
|
|
43
|
+
code but never committed, not for the team and not on GitHub. `nhq` keeps them
|
|
44
|
+
in a store whose path is derived from the repo's identity, the same way
|
|
45
|
+
[ghq](https://github.com/x-motemen/ghq) derives a checkout path from a remote
|
|
46
|
+
URL.
|
|
47
|
+
|
|
48
|
+
## How it works
|
|
49
|
+
|
|
50
|
+
Two planes:
|
|
51
|
+
|
|
52
|
+
- **The store** is `$NHQ_ROOT/<host>/<user>/<repo>/`: the actual notes. Created
|
|
53
|
+
once, lives in your synced folder, shared across machines.
|
|
54
|
+
- **The link** is the `./nhq` symlink plus a `.git/info/exclude` entry.
|
|
55
|
+
Per-checkout and per-machine, never committed.
|
|
56
|
+
|
|
57
|
+
`nhq init` sets up both on the first machine; `nhq link` connects the link plane
|
|
58
|
+
on every other machine.
|
|
59
|
+
|
|
60
|
+
`nhq` does not sync. Point the root at a folder something already syncs (Dropbox,
|
|
61
|
+
iCloud, Syncthing, a NAS mount) and backup comes for free.
|
|
62
|
+
|
|
63
|
+
## Install
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install nhq
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Or with [uv](https://docs.astral.sh/uv/):
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
uv tool install nhq
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Verify it works:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
nhq --help
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Requires POSIX (macOS, Linux); it relies on symlinks.
|
|
82
|
+
|
|
83
|
+
## Usage
|
|
84
|
+
|
|
85
|
+
On the first machine, one command sets everything up. `nhq init` creates the
|
|
86
|
+
store and drops a `./nhq` symlink into your working tree, added to
|
|
87
|
+
`.git/info/exclude` so git never sees it:
|
|
88
|
+
|
|
89
|
+
```console
|
|
90
|
+
$ nhq init
|
|
91
|
+
created store /home/you/nhq/github.com/wkentaro/labelme
|
|
92
|
+
linked /home/you/code/labelme/nhq -> /home/you/nhq/github.com/wkentaro/labelme
|
|
93
|
+
|
|
94
|
+
$ ls -l nhq
|
|
95
|
+
nhq -> /home/you/nhq/github.com/wkentaro/labelme
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Now write notes into `./nhq/`. They live in your synced storage and never touch
|
|
99
|
+
git.
|
|
100
|
+
|
|
101
|
+
On any other machine or checkout the store already exists (it synced over), so
|
|
102
|
+
just link to it:
|
|
103
|
+
|
|
104
|
+
```console
|
|
105
|
+
$ nhq link
|
|
106
|
+
linked /home/you/code/labelme/nhq -> /home/you/nhq/github.com/wkentaro/labelme
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Your notes from the first machine are already under `./nhq/`, synced over.
|
|
110
|
+
|
|
111
|
+
Both commands also work from a subdirectory: a subtree gets its own separate
|
|
112
|
+
store, keyed by its path within the repo, so a monorepo subtree keeps its own
|
|
113
|
+
notes.
|
|
114
|
+
|
|
115
|
+
To undo the link on a checkout, `nhq unlink` removes the `./nhq` symlink and its
|
|
116
|
+
`.git/info/exclude` entry. It is the inverse of `link` and never touches the
|
|
117
|
+
store, so your notes stay safe in the synced root:
|
|
118
|
+
|
|
119
|
+
```console
|
|
120
|
+
$ nhq unlink
|
|
121
|
+
unlinked /home/you/code/labelme/nhq
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Root resolution
|
|
125
|
+
|
|
126
|
+
The root is resolved like ghq, in order:
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
NHQ_ROOT env -> git config nhq.root -> ~/nhq
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`nhq root` prints it, the same way `ghq root` does. It needs no repo, so it
|
|
133
|
+
works anywhere:
|
|
134
|
+
|
|
135
|
+
```console
|
|
136
|
+
$ nhq root
|
|
137
|
+
/home/you/nhq
|
|
138
|
+
|
|
139
|
+
$ cd "$(nhq root)"
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Listing stores
|
|
143
|
+
|
|
144
|
+
`nhq list` prints every store for this repo, the root plus any subtree stores,
|
|
145
|
+
the same wherever in the repo you run it. Each line shows the decoded subpath and
|
|
146
|
+
the store path; the store for the current directory is marked with `*`. It only
|
|
147
|
+
reads, creating and linking nothing:
|
|
148
|
+
|
|
149
|
+
```console
|
|
150
|
+
$ nhq list
|
|
151
|
+
* . /home/you/nhq/github.com/wkentaro/labelme
|
|
152
|
+
tests/ /home/you/nhq/github.com/wkentaro/labelme%2Ftests
|
|
153
|
+
labelme/widgets/ /home/you/nhq/github.com/wkentaro/labelme%2Flabelme%2Fwidgets
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## vs repoverlay
|
|
157
|
+
|
|
158
|
+
[repoverlay](https://github.com/tylerbutler/repoverlay) uses the same mechanism
|
|
159
|
+
(a symlink plus `.git/info/exclude`) but the opposite data model. It distributes
|
|
160
|
+
one shared bundle of files into many repos; `nhq` captures each repo's own unique
|
|
161
|
+
notes out to a synced store.
|
|
162
|
+
|
|
163
|
+
- **Capture, not distribute**: notes flow out of the repo, not config in.
|
|
164
|
+
- **Zero config**: the store path is derived from repo identity, not named.
|
|
165
|
+
- **Three verbs**: `init`, `link`, and `unlink` are all that change anything; `root` and `list` only print.
|
|
166
|
+
|
|
167
|
+
## Scope
|
|
168
|
+
|
|
169
|
+
v1 manages the link and the derived path; it never syncs, clones, or touches the
|
|
170
|
+
network. Backup is delegated entirely to whatever already syncs your root.
|
|
171
|
+
|
|
172
|
+
## License
|
|
173
|
+
|
|
174
|
+
MIT. Modeled on and crediting [ghq](https://github.com/x-motemen/ghq).
|
nhq-0.1.0/README.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# nhq
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/nhq/)
|
|
4
|
+
[](https://pypi.org/project/nhq/)
|
|
5
|
+
[](https://github.com/wkentaro/nhq/actions/workflows/test.yml)
|
|
6
|
+
[](https://pypi.org/project/nhq/)
|
|
7
|
+
|
|
8
|
+
ghq for your private per-repo notes: every repo gets a private folder that lives
|
|
9
|
+
in storage you already sync, never in git. A capture tool, not a
|
|
10
|
+
config-distribution tool.
|
|
11
|
+
|
|
12
|
+
`nhq` ("notes headquarters") manages a deterministic symlink from inside a git
|
|
13
|
+
repo to a notes directory kept outside git. While working in a repo (often with
|
|
14
|
+
an AI agent) you accumulate notes, scratch, and artifacts you want beside the
|
|
15
|
+
code but never committed, not for the team and not on GitHub. `nhq` keeps them
|
|
16
|
+
in a store whose path is derived from the repo's identity, the same way
|
|
17
|
+
[ghq](https://github.com/x-motemen/ghq) derives a checkout path from a remote
|
|
18
|
+
URL.
|
|
19
|
+
|
|
20
|
+
## How it works
|
|
21
|
+
|
|
22
|
+
Two planes:
|
|
23
|
+
|
|
24
|
+
- **The store** is `$NHQ_ROOT/<host>/<user>/<repo>/`: the actual notes. Created
|
|
25
|
+
once, lives in your synced folder, shared across machines.
|
|
26
|
+
- **The link** is the `./nhq` symlink plus a `.git/info/exclude` entry.
|
|
27
|
+
Per-checkout and per-machine, never committed.
|
|
28
|
+
|
|
29
|
+
`nhq init` sets up both on the first machine; `nhq link` connects the link plane
|
|
30
|
+
on every other machine.
|
|
31
|
+
|
|
32
|
+
`nhq` does not sync. Point the root at a folder something already syncs (Dropbox,
|
|
33
|
+
iCloud, Syncthing, a NAS mount) and backup comes for free.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install nhq
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Or with [uv](https://docs.astral.sh/uv/):
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
uv tool install nhq
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Verify it works:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
nhq --help
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Requires POSIX (macOS, Linux); it relies on symlinks.
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
On the first machine, one command sets everything up. `nhq init` creates the
|
|
58
|
+
store and drops a `./nhq` symlink into your working tree, added to
|
|
59
|
+
`.git/info/exclude` so git never sees it:
|
|
60
|
+
|
|
61
|
+
```console
|
|
62
|
+
$ nhq init
|
|
63
|
+
created store /home/you/nhq/github.com/wkentaro/labelme
|
|
64
|
+
linked /home/you/code/labelme/nhq -> /home/you/nhq/github.com/wkentaro/labelme
|
|
65
|
+
|
|
66
|
+
$ ls -l nhq
|
|
67
|
+
nhq -> /home/you/nhq/github.com/wkentaro/labelme
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Now write notes into `./nhq/`. They live in your synced storage and never touch
|
|
71
|
+
git.
|
|
72
|
+
|
|
73
|
+
On any other machine or checkout the store already exists (it synced over), so
|
|
74
|
+
just link to it:
|
|
75
|
+
|
|
76
|
+
```console
|
|
77
|
+
$ nhq link
|
|
78
|
+
linked /home/you/code/labelme/nhq -> /home/you/nhq/github.com/wkentaro/labelme
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Your notes from the first machine are already under `./nhq/`, synced over.
|
|
82
|
+
|
|
83
|
+
Both commands also work from a subdirectory: a subtree gets its own separate
|
|
84
|
+
store, keyed by its path within the repo, so a monorepo subtree keeps its own
|
|
85
|
+
notes.
|
|
86
|
+
|
|
87
|
+
To undo the link on a checkout, `nhq unlink` removes the `./nhq` symlink and its
|
|
88
|
+
`.git/info/exclude` entry. It is the inverse of `link` and never touches the
|
|
89
|
+
store, so your notes stay safe in the synced root:
|
|
90
|
+
|
|
91
|
+
```console
|
|
92
|
+
$ nhq unlink
|
|
93
|
+
unlinked /home/you/code/labelme/nhq
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Root resolution
|
|
97
|
+
|
|
98
|
+
The root is resolved like ghq, in order:
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
NHQ_ROOT env -> git config nhq.root -> ~/nhq
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`nhq root` prints it, the same way `ghq root` does. It needs no repo, so it
|
|
105
|
+
works anywhere:
|
|
106
|
+
|
|
107
|
+
```console
|
|
108
|
+
$ nhq root
|
|
109
|
+
/home/you/nhq
|
|
110
|
+
|
|
111
|
+
$ cd "$(nhq root)"
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Listing stores
|
|
115
|
+
|
|
116
|
+
`nhq list` prints every store for this repo, the root plus any subtree stores,
|
|
117
|
+
the same wherever in the repo you run it. Each line shows the decoded subpath and
|
|
118
|
+
the store path; the store for the current directory is marked with `*`. It only
|
|
119
|
+
reads, creating and linking nothing:
|
|
120
|
+
|
|
121
|
+
```console
|
|
122
|
+
$ nhq list
|
|
123
|
+
* . /home/you/nhq/github.com/wkentaro/labelme
|
|
124
|
+
tests/ /home/you/nhq/github.com/wkentaro/labelme%2Ftests
|
|
125
|
+
labelme/widgets/ /home/you/nhq/github.com/wkentaro/labelme%2Flabelme%2Fwidgets
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## vs repoverlay
|
|
129
|
+
|
|
130
|
+
[repoverlay](https://github.com/tylerbutler/repoverlay) uses the same mechanism
|
|
131
|
+
(a symlink plus `.git/info/exclude`) but the opposite data model. It distributes
|
|
132
|
+
one shared bundle of files into many repos; `nhq` captures each repo's own unique
|
|
133
|
+
notes out to a synced store.
|
|
134
|
+
|
|
135
|
+
- **Capture, not distribute**: notes flow out of the repo, not config in.
|
|
136
|
+
- **Zero config**: the store path is derived from repo identity, not named.
|
|
137
|
+
- **Three verbs**: `init`, `link`, and `unlink` are all that change anything; `root` and `list` only print.
|
|
138
|
+
|
|
139
|
+
## Scope
|
|
140
|
+
|
|
141
|
+
v1 manages the link and the derived path; it never syncs, clones, or touches the
|
|
142
|
+
network. Backup is delegated entirely to whatever already syncs your root.
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
|
|
146
|
+
MIT. Modeled on and crediting [ghq](https://github.com/x-motemen/ghq).
|
nhq-0.1.0/nhq/_cli.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Final
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
from typing_extensions import override
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from ._git import GitError
|
|
13
|
+
from ._git import ensure_excluded
|
|
14
|
+
from ._git import get_config
|
|
15
|
+
from ._git import get_origin_url
|
|
16
|
+
from ._git import get_show_prefix
|
|
17
|
+
from ._git import is_git_repo
|
|
18
|
+
from ._git import remove_excluded
|
|
19
|
+
from ._store import list_stores
|
|
20
|
+
from ._store import parse_remote_url
|
|
21
|
+
from ._store import resolve_root
|
|
22
|
+
from ._store import store_path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CliError(Exception):
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
message: str,
|
|
29
|
+
*,
|
|
30
|
+
tip: str | None = None,
|
|
31
|
+
usage: str | None = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
super().__init__(message)
|
|
34
|
+
self.tip = tip
|
|
35
|
+
self.usage = usage
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CliGroup(click.Group):
|
|
39
|
+
@override
|
|
40
|
+
def resolve_command(
|
|
41
|
+
self, ctx: click.Context, args: list[str]
|
|
42
|
+
) -> tuple[str | None, click.Command | None, list[str]]:
|
|
43
|
+
try:
|
|
44
|
+
return super().resolve_command(ctx, args)
|
|
45
|
+
except click.UsageError:
|
|
46
|
+
cmd_name = args[0] if args else ""
|
|
47
|
+
raise CliError(
|
|
48
|
+
f"unrecognized subcommand '{cmd_name}'", usage=USAGE
|
|
49
|
+
) from None
|
|
50
|
+
|
|
51
|
+
@override
|
|
52
|
+
def invoke(self, ctx: click.Context) -> None:
|
|
53
|
+
try:
|
|
54
|
+
super().invoke(ctx)
|
|
55
|
+
except CliError as exc:
|
|
56
|
+
print_error(str(exc), tip=exc.tip, usage=exc.usage)
|
|
57
|
+
ctx.exit(2 if exc.usage else 1)
|
|
58
|
+
except GitError as exc:
|
|
59
|
+
print_error(str(exc))
|
|
60
|
+
ctx.exit(1)
|
|
61
|
+
except FileNotFoundError:
|
|
62
|
+
print_error(
|
|
63
|
+
"git not found",
|
|
64
|
+
tip="install git and ensure it is on your PATH",
|
|
65
|
+
)
|
|
66
|
+
ctx.exit(1)
|
|
67
|
+
except KeyboardInterrupt:
|
|
68
|
+
ctx.exit(130)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _resolve_root() -> Path:
|
|
72
|
+
env_root = os.environ.get("NHQ_ROOT")
|
|
73
|
+
config_root = None if env_root else get_config("nhq.root")
|
|
74
|
+
return resolve_root(env_root=env_root, config_root=config_root)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _resolve_identity() -> str:
|
|
78
|
+
if not is_git_repo():
|
|
79
|
+
raise CliError("not a git repository")
|
|
80
|
+
|
|
81
|
+
origin = get_origin_url()
|
|
82
|
+
if origin is None:
|
|
83
|
+
raise CliError(
|
|
84
|
+
"nhq requires an 'origin' remote",
|
|
85
|
+
tip="add one with: git remote add origin <url>",
|
|
86
|
+
)
|
|
87
|
+
try:
|
|
88
|
+
return parse_remote_url(origin)
|
|
89
|
+
except ValueError as exc:
|
|
90
|
+
raise CliError(str(exc)) from exc
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _resolve_store() -> tuple[Path, str]:
|
|
94
|
+
identity = _resolve_identity()
|
|
95
|
+
show_prefix = get_show_prefix()
|
|
96
|
+
store = store_path(root=_resolve_root(), identity=identity, subpath=show_prefix)
|
|
97
|
+
return store, show_prefix
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _exclude_line(show_prefix: str) -> str:
|
|
101
|
+
return "/" + show_prefix + "nhq"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _link_store(store: Path, show_prefix: str) -> None:
|
|
105
|
+
link = Path("nhq")
|
|
106
|
+
try:
|
|
107
|
+
if link.is_symlink():
|
|
108
|
+
current = link.readlink()
|
|
109
|
+
if current != store.absolute():
|
|
110
|
+
raise CliError(
|
|
111
|
+
f"'./nhq' already links to {current}",
|
|
112
|
+
tip="remove ./nhq, then re-run",
|
|
113
|
+
)
|
|
114
|
+
verb = "already linked"
|
|
115
|
+
elif link.exists():
|
|
116
|
+
raise CliError(
|
|
117
|
+
"'./nhq' exists and is not an nhq symlink",
|
|
118
|
+
tip="remove ./nhq, then re-run",
|
|
119
|
+
)
|
|
120
|
+
else:
|
|
121
|
+
link.symlink_to(store.absolute())
|
|
122
|
+
verb = "linked"
|
|
123
|
+
ensure_excluded(_exclude_line(show_prefix))
|
|
124
|
+
except OSError as exc:
|
|
125
|
+
raise CliError(f"cannot link ./nhq: {exc}") from exc
|
|
126
|
+
|
|
127
|
+
print_status(verb, link.absolute(), store)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _unlink_store(store: Path, show_prefix: str) -> None:
|
|
131
|
+
link = Path("nhq")
|
|
132
|
+
if link.is_symlink():
|
|
133
|
+
current = link.readlink()
|
|
134
|
+
if current != store.absolute():
|
|
135
|
+
raise CliError(
|
|
136
|
+
f"'./nhq' links to {current}, not this repo's store",
|
|
137
|
+
tip="remove ./nhq manually if you meant to",
|
|
138
|
+
)
|
|
139
|
+
remove_link = True
|
|
140
|
+
elif link.exists():
|
|
141
|
+
raise CliError(
|
|
142
|
+
"'./nhq' exists and is not an nhq symlink",
|
|
143
|
+
tip="remove ./nhq manually if you meant to",
|
|
144
|
+
)
|
|
145
|
+
else:
|
|
146
|
+
remove_link = False
|
|
147
|
+
|
|
148
|
+
# Scrub the exclude entry before removing the symlink: if it fails, the link
|
|
149
|
+
# is still intact and the user can retry cleanly, rather than being left with
|
|
150
|
+
# a removed symlink and a lingering exclude entry.
|
|
151
|
+
exclude_line = _exclude_line(show_prefix)
|
|
152
|
+
try:
|
|
153
|
+
scrubbed = remove_excluded(exclude_line)
|
|
154
|
+
except OSError as exc:
|
|
155
|
+
raise CliError(f"cannot unlink ./nhq: {exc}") from exc
|
|
156
|
+
|
|
157
|
+
if remove_link:
|
|
158
|
+
try:
|
|
159
|
+
link.unlink()
|
|
160
|
+
except OSError as exc:
|
|
161
|
+
# The symlink survives, so restore the exclude entry we just scrubbed
|
|
162
|
+
# to keep it ignored and leave a clean state to retry from. Best-effort:
|
|
163
|
+
# a restore failure must never mask the original error raised below.
|
|
164
|
+
if scrubbed:
|
|
165
|
+
with contextlib.suppress(Exception):
|
|
166
|
+
ensure_excluded(exclude_line)
|
|
167
|
+
raise CliError(f"cannot unlink ./nhq: {exc}") from exc
|
|
168
|
+
|
|
169
|
+
verb = "unlinked" if remove_link or scrubbed else "nothing to unlink"
|
|
170
|
+
print_status(verb, link.absolute())
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@click.group(cls=CliGroup, invoke_without_command=True, add_help_option=False)
|
|
174
|
+
@click.option("-h", "--help", "show_help", is_flag=True)
|
|
175
|
+
@click.option("-V", "--version", "show_version", is_flag=True)
|
|
176
|
+
@click.pass_context
|
|
177
|
+
def cli(ctx: click.Context, show_help: bool, show_version: bool) -> None:
|
|
178
|
+
if show_version:
|
|
179
|
+
print_version(__version__)
|
|
180
|
+
return
|
|
181
|
+
if show_help or ctx.invoked_subcommand is None:
|
|
182
|
+
print_help(HELP)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@cli.command("init", add_help_option=False)
|
|
186
|
+
@click.option("-h", "--help", "show_help", is_flag=True)
|
|
187
|
+
def cmd_init(show_help: bool) -> None:
|
|
188
|
+
if show_help:
|
|
189
|
+
print_help(HELP_INIT)
|
|
190
|
+
return
|
|
191
|
+
store, show_prefix = _resolve_store()
|
|
192
|
+
existed = store.exists()
|
|
193
|
+
try:
|
|
194
|
+
store.mkdir(parents=True, exist_ok=True)
|
|
195
|
+
except OSError as exc:
|
|
196
|
+
raise CliError(f"cannot create store: {exc}") from exc
|
|
197
|
+
print_status("store exists" if existed else "created store", store)
|
|
198
|
+
_link_store(store, show_prefix)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@cli.command("link", add_help_option=False)
|
|
202
|
+
@click.option("-h", "--help", "show_help", is_flag=True)
|
|
203
|
+
def cmd_link(show_help: bool) -> None:
|
|
204
|
+
if show_help:
|
|
205
|
+
print_help(HELP_LINK)
|
|
206
|
+
return
|
|
207
|
+
store, show_prefix = _resolve_store()
|
|
208
|
+
if not store.exists():
|
|
209
|
+
raise CliError(
|
|
210
|
+
"no store for this repo",
|
|
211
|
+
tip="create it first with: nhq init",
|
|
212
|
+
)
|
|
213
|
+
_link_store(store, show_prefix)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@cli.command("unlink", add_help_option=False)
|
|
217
|
+
@click.option("-h", "--help", "show_help", is_flag=True)
|
|
218
|
+
def cmd_unlink(show_help: bool) -> None:
|
|
219
|
+
if show_help:
|
|
220
|
+
print_help(HELP_UNLINK)
|
|
221
|
+
return
|
|
222
|
+
store, show_prefix = _resolve_store()
|
|
223
|
+
_unlink_store(store, show_prefix)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@cli.command("root", add_help_option=False)
|
|
227
|
+
@click.option("-h", "--help", "show_help", is_flag=True)
|
|
228
|
+
def cmd_root(show_help: bool) -> None:
|
|
229
|
+
if show_help:
|
|
230
|
+
print_help(HELP_ROOT)
|
|
231
|
+
return
|
|
232
|
+
click.echo(str(_resolve_root()))
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@cli.command("list", add_help_option=False)
|
|
236
|
+
@click.option("-h", "--help", "show_help", is_flag=True)
|
|
237
|
+
def cmd_list(show_help: bool) -> None:
|
|
238
|
+
if show_help:
|
|
239
|
+
print_help(HELP_LIST)
|
|
240
|
+
return
|
|
241
|
+
identity = _resolve_identity()
|
|
242
|
+
root = _resolve_root()
|
|
243
|
+
current = store_path(root=root, identity=identity, subpath=get_show_prefix())
|
|
244
|
+
stores = list_stores(root=root, identity=identity)
|
|
245
|
+
if not stores:
|
|
246
|
+
return
|
|
247
|
+
rows = [("." if subpath == "" else subpath + "/", path) for subpath, path in stores]
|
|
248
|
+
width = max(len(label) for label, _ in rows)
|
|
249
|
+
for label, path in rows:
|
|
250
|
+
mark = "*" if path == current else " "
|
|
251
|
+
click.echo(f"{mark} {label:<{width}} {path}")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _out() -> Console:
|
|
255
|
+
return Console(highlight=False)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _err() -> Console:
|
|
259
|
+
return Console(stderr=True, highlight=False)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def print_help(text: str) -> None:
|
|
263
|
+
_out().print(text)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def print_version(version: str) -> None:
|
|
267
|
+
_out().print(f"nhq [dim]{version}[/dim]")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def print_status(verb: str, path: Path, target: Path | None = None) -> None:
|
|
271
|
+
line = Text()
|
|
272
|
+
line.append(f"{verb} ", style="bold green")
|
|
273
|
+
line.append(str(path), style="cyan")
|
|
274
|
+
if target is not None:
|
|
275
|
+
line.append(" -> ", style="dim")
|
|
276
|
+
line.append(str(target), style="cyan")
|
|
277
|
+
_err().print(line)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def print_error(
|
|
281
|
+
msg: str,
|
|
282
|
+
*,
|
|
283
|
+
tip: str | None = None,
|
|
284
|
+
usage: str | None = None,
|
|
285
|
+
) -> None:
|
|
286
|
+
err = _err()
|
|
287
|
+
err.print(f"[bold red]error[/bold red]: {msg}")
|
|
288
|
+
if tip:
|
|
289
|
+
err.print(f"\n [green]tip[/green]: {tip}")
|
|
290
|
+
if usage:
|
|
291
|
+
err.print(f"\n{usage}")
|
|
292
|
+
err.print("\nFor more information, try '[bold cyan]--help[/bold cyan]'.")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
USAGE: Final = (
|
|
296
|
+
"[bold green]Usage:[/bold green] [bold cyan]nhq[/bold cyan] [cyan]<COMMAND>[/cyan]"
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
HELP: Final = f"""\
|
|
300
|
+
Private per-repo notes alongside a git repo, kept out of git.
|
|
301
|
+
|
|
302
|
+
{USAGE}
|
|
303
|
+
|
|
304
|
+
[bold green]Commands:[/bold green]
|
|
305
|
+
[bold cyan]init[/bold cyan] Create this repo's store and link it (run once)
|
|
306
|
+
[bold cyan]link[/bold cyan] Link ./nhq to an existing store (per checkout)
|
|
307
|
+
[bold cyan]unlink[/bold cyan] Remove ./nhq and its exclude entry (inverse of link)
|
|
308
|
+
[bold cyan]root[/bold cyan] Print the resolved root directory
|
|
309
|
+
[bold cyan]list[/bold cyan] List this repo's stores (root and subtrees)
|
|
310
|
+
|
|
311
|
+
[bold green]Options:[/bold green]
|
|
312
|
+
[bold cyan]-h[/bold cyan], [bold cyan]--help[/bold cyan] Print help
|
|
313
|
+
[bold cyan]-V[/bold cyan], [bold cyan]--version[/bold cyan] Print version
|
|
314
|
+
|
|
315
|
+
[bold green]Examples:[/bold green]
|
|
316
|
+
[cyan]nhq init[/cyan] [dim]# Set up the store and link ./nhq (first machine)[/dim]
|
|
317
|
+
[cyan]nhq link[/cyan] [dim]# Link ./nhq on another machine or checkout[/dim]
|
|
318
|
+
[cyan]nhq unlink[/cyan] [dim]# Remove ./nhq from this checkout[/dim]
|
|
319
|
+
[cyan]nhq list[/cyan] [dim]# List this repo's stores[/dim]
|
|
320
|
+
[cyan]cd packages/foo && nhq init[/cyan] [dim]# A monorepo subtree's store[/dim]"""
|
|
321
|
+
|
|
322
|
+
ROOT_RESOLUTION: Final = """\
|
|
323
|
+
[bold green]Root resolution:[/bold green]
|
|
324
|
+
[cyan]NHQ_ROOT[/cyan] env -> [cyan]git config nhq.root[/cyan] -> [cyan]~/nhq[/cyan]"""
|
|
325
|
+
|
|
326
|
+
HELP_INIT: Final = f"""\
|
|
327
|
+
Create this repo's store under the resolved root (path derived from the origin
|
|
328
|
+
remote, ghq-style) and link ./nhq to it. Idempotent. Run this once, on the
|
|
329
|
+
machine where you first start; on other machines use nhq link.
|
|
330
|
+
|
|
331
|
+
[bold green]Usage:[/bold green] [bold cyan]nhq init[/bold cyan]
|
|
332
|
+
|
|
333
|
+
[bold green]Options:[/bold green]
|
|
334
|
+
[bold cyan]-h[/bold cyan], [bold cyan]--help[/bold cyan] Print help
|
|
335
|
+
|
|
336
|
+
{ROOT_RESOLUTION}"""
|
|
337
|
+
|
|
338
|
+
HELP_LINK: Final = f"""\
|
|
339
|
+
Link ./nhq in the current directory to this repo's store and add it to
|
|
340
|
+
.git/info/exclude so git ignores it. Per checkout and per machine; the link
|
|
341
|
+
is never committed. Requires the store to exist (run nhq init first).
|
|
342
|
+
|
|
343
|
+
[bold green]Usage:[/bold green] [bold cyan]nhq link[/bold cyan]
|
|
344
|
+
|
|
345
|
+
[bold green]Options:[/bold green]
|
|
346
|
+
[bold cyan]-h[/bold cyan], [bold cyan]--help[/bold cyan] Print help
|
|
347
|
+
|
|
348
|
+
{ROOT_RESOLUTION}"""
|
|
349
|
+
|
|
350
|
+
HELP_UNLINK: Final = f"""\
|
|
351
|
+
Remove ./nhq in the current directory and scrub its .git/info/exclude entry, the
|
|
352
|
+
inverse of nhq link. Idempotent and link-only: it never touches the store, and
|
|
353
|
+
only ever removes a ./nhq that points at this repo's store. Requires a git repo
|
|
354
|
+
with an origin remote.
|
|
355
|
+
|
|
356
|
+
[bold green]Usage:[/bold green] [bold cyan]nhq unlink[/bold cyan]
|
|
357
|
+
|
|
358
|
+
[bold green]Options:[/bold green]
|
|
359
|
+
[bold cyan]-h[/bold cyan], [bold cyan]--help[/bold cyan] Print help
|
|
360
|
+
|
|
361
|
+
{ROOT_RESOLUTION}"""
|
|
362
|
+
|
|
363
|
+
HELP_ROOT: Final = f"""\
|
|
364
|
+
Print the resolved root directory, the base under which every store lives. Use
|
|
365
|
+
it to locate or cd into your stores. Works anywhere; a git repo is not required.
|
|
366
|
+
|
|
367
|
+
[bold green]Usage:[/bold green] [bold cyan]nhq root[/bold cyan]
|
|
368
|
+
|
|
369
|
+
[bold green]Options:[/bold green]
|
|
370
|
+
[bold cyan]-h[/bold cyan], [bold cyan]--help[/bold cyan] Print help
|
|
371
|
+
|
|
372
|
+
{ROOT_RESOLUTION}"""
|
|
373
|
+
|
|
374
|
+
HELP_LIST: Final = f"""\
|
|
375
|
+
List every existing store for this repo, the root store plus any subtree stores,
|
|
376
|
+
one per line, identically wherever in the repo you run it. Each line shows the
|
|
377
|
+
decoded subpath (. for the repo root) and the store path; the store for the
|
|
378
|
+
current directory is marked with *. Read-only: creates and links nothing.
|
|
379
|
+
Requires a git repo with an origin remote.
|
|
380
|
+
|
|
381
|
+
[bold green]Usage:[/bold green] [bold cyan]nhq list[/bold cyan]
|
|
382
|
+
|
|
383
|
+
[bold green]Options:[/bold green]
|
|
384
|
+
[bold cyan]-h[/bold cyan], [bold cyan]--help[/bold cyan] Print help
|
|
385
|
+
|
|
386
|
+
{ROOT_RESOLUTION}"""
|
nhq-0.1.0/nhq/_git.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class GitError(RuntimeError):
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _run(*args: str) -> subprocess.CompletedProcess[str]:
|
|
10
|
+
return subprocess.run(["git", *args], capture_output=True, text=True)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _run_checked(*args: str) -> str:
|
|
14
|
+
result = _run(*args)
|
|
15
|
+
if result.returncode != 0:
|
|
16
|
+
detail = result.stderr.strip() or f"exited with {result.returncode}"
|
|
17
|
+
raise GitError(f"git {' '.join(args)}: {detail}")
|
|
18
|
+
return result.stdout.strip()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_git_repo() -> bool:
|
|
22
|
+
return _run("rev-parse", "--is-inside-work-tree").stdout.strip() == "true"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_origin_url() -> str | None:
|
|
26
|
+
result = _run("remote", "get-url", "origin")
|
|
27
|
+
if result.returncode != 0:
|
|
28
|
+
return None
|
|
29
|
+
return result.stdout.strip() or None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_show_prefix() -> str:
|
|
33
|
+
return _run_checked("rev-parse", "--show-prefix")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_config(key: str) -> str | None:
|
|
37
|
+
result = _run("config", key)
|
|
38
|
+
if result.returncode != 0:
|
|
39
|
+
return None
|
|
40
|
+
return result.stdout.strip() or None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_exclude_path() -> str:
|
|
44
|
+
return _run_checked("rev-parse", "--git-path", "info/exclude")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def ensure_excluded(line: str) -> None:
|
|
48
|
+
exclude = Path(get_exclude_path())
|
|
49
|
+
content = exclude.read_text() if exclude.exists() else ""
|
|
50
|
+
if line in content.splitlines():
|
|
51
|
+
return
|
|
52
|
+
prefix = "" if content == "" or content.endswith("\n") else "\n"
|
|
53
|
+
exclude.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
with exclude.open("a") as file:
|
|
55
|
+
file.write(prefix + line + "\n")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def remove_excluded(line: str) -> bool:
|
|
59
|
+
exclude = Path(get_exclude_path())
|
|
60
|
+
if not exclude.exists():
|
|
61
|
+
return False
|
|
62
|
+
lines = exclude.read_text().splitlines()
|
|
63
|
+
if line not in lines:
|
|
64
|
+
return False
|
|
65
|
+
kept = [existing for existing in lines if existing != line]
|
|
66
|
+
exclude.write_text("".join(existing + "\n" for existing in kept))
|
|
67
|
+
return True
|
nhq-0.1.0/nhq/_store.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import urllib.parse
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def parse_remote_url(url: str) -> str:
|
|
7
|
+
url = url.strip().rstrip("/")
|
|
8
|
+
|
|
9
|
+
scp = (
|
|
10
|
+
re.match(r"^(?:[^@/]+@)?(?P<host>[^/:]+):(?P<path>.+)$", url)
|
|
11
|
+
if "://" not in url
|
|
12
|
+
else None
|
|
13
|
+
)
|
|
14
|
+
if scp:
|
|
15
|
+
host, path = scp.group("host"), scp.group("path")
|
|
16
|
+
else:
|
|
17
|
+
parsed = urllib.parse.urlparse(url)
|
|
18
|
+
host, path = parsed.hostname or "", parsed.path
|
|
19
|
+
|
|
20
|
+
host = host.lower()
|
|
21
|
+
path = re.sub(r"/+", "/", urllib.parse.unquote(path).strip("/"))
|
|
22
|
+
path = path.removesuffix(".git").strip("/")
|
|
23
|
+
|
|
24
|
+
if not host or not path:
|
|
25
|
+
raise ValueError(f"cannot parse remote url: {url!r}")
|
|
26
|
+
if any(segment in (".", "..") for segment in path.split("/")):
|
|
27
|
+
raise ValueError(f"remote url has invalid path: {url!r}")
|
|
28
|
+
return f"{host}/{path}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def resolve_root(env_root: str | None, config_root: str | None) -> Path:
|
|
32
|
+
raw = env_root or config_root
|
|
33
|
+
if raw:
|
|
34
|
+
return Path(raw).expanduser().absolute()
|
|
35
|
+
return Path.home() / "nhq"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def store_path(root: Path, identity: str, subpath: str) -> Path:
|
|
39
|
+
base = root / identity
|
|
40
|
+
subpath = subpath.strip("/")
|
|
41
|
+
if not subpath:
|
|
42
|
+
return base
|
|
43
|
+
# Encode the joining "/" too, so the subtree store is a sibling of the repo
|
|
44
|
+
# store, not a child of it (ADR-0003).
|
|
45
|
+
encoded = urllib.parse.quote("/" + subpath, safe="")
|
|
46
|
+
return base.parent / (base.name + encoded)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def decode_subpath(leaf: str, name: str) -> str | None:
|
|
50
|
+
if name == leaf:
|
|
51
|
+
return ""
|
|
52
|
+
prefix = leaf + urllib.parse.quote("/", safe="")
|
|
53
|
+
if not name.startswith(prefix):
|
|
54
|
+
return None
|
|
55
|
+
return urllib.parse.unquote(name[len(leaf) :]).strip("/")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def list_stores(root: Path, identity: str) -> list[tuple[str, Path]]:
|
|
59
|
+
base = root / identity
|
|
60
|
+
parent = base.parent
|
|
61
|
+
if not parent.is_dir():
|
|
62
|
+
return []
|
|
63
|
+
stores: list[tuple[str, Path]] = []
|
|
64
|
+
for entry in parent.iterdir():
|
|
65
|
+
if not entry.is_dir():
|
|
66
|
+
continue
|
|
67
|
+
subpath = decode_subpath(leaf=base.name, name=entry.name)
|
|
68
|
+
if subpath is None:
|
|
69
|
+
continue
|
|
70
|
+
stores.append((subpath, entry))
|
|
71
|
+
stores.sort(key=lambda item: (item[0] != "", item[0]))
|
|
72
|
+
return stores
|
nhq-0.1.0/pyproject.toml
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
build-backend = "hatchling.build"
|
|
3
|
+
requires = ["hatch-vcs", "hatchling"]
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
authors = [{ name = "Kentaro Wada" }]
|
|
7
|
+
classifiers = [
|
|
8
|
+
"Development Status :: 4 - Beta",
|
|
9
|
+
"Environment :: Console",
|
|
10
|
+
"Intended Audience :: Developers",
|
|
11
|
+
"License :: OSI Approved :: MIT License",
|
|
12
|
+
"Operating System :: POSIX",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3.10",
|
|
15
|
+
"Programming Language :: Python :: 3.11",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Programming Language :: Python :: 3.13",
|
|
18
|
+
"Topic :: Utilities",
|
|
19
|
+
]
|
|
20
|
+
dependencies = ["click>=8", "rich>=13", "typing-extensions>=4"]
|
|
21
|
+
description = "Private per-repo notes alongside a git repo, kept out of git and backed up via storage you already sync."
|
|
22
|
+
dynamic = ["version"]
|
|
23
|
+
keywords = ["cli", "ghq", "git", "notes", "private"]
|
|
24
|
+
license = "MIT"
|
|
25
|
+
name = "nhq"
|
|
26
|
+
readme = "README.md"
|
|
27
|
+
requires-python = ">=3.10"
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
nhq = "nhq._cli:cli"
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/wkentaro/nhq"
|
|
34
|
+
Issues = "https://github.com/wkentaro/nhq/issues"
|
|
35
|
+
Repository = "https://github.com/wkentaro/nhq"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.version]
|
|
38
|
+
source = "vcs"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.sdist]
|
|
41
|
+
include = ["/nhq"]
|
|
42
|
+
|
|
43
|
+
[tool.ty.rules]
|
|
44
|
+
all = "error"
|
|
45
|
+
|
|
46
|
+
[tool.ruff.lint]
|
|
47
|
+
select = [
|
|
48
|
+
"ANN", # flake8-annotations
|
|
49
|
+
"E", # pycodestyle
|
|
50
|
+
"F", # pyflakes
|
|
51
|
+
"I", # isort
|
|
52
|
+
"UP", # pyupgrade
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[tool.ruff.lint.isort]
|
|
56
|
+
force-single-line = true
|
|
57
|
+
|
|
58
|
+
[tool.ruff.lint.per-file-ignores]
|
|
59
|
+
"__init__.py" = ["F401"]
|
|
60
|
+
|
|
61
|
+
[dependency-groups]
|
|
62
|
+
dev = [
|
|
63
|
+
"mdformat-frontmatter>=2.0.10",
|
|
64
|
+
"mdformat-gfm>=1.0.0",
|
|
65
|
+
"mdformat>=0.7",
|
|
66
|
+
"pytest-cov>=4",
|
|
67
|
+
"pytest-xdist>=3.8.0",
|
|
68
|
+
"pytest>=7",
|
|
69
|
+
"ruff>=0.15.9",
|
|
70
|
+
"taplo>=0.9",
|
|
71
|
+
"ty>=0.0.28",
|
|
72
|
+
"typos>=1.44",
|
|
73
|
+
"yamlfix>=1.17",
|
|
74
|
+
]
|