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 ADDED
@@ -0,0 +1,13 @@
1
+ # Build artifacts
2
+ /build/
3
+ /dist/
4
+ /*.egg-info/
5
+
6
+ # Test / lint caches
7
+ /.coverage
8
+ /.mypy_cache/
9
+ /.pytest_cache/
10
+ /.ruff_cache/
11
+
12
+ # Environment
13
+ /.venv/
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
+ [![PyPI](https://img.shields.io/pypi/v/nhq.svg)](https://pypi.org/project/nhq/)
32
+ [![Python](https://img.shields.io/pypi/pyversions/nhq.svg)](https://pypi.org/project/nhq/)
33
+ [![Build](https://github.com/wkentaro/nhq/actions/workflows/test.yml/badge.svg)](https://github.com/wkentaro/nhq/actions/workflows/test.yml)
34
+ [![License](https://img.shields.io/pypi/l/nhq.svg)](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
+ [![PyPI](https://img.shields.io/pypi/v/nhq.svg)](https://pypi.org/project/nhq/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/nhq.svg)](https://pypi.org/project/nhq/)
5
+ [![Build](https://github.com/wkentaro/nhq/actions/workflows/test.yml/badge.svg)](https://github.com/wkentaro/nhq/actions/workflows/test.yml)
6
+ [![License](https://img.shields.io/pypi/l/nhq.svg)](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).
@@ -0,0 +1,3 @@
1
+ import importlib.metadata
2
+
3
+ __version__ = importlib.metadata.version("nhq")
@@ -0,0 +1,3 @@
1
+ from nhq._cli import cli
2
+
3
+ cli()
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
@@ -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
@@ -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
+ ]