git-buckets 0.2.0__tar.gz → 0.3.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.
@@ -0,0 +1,137 @@
1
+ Metadata-Version: 2.3
2
+ Name: git-buckets
3
+ Version: 0.3.0
4
+ Summary: Git over S3: clone, fetch and push repositories backed by an S3 bucket.
5
+ Author: Full Duplex Media
6
+ Author-email: Full Duplex Media <contact@fullduplex.media>
7
+ License: Apache 2.0
8
+ Requires-Dist: boto3>=1.43.77
9
+ Requires-Dist: click>=8.4.2
10
+ Requires-Dist: keyring>=25.7.0
11
+ Requires-Dist: keyring-pass>=0.9.3
12
+ Requires-Dist: pyyaml>=6.0.3
13
+ Requires-Python: >=3.14
14
+ Description-Content-Type: text/markdown
15
+
16
+ # git-buckets
17
+
18
+ `gb` is the end-user CLI for a git-buckets deployment: git repos and Python package indexes over HTTPS, backed by S3.
19
+ Someone else runs the deployment. You log in once and then use stock git and uv.
20
+
21
+ ## Install
22
+
23
+ Prereqs:
24
+ - git (≥ 2.13, ≥ 2.40 for bundle-accelerated clones)
25
+ - [git-lfs](https://git-lfs.com/)
26
+ - Python ≥ 3.14
27
+
28
+ ```sh
29
+ uv tool install git-buckets
30
+ # or: pipx install git-buckets
31
+ ```
32
+
33
+ ## First login
34
+
35
+ ```sh
36
+ gb login git.example.com
37
+ ```
38
+
39
+ Once per deployment, never per bucket. This signs you in through the browser, stores the session, writes the
40
+ credential-helper stanza for `https://*.git.example.com` plus `transfer.bundleURI = true` into your global gitconfig,
41
+ and records the deployment.
42
+
43
+ On a box with no browser, `--no-browser` prints a URL to open elsewhere and reads the code back at a masked prompt.
44
+
45
+ ```sh
46
+ gb auth status # deployments known, the user on each, where each secret lives
47
+ gb logout git.example.com # revoke the session token, erase the stored credentials
48
+ ```
49
+
50
+ ## Daily use
51
+
52
+ Remote URLs are `https://<bucket>.<deployment-domain>/<repo>`: the first hostname label is the bucket's public label,
53
+ the path is the repo name, as deep as you like. `git clone https://my-bucket.git.example.com/tools/cli/my-project`, then
54
+ fetch, pull and push are stock git as well.
55
+
56
+ **New repos are made by pushing.** There is no create command, simply push to a new path on an existing bucket and
57
+ you've created a new repo.
58
+
59
+ ```sh
60
+ git init && git remote add origin https://my-bucket.git.example.com/tools/gui/my-new-project
61
+ git push -u origin main
62
+ ```
63
+
64
+ **Signed commits.** Buckets require them by default: every commit a push introduces is checked, so set up signing before
65
+ the first push (`gpg.format ssh` + `user.signingkey` + `commit.gpgsign` is the short route).
66
+
67
+ **LFS.** Downloads at any size and uploads up to 5 GB per file need nothing installed: objects come from the same host
68
+ under the same credential. Above 5 GB an upload needs gb's transfer agent, which also makes both directions parallel and
69
+ resumable. Once per repo:
70
+
71
+ ```sh
72
+ gb lfs install [--remote <name>]
73
+ ```
74
+
75
+ **Packages.** Each bucket is one Python index. In the consuming project's `pyproject.toml`:
76
+
77
+ ```toml
78
+ [[tool.uv.index]]
79
+ name = "my-bucket-index"
80
+ url = "https://gb@my-bucket.git.example.com/packages/pypi/"
81
+ explicit = true
82
+
83
+ [tool.uv.sources]
84
+ my-package = { index = "my-bucket-index" }
85
+
86
+ [tool.uv]
87
+ keyring-provider = "subprocess"
88
+ ```
89
+
90
+ Then just `uv sync` as normal. The `gb@` is **required**: uv only does keyring discovery when the index URL carries a
91
+ username.
92
+
93
+ Publishing needs one more line on the index, in the publishing project rather than the consuming one, naming the repo
94
+ the package belongs to:
95
+
96
+ ```toml
97
+ [[tool.uv.index]]
98
+ name = "my-bucket-index"
99
+ url = "https://gb@my-bucket.git.example.com/packages/pypi/"
100
+ publish-url = "https://gb@my-bucket.git.example.com/packages/pypi/upload/tools/cli/my-project/"
101
+ explicit = true
102
+ ```
103
+
104
+ Then `uv build && uv publish --index my-bucket-index`. Push rights are publish rights: the repo in the upload URL is the
105
+ one your token must reach with `rw`.
106
+
107
+ **CI.** A runner has no login session: it assumes the team's token role via OIDC and sets
108
+ `UV_KEYRING_PROVIDER=subprocess` and `GB_KEYRING_HOSTS="*.git.example.com"`. `GB_KEYRING_HOSTS` is CI only, never needed
109
+ on a user machine.
110
+
111
+ ## Repo lifecycle
112
+
113
+ Every subcommand takes `<bucket>/<repo>`, plus `-H/--host <domain>` when more than one deployment is registered.
114
+
115
+ ```sh
116
+ gb repo list
117
+ gb repo info my-bucket/tools/cli/my-project # head, refs, size, last push; -v lists every ref
118
+ gb repo protect my-bucket/tools/cli/my-project main # blocks delete while the ref exists
119
+ gb repo unprotect my-bucket/tools/cli/my-project main
120
+ gb repo delete my-bucket/tools/cli/my-project --yes # without --yes it only prints what would go
121
+ ```
122
+
123
+ Delete takes the repo's LFS objects, locks, bundles, packages and rendered web tree with it. The undo path is S3
124
+ versioning on the operator's side, not a `gb` command. There is no rename.
125
+
126
+ ## Where the secrets live
127
+
128
+ The refresh token (one year, fixed from sign-in) goes in your OS keyring, or in `~/.config/gb/hosts.yml` at 0600 when no
129
+ keyring works: gb says which, once. On a headless Linux box, `pass` + gpg-agent is the supported keyring. The hourly ID
130
+ and session tokens are just caches under `~/.local/state/gb/<domain>/`.
131
+
132
+ ## Troubleshooting
133
+
134
+ - git prompts for a username and password: the host isn't registered. `gb auth status`, then `gb login`.
135
+ - `the session for <domain> has expired`: the refresh token is past its year, or was revoked. Log in again.
136
+ - A push is refused but a fetch works: your grant on that prefix is read-only.
137
+ - A package index won't authenticate: `GB_KEYRING_DEBUG=1 uv sync` prints why the keyring backend declined.
@@ -0,0 +1,122 @@
1
+ # git-buckets
2
+
3
+ `gb` is the end-user CLI for a git-buckets deployment: git repos and Python package indexes over HTTPS, backed by S3.
4
+ Someone else runs the deployment. You log in once and then use stock git and uv.
5
+
6
+ ## Install
7
+
8
+ Prereqs:
9
+ - git (≥ 2.13, ≥ 2.40 for bundle-accelerated clones)
10
+ - [git-lfs](https://git-lfs.com/)
11
+ - Python ≥ 3.14
12
+
13
+ ```sh
14
+ uv tool install git-buckets
15
+ # or: pipx install git-buckets
16
+ ```
17
+
18
+ ## First login
19
+
20
+ ```sh
21
+ gb login git.example.com
22
+ ```
23
+
24
+ Once per deployment, never per bucket. This signs you in through the browser, stores the session, writes the
25
+ credential-helper stanza for `https://*.git.example.com` plus `transfer.bundleURI = true` into your global gitconfig,
26
+ and records the deployment.
27
+
28
+ On a box with no browser, `--no-browser` prints a URL to open elsewhere and reads the code back at a masked prompt.
29
+
30
+ ```sh
31
+ gb auth status # deployments known, the user on each, where each secret lives
32
+ gb logout git.example.com # revoke the session token, erase the stored credentials
33
+ ```
34
+
35
+ ## Daily use
36
+
37
+ Remote URLs are `https://<bucket>.<deployment-domain>/<repo>`: the first hostname label is the bucket's public label,
38
+ the path is the repo name, as deep as you like. `git clone https://my-bucket.git.example.com/tools/cli/my-project`, then
39
+ fetch, pull and push are stock git as well.
40
+
41
+ **New repos are made by pushing.** There is no create command, simply push to a new path on an existing bucket and
42
+ you've created a new repo.
43
+
44
+ ```sh
45
+ git init && git remote add origin https://my-bucket.git.example.com/tools/gui/my-new-project
46
+ git push -u origin main
47
+ ```
48
+
49
+ **Signed commits.** Buckets require them by default: every commit a push introduces is checked, so set up signing before
50
+ the first push (`gpg.format ssh` + `user.signingkey` + `commit.gpgsign` is the short route).
51
+
52
+ **LFS.** Downloads at any size and uploads up to 5 GB per file need nothing installed: objects come from the same host
53
+ under the same credential. Above 5 GB an upload needs gb's transfer agent, which also makes both directions parallel and
54
+ resumable. Once per repo:
55
+
56
+ ```sh
57
+ gb lfs install [--remote <name>]
58
+ ```
59
+
60
+ **Packages.** Each bucket is one Python index. In the consuming project's `pyproject.toml`:
61
+
62
+ ```toml
63
+ [[tool.uv.index]]
64
+ name = "my-bucket-index"
65
+ url = "https://gb@my-bucket.git.example.com/packages/pypi/"
66
+ explicit = true
67
+
68
+ [tool.uv.sources]
69
+ my-package = { index = "my-bucket-index" }
70
+
71
+ [tool.uv]
72
+ keyring-provider = "subprocess"
73
+ ```
74
+
75
+ Then just `uv sync` as normal. The `gb@` is **required**: uv only does keyring discovery when the index URL carries a
76
+ username.
77
+
78
+ Publishing needs one more line on the index, in the publishing project rather than the consuming one, naming the repo
79
+ the package belongs to:
80
+
81
+ ```toml
82
+ [[tool.uv.index]]
83
+ name = "my-bucket-index"
84
+ url = "https://gb@my-bucket.git.example.com/packages/pypi/"
85
+ publish-url = "https://gb@my-bucket.git.example.com/packages/pypi/upload/tools/cli/my-project/"
86
+ explicit = true
87
+ ```
88
+
89
+ Then `uv build && uv publish --index my-bucket-index`. Push rights are publish rights: the repo in the upload URL is the
90
+ one your token must reach with `rw`.
91
+
92
+ **CI.** A runner has no login session: it assumes the team's token role via OIDC and sets
93
+ `UV_KEYRING_PROVIDER=subprocess` and `GB_KEYRING_HOSTS="*.git.example.com"`. `GB_KEYRING_HOSTS` is CI only, never needed
94
+ on a user machine.
95
+
96
+ ## Repo lifecycle
97
+
98
+ Every subcommand takes `<bucket>/<repo>`, plus `-H/--host <domain>` when more than one deployment is registered.
99
+
100
+ ```sh
101
+ gb repo list
102
+ gb repo info my-bucket/tools/cli/my-project # head, refs, size, last push; -v lists every ref
103
+ gb repo protect my-bucket/tools/cli/my-project main # blocks delete while the ref exists
104
+ gb repo unprotect my-bucket/tools/cli/my-project main
105
+ gb repo delete my-bucket/tools/cli/my-project --yes # without --yes it only prints what would go
106
+ ```
107
+
108
+ Delete takes the repo's LFS objects, locks, bundles, packages and rendered web tree with it. The undo path is S3
109
+ versioning on the operator's side, not a `gb` command. There is no rename.
110
+
111
+ ## Where the secrets live
112
+
113
+ The refresh token (one year, fixed from sign-in) goes in your OS keyring, or in `~/.config/gb/hosts.yml` at 0600 when no
114
+ keyring works: gb says which, once. On a headless Linux box, `pass` + gpg-agent is the supported keyring. The hourly ID
115
+ and session tokens are just caches under `~/.local/state/gb/<domain>/`.
116
+
117
+ ## Troubleshooting
118
+
119
+ - git prompts for a username and password: the host isn't registered. `gb auth status`, then `gb login`.
120
+ - `the session for <domain> has expired`: the refresh token is past its year, or was revoked. Log in again.
121
+ - A push is refused but a fetch works: your grant on that prefix is read-only.
122
+ - A package index won't authenticate: `GB_KEYRING_DEBUG=1 uv sync` prints why the keyring backend declined.
@@ -1,15 +1,15 @@
1
1
  [project]
2
2
  name = "git-buckets"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Git over S3: clone, fetch and push repositories backed by an S3 bucket."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
7
7
  dependencies = [
8
- "boto3",
9
- "click",
10
- "fduplex-git-remote-s3",
11
- "keyring",
12
- "packaging",
8
+ "boto3>=1.43.77",
9
+ "click>=8.4.2",
10
+ "keyring>=25.7.0",
11
+ "keyring-pass>=0.9.3",
12
+ "pyyaml>=6.0.3",
13
13
  ]
14
14
 
15
15
  [[project.authors]]
@@ -24,6 +24,8 @@ git-buckets = "git_buckets.keyring.backend"
24
24
 
25
25
  [project.scripts]
26
26
  gb = "git_buckets.cli:cli"
27
+ git-credential-gb = "git_buckets.credential:main"
28
+ git-lfs-gb = "git_buckets.lfs_agent:main"
27
29
  keyring = "keyring.cli:main"
28
30
 
29
31
  [build-system]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "git-buckets"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Git over S3: clone, fetch and push repositories backed by an S3 bucket."
5
5
  authors = [
6
6
  { name = 'Full Duplex Media', email = 'contact@fullduplex.media' }
@@ -9,11 +9,11 @@ readme = 'README.md'
9
9
  license = { text = 'Apache 2.0' }
10
10
  requires-python = ">=3.14"
11
11
  dependencies = [
12
- "boto3",
13
- "click",
14
- "fduplex-git-remote-s3",
15
- "keyring",
16
- "packaging",
12
+ "boto3>=1.43.77",
13
+ "click>=8.4.2",
14
+ "keyring>=25.7.0",
15
+ "keyring-pass>=0.9.3",
16
+ "pyyaml>=6.0.3",
17
17
  ]
18
18
 
19
19
  [project.entry-points."keyring.backends"]
@@ -21,6 +21,10 @@ git-buckets = "git_buckets.keyring.backend"
21
21
 
22
22
  [project.scripts]
23
23
  gb = "git_buckets.cli:cli"
24
+ # `helper = gb` in a gitconfig makes git exec `git credential-gb`, i.e. this script.
25
+ git-credential-gb = "git_buckets.credential:main"
26
+ # lfs.customtransfer.gb.path in a repo's config points at this; `gb lfs install` writes it.
27
+ git-lfs-gb = "git_buckets.lfs_agent:main"
24
28
  keyring = "keyring.cli:main"
25
29
 
26
30
  [build-system]
@@ -0,0 +1,240 @@
1
+ import click
2
+
3
+
4
+ @click.group(context_settings=dict(help_option_names=['-h', '--help']))
5
+ @click.version_option(package_name='git-buckets')
6
+ def cli() -> None:
7
+ """git-buckets: git repos and Python package indexes over HTTPS."""
8
+
9
+
10
+ @cli.command(name='login')
11
+ @click.option('--no-browser', is_flag=True, help='Skip the loopback redirect; print a URL and read back a code.')
12
+ @click.option('-v', '--verbose', is_flag=True, help='Print the full summary, not just the confirmation line.')
13
+ @click.argument('domain')
14
+ def login_command(domain: str, no_browser: bool, verbose: bool) -> None:
15
+ """Connect to a git-buckets deployment: gb login git.example.com.
16
+
17
+ Runs the browser sign-in once, stores the session, writes the credential-helper stanza into your global
18
+ gitconfig and records the deployment. One command per deployment, never per bucket.
19
+ """
20
+ from . import login as login_module
21
+ from .termio import masked_prompt
22
+
23
+ try:
24
+ result = login_module.login(domain, no_browser=no_browser, echo=click.echo, prompt=masked_prompt)
25
+ except Exception as x:
26
+ raise click.ClickException(str(x)) from None
27
+ login_module.report(result, click.echo, verbose=verbose)
28
+
29
+
30
+ @cli.command(name='logout')
31
+ @click.argument('domain')
32
+ def logout_command(domain: str) -> None:
33
+ """Revoke the current session token and erase the stored credentials for a deployment."""
34
+ from . import login as login_module
35
+
36
+ try:
37
+ outcome = login_module.logout(domain)
38
+ except Exception as x:
39
+ raise click.ClickException(str(x)) from None
40
+ click.echo(f'Revoked the session token for {domain}.' if outcome['revoked'] else 'No session token to revoke.')
41
+ if outcome['error']:
42
+ click.echo(f'Warning: the revocation call failed ({outcome["error"]}); the local session is erased anyway.')
43
+ click.echo(f'Erased the stored refresh token and caches for {domain}.')
44
+ key = outcome['helper_key']
45
+ click.echo('The gitconfig stanza is left in place; remove it with:')
46
+ click.echo(f' git config --global --unset-all {key}')
47
+
48
+
49
+ @cli.group(name='auth')
50
+ def auth_group() -> None:
51
+ """Inspect the deployments gb knows about."""
52
+
53
+
54
+ @auth_group.command(name='status')
55
+ def auth_status_command() -> None:
56
+ """The deployments known, the user on each, and where each secret is stored."""
57
+ from . import login as login_module
58
+
59
+ rows = login_module.status()
60
+ if not rows:
61
+ click.echo('No deployments. Run: gb login <deployment-domain>')
62
+ return
63
+ for row in rows:
64
+ click.echo(row['domain'])
65
+ click.echo(f' user {row["user"] or "(unknown)"}')
66
+ click.echo(f' storage {row["storage"]}')
67
+ click.echo(f' session {row["session"]}')
68
+
69
+
70
+ @cli.group(name='lfs')
71
+ def lfs_group() -> None:
72
+ """Git LFS: register gb's transfer agent for large files."""
73
+
74
+
75
+ @lfs_group.command(name='install')
76
+ @click.option('--remote', default='origin', show_default=True, help='Remote to check the deployment against.')
77
+ def lfs_install_command(remote: str) -> None:
78
+ """Register gb's LFS transfer agent in this repo.
79
+
80
+ Nothing is required for downloads or for uploads up to 5 GB: stock git-lfs handles both. Above 5 GB
81
+ git-lfs has no multipart upload of its own, so the agent is the only way to push such a file at all --
82
+ and once it is installed both directions go through it, in parallel parts that resume rather than one
83
+ stream that restarts. Repo-local by design: the agent only knows how to talk to a git-buckets deployment.
84
+ """
85
+ from .lfs_agent import AgentError, install
86
+
87
+ try:
88
+ settings = install(remote, echo=click.echo)
89
+ except AgentError as x:
90
+ raise click.ClickException(str(x)) from None
91
+ click.echo(f'Registered the gb LFS transfer agent in this repo ({settings["lfs.customtransfer.gb.path"]}).')
92
+ click.echo('Uploads and downloads now go through gb: parallel parts, resumable, and no 5 GB upload limit.')
93
+
94
+
95
+ @cli.command(name='lfs-agent', hidden=True)
96
+ def lfs_agent_command() -> None:
97
+ """The git-lfs custom transfer process, for a config that spells it out as `gb lfs-agent`."""
98
+ from .lfs_agent import main
99
+
100
+ raise SystemExit(main([]))
101
+
102
+
103
+ def _human_bytes(count: int) -> str:
104
+ size = float(count)
105
+ for unit in ('B', 'KB', 'MB', 'GB', 'TB'):
106
+ if size < 1024 or unit == 'TB':
107
+ return f'{size:.0f} {unit}' if unit == 'B' else f'{size:.1f} {unit}'
108
+ size /= 1024
109
+ return f'{size:.1f} TB'
110
+
111
+
112
+ _HOST_OPTION = click.option('-H', '--host', default=None, help='Deployment domain; the sole gb login if omitted.')
113
+
114
+
115
+ @cli.group(name='repo')
116
+ def repo_group() -> None:
117
+ """Manage repos: list them, inspect one, protect refs, delete."""
118
+
119
+
120
+ @repo_group.command(name='list')
121
+ @_HOST_OPTION
122
+ def repo_list_command(host: str | None) -> None:
123
+ """List the buckets and repos your token can see."""
124
+ from . import repo as repo_module
125
+
126
+ try:
127
+ domain, entry = repo_module.select_host(host)
128
+ result = repo_module.list_repos(domain, entry)
129
+ except Exception as x:
130
+ raise click.ClickException(str(x)) from None
131
+ for bucket in result.get('buckets', []):
132
+ click.echo(bucket['alias'])
133
+ if not bucket['repos']:
134
+ click.echo(' (no repos)')
135
+ for name in bucket['repos']:
136
+ click.echo(f' {name}')
137
+
138
+
139
+ @repo_group.command(name='info')
140
+ @_HOST_OPTION
141
+ @click.option('-v', '--verbose', is_flag=True, help='Also list every ref with its sha.')
142
+ @click.argument('name')
143
+ def repo_info_command(name: str, host: str | None, verbose: bool) -> None:
144
+ """Show one repo: gb repo info <bucket>/<repo>."""
145
+ from . import repo as repo_module
146
+
147
+ try:
148
+ bucket, path = repo_module.parse_name(name)
149
+ domain, entry = repo_module.select_host(host)
150
+ result = repo_module.info(domain, entry, bucket, path)
151
+ except Exception as x:
152
+ raise click.ClickException(str(x)) from None
153
+ click.echo(f'{result["bucket"]}/{result["repo"]}')
154
+ click.echo(f' alias {result["alias"]}')
155
+ click.echo(f' web url {result["web_url"]}')
156
+ click.echo(f' seq {result["seq"]}')
157
+ click.echo(f' head {result["head"]}')
158
+ click.echo(f' refs {len(result["refs"])}')
159
+ protected = result.get('protected') or []
160
+ click.echo(f' protected {", ".join(protected) if protected else "none"}')
161
+ click.echo(f' entries {result["entries"]}')
162
+ click.echo(f' bytes {_human_bytes(result["bytes"])}')
163
+ last_push = result.get('last_push')
164
+ if last_push:
165
+ click.echo(f' last push {last_push["by"]} at {last_push["at"]}')
166
+ else:
167
+ click.echo(' last push (none)')
168
+ if verbose:
169
+ click.echo(' refs:')
170
+ for refname, sha in result['refs'].items():
171
+ click.echo(f' {refname} {sha}')
172
+
173
+
174
+ @repo_group.command(name='delete')
175
+ @_HOST_OPTION
176
+ @click.option('--yes', is_flag=True, help='Actually delete; without it, nothing is touched.')
177
+ @click.argument('name')
178
+ def repo_delete_command(name: str, host: str | None, yes: bool) -> None:
179
+ """Delete a repo and everything it published: gb repo delete <bucket>/<repo>."""
180
+ from . import repo as repo_module
181
+
182
+ try:
183
+ bucket, path = repo_module.parse_name(name)
184
+ domain, entry = repo_module.select_host(host)
185
+ except Exception as x:
186
+ raise click.ClickException(str(x)) from None
187
+ if not yes:
188
+ click.echo(f'This deletes {name}: its LFS objects, locks, bundles, packages and rendered web tree.')
189
+ click.echo('S3 versioning is the undo path; a re-push re-creates the repo from nothing.')
190
+ raise click.ClickException('refusing to delete without confirmation; re-run with --yes')
191
+ try:
192
+ result = repo_module.delete(domain, entry, bucket, path)
193
+ except Exception as x:
194
+ raise click.ClickException(str(x)) from None
195
+ deleted = result['deleted']
196
+ click.echo(
197
+ f'Deleted {name}: {deleted["objects"]} object(s), {deleted["web_objects"]} web object(s), '
198
+ f'{deleted["pins"]} pin(s).'
199
+ )
200
+
201
+
202
+ def _protect(operation: str, name: str, ref: str, host: str | None) -> None:
203
+ from . import repo as repo_module
204
+
205
+ call = repo_module.protect if operation == 'protect' else repo_module.unprotect
206
+ try:
207
+ bucket, path = repo_module.parse_name(name)
208
+ domain, entry = repo_module.select_host(host)
209
+ result = call(domain, entry, bucket, path, ref)
210
+ except Exception as x:
211
+ raise click.ClickException(str(x)) from None
212
+ protected = result.get('protected') or []
213
+ click.echo(f'protected: {", ".join(protected) if protected else "none"}')
214
+
215
+
216
+ @repo_group.command(name='protect')
217
+ @_HOST_OPTION
218
+ @click.argument('name')
219
+ @click.argument('ref')
220
+ def repo_protect_command(name: str, ref: str, host: str | None) -> None:
221
+ """Protect a ref against deletion: gb repo protect <bucket>/<repo> <ref>."""
222
+ _protect('protect', name, ref, host)
223
+
224
+
225
+ @repo_group.command(name='unprotect')
226
+ @_HOST_OPTION
227
+ @click.argument('name')
228
+ @click.argument('ref')
229
+ def repo_unprotect_command(name: str, ref: str, host: str | None) -> None:
230
+ """Remove a ref's protection: gb repo unprotect <bucket>/<repo> <ref>."""
231
+ _protect('unprotect', name, ref, host)
232
+
233
+
234
+ @cli.command(name='credential', hidden=True)
235
+ @click.argument('operation')
236
+ def credential_command(operation: str) -> None:
237
+ """The git credential helper, for a gitconfig that spells it out as `helper = "gb credential"`."""
238
+ from .credential import main
239
+
240
+ raise SystemExit(main([operation]))
@@ -0,0 +1,83 @@
1
+ """The git credential helper: ``git credential-gb``, which is what ``helper = gb`` in a gitconfig runs.
2
+
3
+ Deliberately importless beyond the client's own small modules: this runs on every git operation against the
4
+ deployment. A warm session token is one cached-file read; a live MicroVM behind it is not something the cache
5
+ can promise, which is what ``wake.probe`` is for -- one cheap authenticated request, silent unless the bucket
6
+ turns out to be cold. ``GB_NO_WAKE_PROBE=1`` drops back to the old zero-network warm path.
7
+ """
8
+
9
+ import sys
10
+
11
+ from . import hosts, session, wake
12
+
13
+ _USERNAME = 'gb'
14
+
15
+
16
+ def parse(stream) -> dict[str, str]: # noqa: ANN001
17
+ """Read git's key=value block, terminated by a blank line or EOF."""
18
+ fields: dict[str, str] = {}
19
+ for raw in stream:
20
+ line = raw.rstrip('\n')
21
+ if not line:
22
+ break
23
+ key, _, value = line.partition('=')
24
+ if key:
25
+ fields[key] = value
26
+ return fields
27
+
28
+
29
+ def get(fields: dict[str, str]) -> str | None:
30
+ """The credential block for a registered host, or None to decline.
31
+
32
+ Declining is the whole point of the host match: installing gb can never change what git sends to
33
+ somebody else's server.
34
+ """
35
+ if fields.get('protocol', 'https') != 'https':
36
+ return None
37
+ resolved = hosts.resolve(fields.get('host', ''))
38
+ if resolved is None:
39
+ return None
40
+ domain, entry = resolved
41
+ token = session.token(domain, entry)
42
+ # The bucket's own host, not the deployment domain the token was minted against: that is what git is
43
+ # about to talk to, and the only host the launcher's cold path answers for.
44
+ wake.probe(fields.get('host', domain).partition(':')[0], token)
45
+ return f'username={_USERNAME}\npassword={token}\n'
46
+
47
+
48
+ def erase(fields: dict[str, str]) -> None:
49
+ """Drop the cached session token for a registered host, so the next ``get`` mints fresh instead of
50
+ handing back the same rejected value.
51
+
52
+ git-lfs runs ``reject`` (our ``erase``) then ``fill`` (our ``get``) on a 401 and retries with whatever
53
+ ``get`` returns, with no backoff. If ``get`` kept answering from a warm cache, that would loop forever on a
54
+ revoked or skewed token. This is why erase has to actually do something, unlike plain git's store/erase.
55
+ """
56
+ if fields.get('protocol', 'https') != 'https':
57
+ return
58
+ resolved = hosts.resolve(fields.get('host', ''))
59
+ if resolved is None:
60
+ return
61
+ domain, _entry = resolved
62
+ session.forget_token(domain)
63
+
64
+
65
+ def main(argv: list[str] | None = None) -> int:
66
+ argv = sys.argv[1:] if argv is None else argv
67
+ operation = argv[0] if argv else ''
68
+ try:
69
+ fields = parse(sys.stdin)
70
+ if operation == 'get':
71
+ answer = get(fields)
72
+ elif operation == 'erase':
73
+ erase(fields)
74
+ answer = None
75
+ else:
76
+ # store is a no-op: gb owns its own storage and git has nothing to tell it about the session.
77
+ answer = None
78
+ except Exception as x:
79
+ print(f'gb: {x}', file=sys.stderr)
80
+ return 1
81
+ if answer:
82
+ sys.stdout.write(answer)
83
+ return 0