๐ feat: initial git-shark platform import
Changes
119 files changed, +8881 -0
ADD
.agents/skills/cavecrew/README.md
+46 -0
@@ -0,0 +1,46 @@
1
+# cavecrew
2
+
3
+Decision guide. When to delegate to caveman subagents instead of doing the work inline.
4
+
5
+## What it does
6
+
7
+Tells the main thread when to spawn a caveman-style subagent versus the vanilla equivalent. The win: subagent
8
+tool-results inject back into main context verbatim, and caveman output is roughly 1/3 the size of vanilla prose. Across
9
+20 delegations in one session, that is the difference between context exhaustion and finishing the task.
10
+
11
+Three subagents:
12
+
13
+| Subagent | Job | Use when |
14
+|-------------------------|--------------------------|------------------------------------------------------|
15
+| `cavecrew-investigator` | Locate code (read-only) | "Where is X defined / what calls Y / list uses of Z" |
16
+| `cavecrew-builder` | Surgical edit, 1-2 files | Scope is obvious, โค2 files. Refuses 3+ file scope. |
17
+| `cavecrew-reviewer` | Diff/file review | One-line findings with severity emoji |
18
+
19
+Use vanilla `Explore` or `Code Reviewer` when you want prose, architecture commentary, or rationale. Use main thread
20
+directly for one-line answers and 3+ file refactors.
21
+
22
+This skill is a decision guide, not a slash command. It activates when the conversation mentions delegation.
23
+
24
+## How to invoke
25
+
26
+Triggers on phrases like "delegate to subagent", "use cavecrew", "spawn investigator", "save context", "compressed agent
27
+output".
28
+
29
+## Example chaining
30
+
31
+Locate โ fix โ verify (most common):
32
+
33
+1. `cavecrew-investigator` returns site list (`path:line โ symbol โ note`)
34
+2. Main thread picks 1-2 sites, hands paths to `cavecrew-builder`
35
+3. `cavecrew-reviewer` audits the resulting diff
36
+
37
+Parallel scout: spawn 2-3 `cavecrew-investigator` calls in one message with different angles (defs, callers, tests).
38
+Aggregate in main.
39
+
40
+## See also
41
+
42
+- [`SKILL.md`](./SKILL.md) โ full decision matrix and output contracts
43
+- [`agents/cavecrew-investigator.md`](../../agents/cavecrew-investigator.md)
44
+- [`agents/cavecrew-builder.md`](../../agents/cavecrew-builder.md)
45
+- [`agents/cavecrew-reviewer.md`](../../agents/cavecrew-reviewer.md)
46
+- [Caveman README](../../README.md) โ repo overview
ADD
.agents/skills/cavecrew/SKILL.md
+99 -0
@@ -0,0 +1,99 @@
1
+---
2
+name: cavecrew
3
+description: >
4
+ Decision guide for delegating to caveman-style subagents. Tells the main
5
+ thread WHEN to spawn `cavecrew-investigator` (locate code), `cavecrew-builder`
6
+ (1-2 file edit), or `cavecrew-reviewer` (diff review) instead of doing the
7
+ work inline or using vanilla `Explore`. Subagent output is caveman-compressed
8
+ so the tool-result injected back into main context is ~60% smaller โ main
9
+ context lasts longer across long sessions.
10
+ Trigger: "delegate to subagent", "use cavecrew", "spawn investigator/builder/reviewer",
11
+ "save context", "compressed agent output".
12
+---
13
+
14
+Cavecrew = three subagent presets that emit caveman output. Same job as Anthropic defaults (`Explore`, edit-style
15
+agents, reviewer); difference is the tool-result they return is compressed, so main context shrinks per delegation.
16
+
17
+## When to use cavecrew vs alternatives
18
+
19
+| Task | Use |
20
+|------------------------------------------------------------|---------------------------------------------|
21
+| "Where is X defined / what calls Y / list uses of Z" | `cavecrew-investigator` |
22
+| Same but you also want suggestions/architecture commentary | `Explore` (vanilla) |
23
+| Surgical edit, โค2 files, scope obvious | `cavecrew-builder` |
24
+| New feature / 3+ files / cross-cutting refactor | Main thread or `feature-dev:code-architect` |
25
+| Review diff, branch, or file for bugs | `cavecrew-reviewer` |
26
+| Deep code review with rationale + alternatives | `Code Reviewer` (vanilla) |
27
+| One-line answer you already know | Main thread, no subagent |
28
+
29
+Rule of thumb: **if you'd want the subagent's output in 1/3 the tokens, pick cavecrew. If you'd want prose, pick
30
+vanilla.**
31
+
32
+## Why this exists (the real win)
33
+
34
+Subagent tool results get injected into main context verbatim. A vanilla `Explore` that returns 2k tokens of prose costs
35
+2k tokens of main-context budget every time. The same finding from `cavecrew-investigator` returns ~700 tokens. Across
36
+20 delegations in one session that's the difference between context exhaustion and finishing the task.
37
+
38
+## Output contracts
39
+
40
+What main thread can rely on per agent:
41
+
42
+**`cavecrew-investigator`**
43
+
44
+```
45
+<Header>:
46
+- path:line โ `symbol` โ short note
47
+totals: <counts>.
48
+```
49
+
50
+Or `No match.` Always file-path-first, line-number-attached, backticked symbols. Safe to grep with `path:\d+`.
51
+
52
+**`cavecrew-builder`**
53
+
54
+```
55
+<path:line-range> โ <change โค10 words>.
56
+verified: <re-read OK | mismatch @ path:line>.
57
+```
58
+
59
+Or one of: `too-big.` / `needs-confirm.` / `ambiguous.` / `regressed.` (terminal first token).
60
+
61
+**`cavecrew-reviewer`**
62
+
63
+```
64
+path:line: <emoji> <severity>: <problem>. <fix>.
65
+totals: N๐ด N๐ก N๐ต Nโ
66
+```
67
+
68
+Or `No issues.` Findings sorted file โ line ascending.
69
+
70
+## Chaining patterns
71
+
72
+**Locate โ fix โ verify** (most common):
73
+
74
+1. `cavecrew-investigator` returns site list.
75
+2. Main thread picks 1-2 sites, hands paths to `cavecrew-builder`.
76
+3. `cavecrew-reviewer` audits the diff.
77
+
78
+**Parallel scout** (when investigation is broad):
79
+Spawn 2-3 `cavecrew-investigator` calls in one message (different angles: defs vs callers vs tests). Aggregate in main
80
+thread.
81
+
82
+**Single-shot edit** (when site is already known):
83
+Skip investigator. Hand exact path:line to `cavecrew-builder` directly.
84
+
85
+## What NOT to do
86
+
87
+- Don't use `cavecrew-builder` when you don't already know the file. Spawn investigator first or main thread will eat
88
+ tokens passing context.
89
+- Don't chain `cavecrew-investigator โ cavecrew-builder` for a 5-file refactor. Builder will return `too-big.` and
90
+ you'll have wasted a turn.
91
+- Don't ask `cavecrew-reviewer` for "general feedback" โ it returns findings only, no architecture opinions. Use
92
+ `Code Reviewer` for that.
93
+- Don't expect prose. Cavecrew output is structured, sometimes terse to the point of cryptic. If a human will read it
94
+ directly, paraphrase.
95
+
96
+## Auto-clarity (inherited)
97
+
98
+Subagents drop caveman โ normal English for security warnings, irreversible-action confirmations, and any output where
99
+fragment ambiguity could be misread. Resume caveman after.
ADD
.agents/skills/caveman-commit/README.md
+47 -0
@@ -0,0 +1,47 @@
1
+# caveman-commit
2
+
3
+Terse Conventional Commits. Why over what.
4
+
5
+## What it does
6
+
7
+Generates commit messages in Conventional Commits format. Subject โค50 chars, hard cap 72. Imperative mood. Body only
8
+when the *why* is non-obvious or there are breaking changes. No AI attribution, no "this commit does X", no emoji unless
9
+the project uses them. Body always required for breaking changes, security fixes, data migrations, and reverts โ future
10
+debuggers need the context.
11
+
12
+Outputs only the message. Does not stage, commit, or amend.
13
+
14
+## How to invoke
15
+
16
+```
17
+/caveman-commit
18
+```
19
+
20
+Also triggers on phrases like "write a commit", "commit message", "generate commit".
21
+
22
+## Example output
23
+
24
+Diff: new endpoint for user profile.
25
+
26
+```
27
+feat(api): add GET /users/:id/profile
28
+
29
+Mobile client needs profile data without the full user payload
30
+to reduce LTE bandwidth on cold-launch screens.
31
+
32
+Closes #128
33
+```
34
+
35
+Diff: breaking API rename.
36
+
37
+```
38
+feat(api)!: rename /v1/orders to /v1/checkout
39
+
40
+BREAKING CHANGE: clients on /v1/orders must migrate to /v1/checkout
41
+before 2026-06-01. Old route returns 410 after that date.
42
+```
43
+
44
+## See also
45
+
46
+- [`SKILL.md`](./SKILL.md) โ full LLM-facing instructions
47
+- [Caveman README](../../README.md) โ repo overview
ADD
.agents/skills/caveman-commit/SKILL.md
+72 -0
@@ -0,0 +1,72 @@
1
+---
2
+name: caveman-commit
3
+description: >
4
+ Ultra-compressed commit message generator. Cuts noise from commit messages while preserving
5
+ intent and reasoning. Conventional Commits format. Subject โค50 chars, body only when "why"
6
+ isn't obvious. Use when user says "write a commit", "commit message", "generate commit",
7
+ "/commit", or invokes /caveman-commit. Auto-triggers when staging changes.
8
+---
9
+
10
+Write commit messages terse and exact. Conventional Commits format. No fluff. Why over what.
11
+
12
+## Rules
13
+
14
+**Subject line:**
15
+
16
+- `<type>(<scope>): <imperative summary>` โ `<scope>` optional
17
+- Types: `feat`, `fix`, `refactor`, `perf`, `docs`, `test`, `chore`, `build`, `ci`, `style`, `revert`
18
+- Imperative mood: "add", "fix", "remove" โ not "added", "adds", "adding"
19
+- โค50 chars when possible, hard cap 72
20
+- No trailing period
21
+- Match project convention for capitalization after the colon
22
+
23
+**Body (only if needed):**
24
+
25
+- Skip entirely when subject is self-explanatory
26
+- Add body only for: non-obvious *why*, breaking changes, migration notes, linked issues
27
+- Wrap at 72 chars
28
+- Bullets `-` not `*`
29
+- Reference issues/PRs at end: `Closes #42`, `Refs #17`
30
+
31
+**What NEVER goes in:**
32
+
33
+- "This commit does X", "I", "we", "now", "currently" โ the diff says what
34
+- "As requested by..." โ use Co-authored-by trailer
35
+- "Generated with Claude Code" or any AI attribution
36
+- Emoji (unless project convention requires)
37
+- Restating the file name when scope already says it
38
+
39
+## Examples
40
+
41
+Diff: new endpoint for user profile with body explaining the why
42
+
43
+- โ "feat: add a new endpoint to get user profile information from the database"
44
+- โ
45
+ ```
46
+ feat(api): add GET /users/:id/profile
47
+
48
+ Mobile client needs profile data without the full user payload
49
+ to reduce LTE bandwidth on cold-launch screens.
50
+
51
+ Closes #128
52
+ ```
53
+
54
+Diff: breaking API change
55
+
56
+- โ
57
+ ```
58
+ feat(api)!: rename /v1/orders to /v1/checkout
59
+
60
+ BREAKING CHANGE: clients on /v1/orders must migrate to /v1/checkout
61
+ before 2026-06-01. Old route returns 410 after that date.
62
+ ```
63
+
64
+## Auto-Clarity
65
+
66
+Always include body for: breaking changes, security fixes, data migrations, anything reverting a prior commit. Never
67
+compress these into subject-only โ future debuggers need the context.
68
+
69
+## Boundaries
70
+
71
+Only generates the commit message. Does not run `git commit`, does not stage files, does not amend. Output the message
72
+as a code block ready to paste. "stop caveman-commit" or "normal mode": revert to verbose commit style.
ADD
.agents/skills/caveman-compress/README.md
+172 -0
@@ -0,0 +1,172 @@
1
+<p align="center">
2
+ <img src="https://em-content.zobj.net/source/apple/391/rock_1faa8.png" width="80" />
3
+</p>
4
+
5
+<h1 align="center">caveman-compress</h1>
6
+
7
+<p align="center">
8
+ <strong>shrink memory file. save token every session.</strong>
9
+</p>
10
+
11
+---
12
+
13
+A Claude Code skill that compresses your project memory files (`CLAUDE.md`, todos, preferences) into caveman format โ so
14
+every session loads fewer tokens automatically.
15
+
16
+Claude read `CLAUDE.md` on every session start. If file big, cost big. Caveman make file small. Cost go down forever.
17
+
18
+## What It Do
19
+
20
+```
21
+/caveman-compress CLAUDE.md
22
+```
23
+
24
+```
25
+CLAUDE.md โ compressed (Claude reads this โ fewer tokens every session)
26
+CLAUDE.original.md โ human-readable backup (you edit this)
27
+```
28
+
29
+Original never lost. You can read and edit `.original.md`. Run skill again to re-compress after edits.
30
+
31
+## Benchmarks
32
+
33
+Real results on real project files:
34
+
35
+| File | Original | Compressed | Saved |
36
+|----------------------------|---------:|-----------:|----------:|
37
+| `claude-md-preferences.md` | 706 | 285 | **59.6%** |
38
+| `project-notes.md` | 1145 | 535 | **53.3%** |
39
+| `claude-md-project.md` | 1122 | 636 | **43.3%** |
40
+| `todo-list.md` | 627 | 388 | **38.1%** |
41
+| `mixed-with-code.md` | 888 | 560 | **36.9%** |
42
+| **Average** | **898** | **481** | **46%** |
43
+
44
+All validations passed โ
โ headings, code blocks, URLs, file paths preserved exactly.
45
+
46
+## Before / After
47
+
48
+<table>
49
+<tr>
50
+<td width="50%">
51
+
52
+### ๐ Original (706 tokens)
53
+
54
+> "I strongly prefer TypeScript with strict mode enabled for all new code. Please don't use `any` type unless there's
55
+> genuinely no way around it, and if you do, leave a comment explaining the reasoning. I find that taking the time to
56
+> properly type things catches a lot of bugs before they ever make it to runtime."
57
+
58
+</td>
59
+<td width="50%">
60
+
61
+### <img src="../../docs/assets/dancing-rock.svg" width="20" height="20" alt="rock"/> Caveman (285 tokens)
62
+
63
+> "Prefer TypeScript strict mode always. No `any` unless unavoidable โ comment why if used. Proper types catch bugs
64
+> early."
65
+
66
+</td>
67
+</tr>
68
+</table>
69
+
70
+**Same instructions. 60% fewer tokens. Every. Single. Session.**
71
+
72
+## Security
73
+
74
+`caveman-compress` is flagged as Snyk High Risk due to subprocess and file I/O patterns detected by static analysis.
75
+This is a false positive โ see [SECURITY.md](./SECURITY.md) for a full explanation of what the skill does and does not
76
+do.
77
+
78
+## Install
79
+
80
+Compress is built in with the `caveman` plugin. Install `caveman` once, then use `/caveman-compress`.
81
+
82
+If you need local files, the compress skill lives at:
83
+
84
+```bash
85
+caveman-compress/
86
+```
87
+
88
+**Requires:** Python 3.10+
89
+
90
+## Usage
91
+
92
+```
93
+/caveman-compress <filepath>
94
+```
95
+
96
+Examples:
97
+
98
+```
99
+/caveman-compress CLAUDE.md
100
+/caveman-compress docs/preferences.md
101
+/caveman-compress todos.md
102
+```
103
+
104
+### What files work
105
+
106
+| Type | Compress? |
107
+|-------------------------------------------------|-----------------------|
108
+| `.md`, `.txt`, `.rst`, `.typ`, `.typst`, `.tex` | โ
Yes |
109
+| Extensionless natural language | โ
Yes |
110
+| `.py`, `.js`, `.ts`, `.json`, `.yaml` | โ Skip (code/config) |
111
+| `*.original.md` | โ Skip (backup files) |
112
+
113
+## How It Work
114
+
115
+```
116
+/caveman-compress CLAUDE.md
117
+ โ
118
+detect file type (no tokens)
119
+ โ
120
+Claude compresses (tokens โ one call)
121
+ โ
122
+validate output (no tokens)
123
+ checks: headings, code blocks, URLs, file paths, bullets
124
+ โ
125
+if errors: Claude fixes cherry-picked issues only (tokens โ targeted fix)
126
+ does NOT recompress โ only patches broken parts
127
+ โ
128
+retry up to 2 times
129
+ โ
130
+write compressed โ CLAUDE.md
131
+write original โ CLAUDE.original.md
132
+```
133
+
134
+Only two things use tokens: initial compression + targeted fix if validation fails. Everything else is local Python.
135
+
136
+## What Is Preserved
137
+
138
+Caveman compress natural language. It never touch:
139
+
140
+- Code blocks (` ``` ` fenced or indented)
141
+- Inline code (`` `backtick content` ``)
142
+- URLs and links
143
+- File paths (`/src/components/...`)
144
+- Commands (`npm install`, `git commit`)
145
+- Technical terms, library names, API names
146
+- Headings (exact text preserved)
147
+- Tables (structure preserved, cell text compressed)
148
+- Dates, version numbers, numeric values
149
+
150
+## Why This Matter
151
+
152
+`CLAUDE.md` loads on **every session start**. A 1000-token project memory file costs tokens every single time you open a
153
+project. Over 100 sessions that's 100,000 tokens of overhead โ just for context you already wrote.
154
+
155
+Caveman cut that by ~46% on average. Same instructions. Same accuracy. Less waste.
156
+
157
+```
158
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
159
+โ TOKEN SAVINGS PER FILE โโโโโ 46% โ
160
+โ SESSIONS THAT BENEFIT โโโโโโโโโโ 100% โ
161
+โ INFORMATION PRESERVED โโโโโโโโโโ 100% โ
162
+โ SETUP TIME โ 1x โ
163
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
164
+```
165
+
166
+## Part of Caveman
167
+
168
+This skill is part of the [caveman](https://github.com/JuliusBrussee/caveman) toolkit โ making Claude use fewer tokens
169
+without losing accuracy.
170
+
171
+- **caveman** โ make Claude *speak* like caveman (cuts response tokens ~65%)
172
+- **caveman-compress** โ make Claude *read* less (cuts context tokens ~46%)
ADD
.agents/skills/caveman-compress/SECURITY.md
+37 -0
@@ -0,0 +1,37 @@
1
+# Security
2
+
3
+## Snyk High Risk Rating
4
+
5
+`caveman-compress` receives a Snyk High Risk rating due to static analysis heuristics. This document explains what the
6
+skill does and does not do.
7
+
8
+### What triggers the rating
9
+
10
+1. **subprocess usage**: The skill calls the `claude` CLI via `subprocess.run()` as a fallback when `ANTHROPIC_API_KEY`
11
+ is not set. The subprocess call uses a fixed argument list โ no shell interpolation occurs. User file content is
12
+ passed via stdin, not as a shell argument.
13
+
14
+2. **File read/write**: The skill reads the file the user explicitly points it at, compresses it, and writes the result
15
+ back to the same path. A `.original.md` backup is saved alongside it. No files outside the user-specified path are
16
+ read or written.
17
+
18
+### What the skill does NOT do
19
+
20
+- Does not execute user file content as code
21
+- Does not make network requests except to Anthropic's API (via SDK or CLI)
22
+- Does not access files outside the path the user provides
23
+- Does not use shell=True or string interpolation in subprocess calls
24
+- Does not collect or transmit any data beyond the file being compressed
25
+
26
+### Auth behavior
27
+
28
+If `ANTHROPIC_API_KEY` is set, the skill uses the Anthropic Python SDK directly (no subprocess). If not set, it falls
29
+back to the `claude` CLI, which uses the user's existing Claude desktop authentication.
30
+
31
+### File size limit
32
+
33
+Files larger than 500KB are rejected before any API call is made.
34
+
35
+### Reporting a vulnerability
36
+
37
+If you believe you've found a genuine security issue, please open a GitHub issue with the label `security`.
ADD
.agents/skills/caveman-compress/SKILL.md
+124 -0
@@ -0,0 +1,124 @@
1
+---
2
+name: caveman-compress
3
+description: >
4
+ Compress natural language memory files (CLAUDE.md, todos, preferences) into caveman format
5
+ to save input tokens. Preserves all technical substance, code, URLs, and structure.
6
+ Compressed version overwrites the original file. Human-readable backup saved as FILE.original.md.
7
+ Trigger: /caveman-compress FILEPATH or "compress memory file"
8
+---
9
+
10
+# Caveman Compress
11
+
12
+## Purpose
13
+
14
+Compress natural language files (CLAUDE.md, todos, preferences) into caveman-speak to reduce input tokens. Compressed
15
+version overwrites original. Human-readable backup saved as `<filename>.original.md`.
16
+
17
+## Trigger
18
+
19
+`/caveman-compress <filepath>` or when user asks to compress a memory file.
20
+
21
+## Process
22
+
23
+1. The compression scripts live in `scripts/` (adjacent to this SKILL.md). If the path is not immediately available,
24
+ search for `scripts/__main__.py` next to this SKILL.md.
25
+
26
+2. From the directory containing this SKILL.md, run:
27
+
28
+python3 -m scripts <absolute_filepath>
29
+
30
+3. The CLI will:
31
+
32
+- detect file type (no tokens)
33
+- call Claude to compress
34
+- validate output (no tokens)
35
+- if errors: cherry-pick fix with Claude (targeted fixes only, no recompression)
36
+- retry up to 2 times
37
+- if still failing after 2 retries: report error to user, leave original file untouched
38
+
39
+4. Return result to user
40
+
41
+## Compression Rules
42
+
43
+### Remove
44
+
45
+- Articles: a, an, the
46
+- Filler: just, really, basically, actually, simply, essentially, generally
47
+- Pleasantries: "sure", "certainly", "of course", "happy to", "I'd recommend"
48
+- Hedging: "it might be worth", "you could consider", "it would be good to"
49
+- Redundant phrasing: "in order to" โ "to", "make sure to" โ "ensure", "the reason is because" โ "because"
50
+- Connective fluff: "however", "furthermore", "additionally", "in addition"
51
+
52
+### Preserve EXACTLY (never modify)
53
+
54
+- Code blocks (fenced ``` and indented)
55
+- Inline code (`backtick content`)
56
+- URLs and links (full URLs, markdown links)
57
+- File paths (`/src/components/...`, `./config.yaml`)
58
+- Commands (`npm install`, `git commit`, `docker build`)
59
+- Technical terms (library names, API names, protocols, algorithms)
60
+- Proper nouns (project names, people, companies)
61
+- Dates, version numbers, numeric values
62
+- Environment variables (`$HOME`, `NODE_ENV`)
63
+
64
+### Preserve Structure
65
+
66
+- All markdown headings (keep exact heading text, compress body below)
67
+- Bullet point hierarchy (keep nesting level)
68
+- Numbered lists (keep numbering)
69
+- Tables (compress cell text, keep structure)
70
+- Frontmatter/YAML headers in markdown files
71
+
72
+### Compress
73
+
74
+- Use short synonyms: "big" not "extensive", "fix" not "implement a solution for", "use" not "utilize"
75
+- Fragments OK: "Run tests before commit" not "You should always run tests before committing"
76
+- Drop "you should", "make sure to", "remember to" โ just state the action
77
+- Merge redundant bullets that say the same thing differently
78
+- Keep one example where multiple examples show the same pattern
79
+
80
+CRITICAL RULE:
81
+Anything inside ``` ... ``` must be copied EXACTLY.
82
+Do not:
83
+
84
+- remove comments
85
+- remove spacing
86
+- reorder lines
87
+- shorten commands
88
+- simplify anything
89
+
90
+Inline code (`...`) must be preserved EXACTLY.
91
+Do not modify anything inside backticks.
92
+
93
+If file contains code blocks:
94
+
95
+- Treat code blocks as read-only regions
96
+- Only compress text outside them
97
+- Do not merge sections around code
98
+
99
+## Pattern
100
+
101
+Original:
102
+> You should always make sure to run the test suite before pushing any changes to the main branch. This is important
103
+> because it helps catch bugs early and prevents broken builds from being deployed to production.
104
+
105
+Compressed:
106
+> Run tests before push to main. Catch bugs early, prevent broken prod deploys.
107
+
108
+Original:
109
+> The application uses a microservices architecture with the following components. The API gateway handles all incoming
110
+> requests and routes them to the appropriate service. The authentication service is responsible for managing user
111
+> sessions and JWT tokens.
112
+
113
+Compressed:
114
+> Microservices architecture. API gateway route all requests to services. Auth service manage user sessions + JWT
115
+> tokens.
116
+
117
+## Boundaries
118
+
119
+- ONLY compress natural language files (.md, .txt, .typ, .typst, .tex, extensionless)
120
+- NEVER modify: .py, .js, .ts, .json, .yaml, .yml, .toml, .env, .lock, .css, .html, .xml, .sql, .sh
121
+- If file has mixed content (prose + code), compress ONLY the prose sections
122
+- If unsure whether something is code or prose, leave it unchanged
123
+- Original file is backed up as FILE.original.md before overwriting
124
+- Never compress FILE.original.md (skip it)
ADD
.agents/skills/caveman-compress/scripts/__init__.py
+9 -0
@@ -0,0 +1,9 @@
1
+"""Caveman compress scripts.
2
+
3
+This package provides tools to compress natural language markdown files
4
+into caveman format to save input tokens.
5
+"""
6
+
7
+__all__ = ["cli", "compress", "detect", "validate"]
8
+
9
+__version__ = "1.0.0"
ADD
.agents/skills/caveman-compress/scripts/__main__.py
+3 -0
@@ -0,0 +1,3 @@
1
+from .cli import main
2
+
3
+main()
ADD
.agents/skills/caveman-compress/scripts/benchmark.py
+80 -0
@@ -0,0 +1,80 @@
1
+#!/usr/bin/env python3
2
+from pathlib import Path
3
+import sys
4
+
5
+# Support both direct execution and module import
6
+try:
7
+ from .validate import validate
8
+except ImportError:
9
+ sys.path.insert(0, str(Path(__file__).parent))
10
+ from validate import validate
11
+
12
+try:
13
+ import tiktoken
14
+ _enc = tiktoken.get_encoding("o200k_base")
15
+except ImportError:
16
+ _enc = None
17
+
18
+
19
+def count_tokens(text):
20
+ if _enc is None:
21
+ return len(text.split()) # fallback: word count
22
+ return len(_enc.encode(text))
23
+
24
+
25
+def benchmark_pair(orig_path: Path, comp_path: Path):
26
+ orig_text = orig_path.read_text()
27
+ comp_text = comp_path.read_text()
28
+
29
+ orig_tokens = count_tokens(orig_text)
30
+ comp_tokens = count_tokens(comp_text)
31
+ saved = 100 * (orig_tokens - comp_tokens) / orig_tokens if orig_tokens > 0 else 0.0
32
+ result = validate(orig_path, comp_path)
33
+
34
+ return (comp_path.name, orig_tokens, comp_tokens, saved, result.is_valid)
35
+
36
+
37
+def print_table(rows):
38
+ print("\n| File | Original | Compressed | Saved % | Valid |")
39
+ print("|------|----------|------------|---------|-------|")
40
+ for r in rows:
41
+ print(f"| {r[0]} | {r[1]} | {r[2]} | {r[3]:.1f}% | {'โ
' if r[4] else 'โ'} |")
42
+
43
+
44
+def main():
45
+ # Direct file pair: python3 benchmark.py original.md compressed.md
46
+ if len(sys.argv) == 3:
47
+ orig = Path(sys.argv[1]).resolve()
48
+ comp = Path(sys.argv[2]).resolve()
49
+ if not orig.exists():
50
+ print(f"โ Not found: {orig}")
51
+ sys.exit(1)
52
+ if not comp.exists():
53
+ print(f"โ Not found: {comp}")
54
+ sys.exit(1)
55
+ print_table([benchmark_pair(orig, comp)])
56
+ return
57
+
58
+ # Glob mode: repo_root/tests/caveman-compress/
59
+ # __file__ lives at <repo_root>/skills/caveman-compress/scripts/benchmark.py
60
+ # Walk up four dirs: scripts โ caveman-compress โ skills โ repo_root.
61
+ tests_dir = Path(__file__).resolve().parents[3] / "tests" / "caveman-compress"
62
+ if not tests_dir.exists():
63
+ print(f"โ Tests dir not found: {tests_dir}")
64
+ sys.exit(1)
65
+
66
+ rows = []
67
+ for orig in sorted(tests_dir.glob("*.original.md")):
68
+ comp = orig.with_name(orig.stem.removesuffix(".original") + ".md")
69
+ if comp.exists():
70
+ rows.append(benchmark_pair(orig, comp))
71
+
72
+ if not rows:
73
+ print("No compressed file pairs found.")
74
+ return
75
+
76
+ print_table(rows)
77
+
78
+
79
+if __name__ == "__main__":
80
+ main()
ADD
.agents/skills/caveman-compress/scripts/cli.py
+85 -0
@@ -0,0 +1,85 @@
1
+#!/usr/bin/env python3
2
+"""
3
+Caveman Compress CLI
4
+
5
+Usage:
6
+ caveman <filepath>
7
+"""
8
+
9
+import sys
10
+
11
+# Force UTF-8 on stdout/stderr before any code can print. Windows consoles
12
+# default to cp1252 and crash on the โ glyphs in error/validation branches,
13
+# masking the real error and leaving the user with a half-compressed file.
14
+for _stream in (sys.stdout, sys.stderr):
15
+ reconfigure = getattr(_stream, "reconfigure", None)
16
+ if callable(reconfigure):
17
+ try:
18
+ reconfigure(encoding="utf-8", errors="replace")
19
+ except Exception:
20
+ pass
21
+
22
+from pathlib import Path
23
+
24
+from .compress import compress_file
25
+from .detect import detect_file_type, should_compress
26
+
27
+
28
+def print_usage():
29
+ print("Usage: caveman <filepath>")
30
+
31
+
32
+def main():
33
+ if len(sys.argv) != 2:
34
+ print_usage()
35
+ sys.exit(1)
36
+
37
+ filepath = Path(sys.argv[1])
38
+
39
+ # Check file exists
40
+ if not filepath.exists():
41
+ print(f"โ File not found: {filepath}")
42
+ sys.exit(1)
43
+
44
+ if not filepath.is_file():
45
+ print(f"โ Not a file: {filepath}")
46
+ sys.exit(1)
47
+
48
+ filepath = filepath.resolve()
49
+
50
+ # Detect file type
51
+ file_type = detect_file_type(filepath)
52
+
53
+ print(f"Detected: {file_type}")
54
+
55
+ # Check if compressible
56
+ if not should_compress(filepath):
57
+ print("Skipping: file is not natural language (code/config)")
58
+ sys.exit(0)
59
+
60
+ print("Starting caveman compression...\n")
61
+
62
+ try:
63
+ success = compress_file(filepath)
64
+
65
+ if success:
66
+ print("\nCompression completed successfully")
67
+ backup_path = filepath.with_name(filepath.stem + ".original.md")
68
+ print(f"Compressed: {filepath}")
69
+ print(f"Original: {backup_path}")
70
+ sys.exit(0)
71
+ else:
72
+ print("\nโ Compression failed after retries")
73
+ sys.exit(2)
74
+
75
+ except KeyboardInterrupt:
76
+ print("\nInterrupted by user")
77
+ sys.exit(130)
78
+
79
+ except Exception as e:
80
+ print(f"\nโ Error: {e}")
81
+ sys.exit(1)
82
+
83
+
84
+if __name__ == "__main__":
85
+ main()
ADD
.agents/skills/caveman-compress/scripts/compress.py
+254 -0
@@ -0,0 +1,254 @@
1
+#!/usr/bin/env python3
2
+"""
3
+Caveman Memory Compression Orchestrator
4
+
5
+Usage:
6
+ python scripts/compress.py <filepath>
7
+"""
8
+
9
+import os
10
+import re
11
+import subprocess
12
+from pathlib import Path
13
+from typing import List
14
+
15
+OUTER_FENCE_REGEX = re.compile(
16
+ r"\A\s*(`{3,}|~{3,})[^\n]*\n(.*)\n\1\s*\Z", re.DOTALL
17
+)
18
+
19
+# Filenames and paths that almost certainly hold secrets or PII. Compressing
20
+# them ships raw bytes to the Anthropic API โ a third-party data boundary that
21
+# developers on sensitive codebases cannot cross. detect.py already skips .env
22
+# by extension, but credentials.md / secrets.txt / ~/.aws/credentials would
23
+# slip through the natural-language filter. This is a hard refuse before read.
24
+SENSITIVE_BASENAME_REGEX = re.compile(
25
+ r"(?ix)^("
26
+ r"\.env(\..+)?"
27
+ r"|\.netrc"
28
+ r"|credentials(\..+)?"
29
+ r"|secrets?(\..+)?"
30
+ r"|passwords?(\..+)?"
31
+ r"|id_(rsa|dsa|ecdsa|ed25519)(\.pub)?"
32
+ r"|authorized_keys"
33
+ r"|known_hosts"
34
+ r"|.*\.(pem|key|p12|pfx|crt|cer|jks|keystore|asc|gpg)"
35
+ r")$"
36
+)
37
+
38
+SENSITIVE_PATH_COMPONENTS = frozenset({".ssh", ".aws", ".gnupg", ".kube", ".docker"})
39
+
40
+SENSITIVE_NAME_TOKENS = (
41
+ "secret", "credential", "password", "passwd",
42
+ "apikey", "accesskey", "token", "privatekey",
43
+)
44
+
45
+
46
+def is_sensitive_path(filepath: Path) -> bool:
47
+ """Heuristic denylist for files that must never be shipped to a third-party API."""
48
+ name = filepath.name
49
+ if SENSITIVE_BASENAME_REGEX.match(name):
50
+ return True
51
+ lowered_parts = {p.lower() for p in filepath.parts}
52
+ if lowered_parts & SENSITIVE_PATH_COMPONENTS:
53
+ return True
54
+ # Normalize separators so "api-key" and "api_key" both match "apikey".
55
+ lower = re.sub(r"[_\-\s.]", "", name.lower())
56
+ return any(tok in lower for tok in SENSITIVE_NAME_TOKENS)
57
+
58
+
59
+def strip_llm_wrapper(text: str) -> str:
60
+ """Strip outer ```markdown ... ``` fence when it wraps the entire output."""
61
+ m = OUTER_FENCE_REGEX.match(text)
62
+ if m:
63
+ return m.group(2)
64
+ return text
65
+
66
+from .detect import should_compress
67
+from .validate import validate
68
+
69
+MAX_RETRIES = 2
70
+
71
+
72
+# ---------- Claude Calls ----------
73
+
74
+
75
+def call_claude(prompt: str) -> str:
76
+ api_key = os.environ.get("ANTHROPIC_API_KEY")
77
+ if api_key:
78
+ try:
79
+ import anthropic
80
+
81
+ client = anthropic.Anthropic(api_key=api_key)
82
+ msg = client.messages.create(
83
+ model=os.environ.get("CAVEMAN_MODEL", "claude-sonnet-4-5"),
84
+ max_tokens=8192,
85
+ messages=[{"role": "user", "content": prompt}],
86
+ )
87
+ return strip_llm_wrapper(msg.content[0].text.strip())
88
+ except ImportError:
89
+ pass # anthropic not installed, fall back to CLI
90
+ # Fallback: use claude CLI (handles desktop auth)
91
+ try:
92
+ result = subprocess.run(
93
+ ["claude", "--print"],
94
+ input=prompt,
95
+ text=True,
96
+ capture_output=True,
97
+ check=True,
98
+ )
99
+ return strip_llm_wrapper(result.stdout.strip())
100
+ except subprocess.CalledProcessError as e:
101
+ raise RuntimeError(f"Claude call failed:\n{e.stderr}")
102
+
103
+
104
+def build_compress_prompt(original: str) -> str:
105
+ return f"""
106
+Compress this markdown into caveman format.
107
+
108
+STRICT RULES:
109
+- Do NOT modify anything inside ``` code blocks
110
+- Do NOT modify anything inside inline backticks
111
+- Preserve ALL URLs exactly
112
+- Preserve ALL headings exactly
113
+- Preserve file paths and commands
114
+- Return ONLY the compressed markdown body โ do NOT wrap the entire output in a ```markdown fence or any other fence. Inner code blocks from the original stay as-is; do not add a new outer fence around the whole file.
115
+
116
+Only compress natural language.
117
+
118
+TEXT:
119
+{original}
120
+"""
121
+
122
+
123
+def build_fix_prompt(original: str, compressed: str, errors: List[str]) -> str:
124
+ errors_str = "\n".join(f"- {e}" for e in errors)
125
+ return f"""You are fixing a caveman-compressed markdown file. Specific validation errors were found.
126
+
127
+CRITICAL RULES:
128
+- DO NOT recompress or rephrase the file
129
+- ONLY fix the listed errors โ leave everything else exactly as-is
130
+- The ORIGINAL is provided as reference only (to restore missing content)
131
+- Preserve caveman style in all untouched sections
132
+
133
+ERRORS TO FIX:
134
+{errors_str}
135
+
136
+HOW TO FIX:
137
+- Missing URL: find it in ORIGINAL, restore it exactly where it belongs in COMPRESSED
138
+- Code block mismatch: find the exact code block in ORIGINAL, restore it in COMPRESSED
139
+- Heading mismatch: restore the exact heading text from ORIGINAL into COMPRESSED
140
+- Do not touch any section not mentioned in the errors
141
+
142
+ORIGINAL (reference only):
143
+{original}
144
+
145
+COMPRESSED (fix this):
146
+{compressed}
147
+
148
+Return ONLY the fixed compressed file. No explanation.
149
+"""
150
+
151
+
152
+# ---------- Core Logic ----------
153
+
154
+
155
+def compress_file(filepath: Path) -> bool:
156
+ # Resolve and validate path
157
+ filepath = filepath.resolve()
158
+ MAX_FILE_SIZE = 500_000 # 500KB
159
+ if not filepath.exists():
160
+ raise FileNotFoundError(f"File not found: {filepath}")
161
+ if filepath.stat().st_size > MAX_FILE_SIZE:
162
+ raise ValueError(f"File too large to compress safely (max 500KB): {filepath}")
163
+
164
+ # Refuse files that look like they contain secrets or PII. Compressing ships
165
+ # the raw bytes to the Anthropic API โ a third-party boundary โ so we fail
166
+ # loudly rather than silently exfiltrate credentials or keys. Override is
167
+ # intentional: the user must rename the file if the heuristic is wrong.
168
+ if is_sensitive_path(filepath):
169
+ raise ValueError(
170
+ f"Refusing to compress {filepath}: filename looks sensitive "
171
+ "(credentials, keys, secrets, or known private paths). "
172
+ "Compression sends file contents to the Anthropic API. "
173
+ "Rename the file if this is a false positive."
174
+ )
175
+
176
+ print(f"Processing: {filepath}")
177
+
178
+ if not should_compress(filepath):
179
+ print("Skipping (not natural language)")
180
+ return False
181
+
182
+ original_text = filepath.read_text(errors="ignore")
183
+ backup_path = filepath.with_name(filepath.stem + ".original.md")
184
+
185
+ if not original_text.strip():
186
+ print("โ Refusing to compress: file is empty or whitespace-only.")
187
+ return False
188
+
189
+ # Check if backup already exists to prevent accidental overwriting
190
+ if backup_path.exists():
191
+ print(f"โ ๏ธ Backup file already exists: {backup_path}")
192
+ print("The original backup may contain important content.")
193
+ print("Aborting to prevent data loss. Please remove or rename the backup file if you want to proceed.")
194
+ return False
195
+
196
+ # Step 1: Compress
197
+ print("Compressing with Claude...")
198
+ compressed = call_claude(build_compress_prompt(original_text))
199
+
200
+ if compressed is None or not compressed.strip():
201
+ print("โ Compression aborted: Claude returned an empty response.")
202
+ print(" Original file is untouched (no backup created).")
203
+ return False
204
+
205
+ if compressed.strip() == original_text.strip():
206
+ print("โ Compression aborted: output is identical to input.")
207
+ print(" Likely causes: Claude refused, returned the prompt verbatim, or the file is")
208
+ print(" already in caveman form. Original file is untouched (no backup created).")
209
+ return False
210
+
211
+ # Save original as backup, then verify the backup readback before
212
+ # touching the input file. If the filesystem dropped bytes (encoding,
213
+ # antivirus, disk full), unlink the bad backup and abort instead of
214
+ # leaving the user with a corrupt backup + compressed primary.
215
+ backup_path.write_text(original_text)
216
+ backup_readback = backup_path.read_text(errors="ignore")
217
+ if backup_readback != original_text:
218
+ print(f"โ Backup write verification failed: {backup_path}")
219
+ print(" In-memory original differs from on-disk backup. Aborting before touching the input file.")
220
+ try:
221
+ backup_path.unlink()
222
+ except OSError:
223
+ pass
224
+ return False
225
+ filepath.write_text(compressed)
226
+
227
+ # Step 2: Validate + Retry
228
+ for attempt in range(MAX_RETRIES):
229
+ print(f"\nValidation attempt {attempt + 1}")
230
+
231
+ result = validate(backup_path, filepath)
232
+
233
+ if result.is_valid:
234
+ print("Validation passed")
235
+ break
236
+
237
+ print("โ Validation failed:")
238
+ for err in result.errors:
239
+ print(f" - {err}")
240
+
241
+ if attempt == MAX_RETRIES - 1:
242
+ # Restore original on failure
243
+ filepath.write_text(original_text)
244
+ backup_path.unlink(missing_ok=True)
245
+ print("โ Failed after retries โ original restored")
246
+ return False
247
+
248
+ print("Fixing with Claude...")
249
+ compressed = call_claude(
250
+ build_fix_prompt(original_text, compressed, result.errors)
251
+ )
252
+ filepath.write_text(compressed)
253
+
254
+ return True
ADD
.agents/skills/caveman-compress/scripts/detect.py
+121 -0
@@ -0,0 +1,121 @@
1
+#!/usr/bin/env python3
2
+"""Detect whether a file is natural language (compressible) or code/config (skip)."""
3
+
4
+import json
5
+import re
6
+from pathlib import Path
7
+
8
+# Extensions that are natural language and compressible
9
+COMPRESSIBLE_EXTENSIONS = {".md", ".txt", ".markdown", ".rst", ".typ", ".typst", ".tex"}
10
+
11
+# Extensions that are code/config and should be skipped
12
+SKIP_EXTENSIONS = {
13
+ ".py", ".js", ".ts", ".tsx", ".jsx", ".json", ".yaml", ".yml",
14
+ ".toml", ".env", ".lock", ".css", ".scss", ".html", ".xml",
15
+ ".sql", ".sh", ".bash", ".zsh", ".go", ".rs", ".java", ".c",
16
+ ".cpp", ".h", ".hpp", ".rb", ".php", ".swift", ".kt", ".lua",
17
+ ".dockerfile", ".makefile", ".csv", ".ini", ".cfg",
18
+}
19
+
20
+# Patterns that indicate a line is code
21
+CODE_PATTERNS = [
22
+ re.compile(r"^\s*(import |from .+ import |require\(|const |let |var )"),
23
+ re.compile(r"^\s*(def |class |function |async function |export )"),
24
+ re.compile(r"^\s*(if\s*\(|for\s*\(|while\s*\(|switch\s*\(|try\s*\{)"),
25
+ re.compile(r"^\s*[\}\]\);]+\s*$"), # closing braces/brackets
26
+ re.compile(r"^\s*@\w+"), # decorators/annotations
27
+ re.compile(r'^\s*"[^"]+"\s*:\s*'), # JSON-like key-value
28
+ re.compile(r"^\s*\w+\s*=\s*[{\[\(\"']"), # assignment with literal
29
+]
30
+
31
+
32
+def _is_code_line(line: str) -> bool:
33
+ """Check if a line looks like code."""
34
+ return any(p.match(line) for p in CODE_PATTERNS)
35
+
36
+
37
+def _is_json_content(text: str) -> bool:
38
+ """Check if content is valid JSON."""
39
+ try:
40
+ json.loads(text)
41
+ return True
42
+ except (json.JSONDecodeError, ValueError):
43
+ return False
44
+
45
+
46
+def _is_yaml_content(lines: list[str]) -> bool:
47
+ """Heuristic: check if content looks like YAML."""
48
+ yaml_indicators = 0
49
+ for line in lines[:30]:
50
+ stripped = line.strip()
51
+ if stripped.startswith("---"):
52
+ yaml_indicators += 1
53
+ elif re.match(r"^\w[\w\s]*:\s", stripped):
54
+ yaml_indicators += 1
55
+ elif stripped.startswith("- ") and ":" in stripped:
56
+ yaml_indicators += 1
57
+ # If most non-empty lines look like YAML
58
+ non_empty = sum(1 for l in lines[:30] if l.strip())
59
+ return non_empty > 0 and yaml_indicators / non_empty > 0.6
60
+
61
+
62
+def detect_file_type(filepath: Path) -> str:
63
+ """Classify a file as 'natural_language', 'code', 'config', or 'unknown'.
64
+
65
+ Returns:
66
+ One of: 'natural_language', 'code', 'config', 'unknown'
67
+ """
68
+ ext = filepath.suffix.lower()
69
+
70
+ # Extension-based classification
71
+ if ext in COMPRESSIBLE_EXTENSIONS:
72
+ return "natural_language"
73
+ if ext in SKIP_EXTENSIONS:
74
+ return "code" if ext not in {".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".env"} else "config"
75
+
76
+ # Extensionless files (like CLAUDE.md, TODO) โ check content
77
+ if not ext:
78
+ try:
79
+ text = filepath.read_text(errors="ignore")
80
+ except (OSError, PermissionError):
81
+ return "unknown"
82
+
83
+ lines = text.splitlines()[:50]
84
+
85
+ if _is_json_content(text[:10000]):
86
+ return "config"
87
+ if _is_yaml_content(lines):
88
+ return "config"
89
+
90
+ code_lines = sum(1 for l in lines if l.strip() and _is_code_line(l))
91
+ non_empty = sum(1 for l in lines if l.strip())
92
+ if non_empty > 0 and code_lines / non_empty > 0.4:
93
+ return "code"
94
+
95
+ return "natural_language"
96
+
97
+ return "unknown"
98
+
99
+
100
+def should_compress(filepath: Path) -> bool:
101
+ """Return True if the file is natural language and should be compressed."""
102
+ if not filepath.is_file():
103
+ return False
104
+ # Skip backup files
105
+ if filepath.name.endswith(".original.md"):
106
+ return False
107
+ return detect_file_type(filepath) == "natural_language"
108
+
109
+
110
+if __name__ == "__main__":
111
+ import sys
112
+
113
+ if len(sys.argv) < 2:
114
+ print("Usage: python detect.py <file1> [file2] ...")
115
+ sys.exit(1)
116
+
117
+ for path_str in sys.argv[1:]:
118
+ p = Path(path_str).resolve()
119
+ file_type = detect_file_type(p)
120
+ compress = should_compress(p)
121
+ print(f" {p.name:30s} type={file_type:20s} compress={compress}")
ADD
.agents/skills/caveman-compress/scripts/validate.py
+213 -0
@@ -0,0 +1,213 @@
1
+#!/usr/bin/env python3
2
+import re
3
+from collections import Counter
4
+from pathlib import Path
5
+
6
+URL_REGEX = re.compile(r"https?://[^\s)]+")
7
+FENCE_OPEN_REGEX = re.compile(r"^(\s{0,3})(`{3,}|~{3,})(.*)$")
8
+HEADING_REGEX = re.compile(r"^(#{1,6})\s+(.*)", re.MULTILINE)
9
+BULLET_REGEX = re.compile(r"^\s*[-*+]\s+", re.MULTILINE)
10
+
11
+# crude but effective path detection
12
+# Requires either a path prefix (./ ../ / or drive letter) or a slash/backslash within the match
13
+PATH_REGEX = re.compile(r"(?:\./|\.\./|/|[A-Za-z]:\\)[\w\-/\\\.]+|[\w\-\.]+[/\\][\w\-/\\\.]+")
14
+
15
+
16
+class ValidationResult:
17
+ def __init__(self):
18
+ self.is_valid = True
19
+ self.errors = []
20
+ self.warnings = []
21
+
22
+ def add_error(self, msg):
23
+ self.is_valid = False
24
+ self.errors.append(msg)
25
+
26
+ def add_warning(self, msg):
27
+ self.warnings.append(msg)
28
+
29
+
30
+def read_file(path: Path) -> str:
31
+ return path.read_text(errors="ignore")
32
+
33
+
34
+# ---------- Extractors ----------
35
+
36
+
37
+def extract_headings(text):
38
+ return [(level, title.strip()) for level, title in HEADING_REGEX.findall(text)]
39
+
40
+
41
+def extract_code_blocks(text):
42
+ """Line-based fenced code block extractor.
43
+
44
+ Handles ``` and ~~~ fences with variable length (CommonMark: closing
45
+ fence must use same char and be at least as long as opening). Supports
46
+ nested fences (e.g. an outer 4-backtick block wrapping inner 3-backtick
47
+ content).
48
+ """
49
+ blocks = []
50
+ lines = text.split("\n")
51
+ i = 0
52
+ n = len(lines)
53
+ while i < n:
54
+ m = FENCE_OPEN_REGEX.match(lines[i])
55
+ if not m:
56
+ i += 1
57
+ continue
58
+ fence_char = m.group(2)[0]
59
+ fence_len = len(m.group(2))
60
+ open_line = lines[i]
61
+ block_lines = [open_line]
62
+ i += 1
63
+ closed = False
64
+ while i < n:
65
+ close_m = FENCE_OPEN_REGEX.match(lines[i])
66
+ if (
67
+ close_m
68
+ and close_m.group(2)[0] == fence_char
69
+ and len(close_m.group(2)) >= fence_len
70
+ and close_m.group(3).strip() == ""
71
+ ):
72
+ block_lines.append(lines[i])
73
+ closed = True
74
+ i += 1
75
+ break
76
+ block_lines.append(lines[i])
77
+ i += 1
78
+ if closed:
79
+ blocks.append("\n".join(block_lines))
80
+ # Unclosed fences are silently skipped โ they indicate malformed markdown
81
+ # and including them would cause false-positive validation failures.
82
+ return blocks
83
+
84
+
85
+def extract_urls(text):
86
+ return set(URL_REGEX.findall(text))
87
+
88
+
89
+def extract_paths(text):
90
+ return set(PATH_REGEX.findall(text))
91
+
92
+
93
+def count_bullets(text):
94
+ return len(BULLET_REGEX.findall(text))
95
+
96
+
97
+def extract_inline_codes(text):
98
+ text_without_fences = re.sub(r"^```[\s\S]*?^```", "", text, flags=re.MULTILINE)
99
+ text_without_fences = re.sub(r"^~~~[\s\S]*?^~~~", "", text_without_fences, flags=re.MULTILINE)
100
+ return re.findall(r"`([^`]+)`", text_without_fences)
101
+
102
+
103
+# ---------- Validators ----------
104
+
105
+
106
+def validate_headings(orig, comp, result):
107
+ h1 = extract_headings(orig)
108
+ h2 = extract_headings(comp)
109
+
110
+ if len(h1) != len(h2):
111
+ result.add_error(f"Heading count mismatch: {len(h1)} vs {len(h2)}")
112
+
113
+ if h1 != h2:
114
+ result.add_warning("Heading text/order changed")
115
+
116
+
117
+def validate_code_blocks(orig, comp, result):
118
+ c1 = extract_code_blocks(orig)
119
+ c2 = extract_code_blocks(comp)
120
+
121
+ if c1 != c2:
122
+ result.add_error("Code blocks not preserved exactly")
123
+
124
+
125
+def validate_urls(orig, comp, result):
126
+ u1 = extract_urls(orig)
127
+ u2 = extract_urls(comp)
128
+
129
+ if u1 != u2:
130
+ result.add_error(f"URL mismatch: lost={u1 - u2}, added={u2 - u1}")
131
+
132
+
133
+def validate_paths(orig, comp, result):
134
+ p1 = extract_paths(orig)
135
+ p2 = extract_paths(comp)
136
+
137
+ if p1 != p2:
138
+ result.add_warning(f"Path mismatch: lost={p1 - p2}, added={p2 - p1}")
139
+
140
+
141
+def validate_bullets(orig, comp, result):
142
+ b1 = count_bullets(orig)
143
+ b2 = count_bullets(comp)
144
+
145
+ if b1 == 0:
146
+ return
147
+
148
+ diff = abs(b1 - b2) / b1
149
+
150
+ if diff > 0.15:
151
+ result.add_warning(f"Bullet count changed too much: {b1} -> {b2}")
152
+
153
+
154
+def validate_inline_codes(orig, comp, result):
155
+ c1 = Counter(extract_inline_codes(orig))
156
+ c2 = Counter(extract_inline_codes(comp))
157
+
158
+ if c1 != c2:
159
+ lost = set(c1.keys()) - set(c2.keys())
160
+ added = set(c2.keys()) - set(c1.keys())
161
+ for code, count in c1.items():
162
+ if code in c2 and c2[code] < count:
163
+ lost.add(f"{code} (lost {count - c2[code]} of {count} occurrences)")
164
+ if lost:
165
+ result.add_error(f"Inline code lost: {lost}")
166
+ if added:
167
+ result.add_warning(f"Inline code added: {added}")
168
+
169
+
170
+# ---------- Main ----------
171
+
172
+
173
+def validate(original_path: Path, compressed_path: Path) -> ValidationResult:
174
+ result = ValidationResult()
175
+
176
+ orig = read_file(original_path)
177
+ comp = read_file(compressed_path)
178
+
179
+ validate_headings(orig, comp, result)
180
+ validate_code_blocks(orig, comp, result)
181
+ validate_urls(orig, comp, result)
182
+ validate_paths(orig, comp, result)
183
+ validate_bullets(orig, comp, result)
184
+ validate_inline_codes(orig, comp, result)
185
+
186
+ return result
187
+
188
+
189
+# ---------- CLI ----------
190
+
191
+if __name__ == "__main__":
192
+ import sys
193
+
194
+ if len(sys.argv) != 3:
195
+ print("Usage: python validate.py <original> <compressed>")
196
+ sys.exit(1)
197
+
198
+ orig = Path(sys.argv[1]).resolve()
199
+ comp = Path(sys.argv[2]).resolve()
200
+
201
+ res = validate(orig, comp)
202
+
203
+ print(f"\nValid: {res.is_valid}")
204
+
205
+ if res.errors:
206
+ print("\nErrors:")
207
+ for e in res.errors:
208
+ print(f" - {e}")
209
+
210
+ if res.warnings:
211
+ print("\nWarnings:")
212
+ for w in res.warnings:
213
+ print(f" - {w}")
ADD
.agents/skills/caveman-help/README.md
+40 -0
@@ -0,0 +1,40 @@
1
+# caveman-help
2
+
3
+Quick-reference card. One shot, no mode change.
4
+
5
+## What it does
6
+
7
+Prints a cheat sheet of all caveman modes, sibling skills, deactivation triggers, and how to set the default mode via
8
+env var or config file. One-shot display โ does not flip the active mode, write flag files, or persist anything. Use
9
+when you forget the slash commands.
10
+
11
+## How to invoke
12
+
13
+```
14
+/caveman-help
15
+```
16
+
17
+Also triggers on "caveman help", "what caveman commands", "how do I use caveman".
18
+
19
+## Example output
20
+
21
+```
22
+Modes:
23
+ /caveman full (default)
24
+ /caveman lite lighter
25
+ /caveman ultra extreme
26
+ /caveman wenyan classical Chinese
27
+
28
+Skills:
29
+ /caveman-commit terse Conventional Commits
30
+ /caveman-review one-line PR comments
31
+ /caveman-stats session token savings
32
+
33
+Deactivate:
34
+ "stop caveman" or "normal mode"
35
+```
36
+
37
+## See also
38
+
39
+- [`SKILL.md`](./SKILL.md) โ full reference card
40
+- [Caveman README](../../README.md) โ repo overview
ADD
.agents/skills/caveman-help/SKILL.md
+62 -0
@@ -0,0 +1,62 @@
1
+---
2
+name: caveman-help
3
+description: >
4
+ Quick-reference card for all caveman modes, skills, and commands.
5
+ One-shot display, not a persistent mode. Trigger: /caveman-help,
6
+ "caveman help", "what caveman commands", "how do I use caveman".
7
+---
8
+
9
+# Caveman Help
10
+
11
+Display this reference card when invoked. One-shot โ do NOT change mode, write flag files, or persist anything. Output
12
+in caveman style.
13
+
14
+## Modes
15
+
16
+| Mode | Trigger | What change |
17
+|------------------|-------------------------|----------------------------------------------------------------------|
18
+| **Lite** | `/caveman lite` | Drop filler. Keep sentence structure. |
19
+| **Full** | `/caveman` | Drop articles, filler, pleasantries, hedging. Fragments OK. Default. |
20
+| **Ultra** | `/caveman ultra` | Extreme compression. Bare fragments. Tables over prose. |
21
+| **Wenyan-Lite** | `/caveman wenyan-lite` | Classical Chinese style, light compression. |
22
+| **Wenyan-Full** | `/caveman wenyan` | Full ๆ่จๆ. Maximum classical terseness. |
23
+| **Wenyan-Ultra** | `/caveman wenyan-ultra` | Extreme. Ancient scholar on a budget. |
24
+
25
+Mode stick until changed or session end.
26
+
27
+## Skills
28
+
29
+| Skill | Trigger | What it do |
30
+|----------------------|----------------------------|----------------------------------------------------------------|
31
+| **caveman-commit** | `/caveman-commit` | Terse commit messages. Conventional Commits. โค50 char subject. |
32
+| **caveman-review** | `/caveman-review` | One-line PR comments: `L42: bug: user null. Add guard.` |
33
+| **caveman-compress** | `/caveman-compress <file>` | Compress .md files to caveman prose. Saves ~46% input tokens. |
34
+| **caveman-help** | `/caveman-help` | This card. |
35
+
36
+## Deactivate
37
+
38
+Say "stop caveman" or "normal mode". Resume anytime with `/caveman`.
39
+
40
+## Configure Default Mode
41
+
42
+Default mode = `full`. Change it:
43
+
44
+**Environment variable** (highest priority):
45
+
46
+```bash
47
+export CAVEMAN_DEFAULT_MODE=ultra
48
+```
49
+
50
+**Config file** (`~/.config/caveman/config.json`):
51
+
52
+```json
53
+{ "defaultMode": "lite" }
54
+```
55
+
56
+Set `"off"` to disable auto-activation on session start. User can still activate manually with `/caveman`.
57
+
58
+Resolution: env var > config file > `full`.
59
+
60
+## More
61
+
62
+Full docs: https://github.com/JuliusBrussee/caveman
ADD
.agents/skills/caveman-review/README.md
+36 -0
@@ -0,0 +1,36 @@
1
+# caveman-review
2
+
3
+One-line PR comments. Location, problem, fix. No throat-clearing.
4
+
5
+## What it does
6
+
7
+Generates code review comments in `L<line>: <severity> <problem>. <fix>.` format. One line per finding. Severity emoji:
8
+๐ด bug, ๐ก risk, ๐ต nit, โ question. Drops "I noticed that...", hedging, and restating what the diff already shows. Keeps
9
+exact line numbers, backticked symbols, and concrete fixes.
10
+
11
+Auto-clarity: drops terse mode for CVE-class security findings, architectural disagreements, and onboarding contexts
12
+where the author needs the *why*. Resumes terse for the rest.
13
+
14
+Output only โ does not approve, request changes, or run linters.
15
+
16
+## How to invoke
17
+
18
+```
19
+/caveman-review
20
+```
21
+
22
+Also triggers on "review this PR", "code review", "review the diff".
23
+
24
+## Example output
25
+
26
+```
27
+L42: ๐ด bug: user can be null after .find(). Add guard before .email.
28
+L88-140: ๐ต nit: 50-line fn does 4 things. Extract validate/normalize/persist.
29
+L23: ๐ก risk: no retry on 429. Wrap in withBackoff(3).
30
+L107: โ q: why drop the cache here? Reads on next request will miss.
31
+```
32
+
33
+## See also
34
+
35
+- [`SKILL.md`](./SKILL.md) โ full LLM-facing instructions
36
+- [Caveman README](../../README.md) โ repo overview
ADD
.agents/skills/caveman-review/SKILL.md
+63 -0
@@ -0,0 +1,63 @@
1
+---
2
+name: caveman-review
3
+description: >
4
+ Ultra-compressed code review comments. Cuts noise from PR feedback while preserving
5
+ the actionable signal. Each comment is one line: location, problem, fix. Use when user
6
+ says "review this PR", "code review", "review the diff", "/review", or invokes
7
+ /caveman-review. Auto-triggers when reviewing pull requests.
8
+---
9
+
10
+Write code review comments terse and actionable. One line per finding. Location, problem, fix. No throat-clearing.
11
+
12
+## Rules
13
+
14
+**Format:** `L<line>: <problem>. <fix>.` โ or `<file>:L<line>: ...` when reviewing multi-file diffs.
15
+
16
+**Severity prefix (optional, when mixed):**
17
+
18
+- `๐ด bug:` โ broken behavior, will cause incident
19
+- `๐ก risk:` โ works but fragile (race, missing null check, swallowed error)
20
+- `๐ต nit:` โ style, naming, micro-optim. Author can ignore
21
+- `โ q:` โ genuine question, not a suggestion
22
+
23
+**Drop:**
24
+
25
+- "I noticed that...", "It seems like...", "You might want to consider..."
26
+- "This is just a suggestion but..." โ use `nit:` instead
27
+- "Great work!", "Looks good overall but..." โ say it once at the top, not per comment
28
+- Restating what the line does โ the reviewer can read the diff
29
+- Hedging ("perhaps", "maybe", "I think") โ if unsure use `q:`
30
+
31
+**Keep:**
32
+
33
+- Exact line numbers
34
+- Exact symbol/function/variable names in backticks
35
+- Concrete fix, not "consider refactoring this"
36
+- The *why* if the fix isn't obvious from the problem statement
37
+
38
+## Examples
39
+
40
+โ "I noticed that on line 42 you're not checking if the user object is null before accessing the email property. This
41
+could potentially cause a crash if the user is not found in the database. You might want to add a null check here."
42
+
43
+โ
`L42: ๐ด bug: user can be null after .find(). Add guard before .email.`
44
+
45
+โ "It looks like this function is doing a lot of things and might benefit from being broken up into smaller functions
46
+for readability."
47
+
48
+โ
`L88-140: ๐ต nit: 50-line fn does 4 things. Extract validate/normalize/persist.`
49
+
50
+โ "Have you considered what happens if the API returns a 429? I think we should probably handle that case."
51
+
52
+โ
`L23: ๐ก risk: no retry on 429. Wrap in withBackoff(3).`
53
+
54
+## Auto-Clarity
55
+
56
+Drop terse mode for: security findings (CVE-class bugs need full explanation + reference), architectural disagreements (
57
+need rationale, not just a one-liner), and onboarding contexts where the author is new and needs the "why". In those
58
+cases write a normal paragraph, then resume terse for the rest.
59
+
60
+## Boundaries
61
+
62
+Reviews only โ does not write the code fix, does not approve/request-changes, does not run linters. Output the comment(
63
+s) ready to paste into the PR. "stop caveman-review" or "normal mode": revert to verbose review style.
ADD
.agents/skills/caveman-stats/README.md
+33 -0
@@ -0,0 +1,33 @@
1
+# caveman-stats
2
+
3
+Real session token receipts. No AI estimation.
4
+
5
+## What it does
6
+
7
+Reads the current Claude Code session log directly and reports actual input/output token usage plus estimated savings
8
+versus a non-caveman baseline. Numbers come from the JSONL session log on disk โ the model itself does not compute or
9
+estimate them. Output is injected by the `caveman-mode-tracker` hook, which intercepts `/caveman-stats` and returns the
10
+formatted stats as a blocked-decision reason.
11
+
12
+Each run also writes a lifetime-savings suffix file used by the statusline badge (`โ 12.4k`).
13
+
14
+## How to invoke
15
+
16
+```
17
+/caveman-stats
18
+```
19
+
20
+## Example output
21
+
22
+```
23
+Session: 47 turns
24
+Input: 12,304 tokens
25
+Output: 3,891 tokens (caveman)
26
+Baseline: 11,247 tokens (estimated without caveman)
27
+Saved: 7,356 tokens (~65%)
28
+```
29
+
30
+## See also
31
+
32
+- [`SKILL.md`](./SKILL.md) โ hook contract and mechanics
33
+- [Caveman README](../../README.md) โ repo overview
ADD
.agents/skills/caveman-stats/SKILL.md
+12 -0
@@ -0,0 +1,12 @@
1
+---
2
+name: caveman-stats
3
+description: >
4
+ Show real token usage and estimated savings for the current session.
5
+ Reads directly from the Claude Code session log โ no AI estimation.
6
+ Triggers on /caveman-stats. Output is injected by the mode-tracker hook;
7
+ the model itself does not compute the numbers.
8
+---
9
+
10
+This skill is delivered by `hooks/caveman-stats.js` (read by `hooks/caveman-mode-tracker.js` on `/caveman-stats`). The
11
+model does not need to do anything when this skill fires โ the hook returns `decision: "block"` with the formatted stats
12
+as the reason. The user sees the numbers immediately.
ADD
.agents/skills/caveman/README.md
+52 -0
@@ -0,0 +1,52 @@
1
+# caveman
2
+
3
+Talk like smart caveman. Same brain, fewer tokens.
4
+
5
+## What it does
6
+
7
+Compress every model response to caveman-style prose. Drops articles, filler, pleasantries, and hedging. Keeps every
8
+technical detail, code block, error string, and symbol exact. Cuts ~65-75% of output tokens with full accuracy
9
+preserved. Mode persists for the whole session until changed or stopped.
10
+
11
+Six intensity levels:
12
+
13
+| Level | What change |
14
+|----------------|---------------------------------------------------------------------|
15
+| `lite` | Drop filler/hedging. Sentences stay full. Professional but tight. |
16
+| `full` | Default. Drop articles, fragments OK, short synonyms. |
17
+| `ultra` | Bare fragments. Abbreviations (DB, auth, fn). Arrows for causality. |
18
+| `wenyan-lite` | Classical Chinese register, light compression. |
19
+| `wenyan-full` | Maximum ๆ่จๆ. 80-90% character reduction. |
20
+| `wenyan-ultra` | Extreme classical compression. |
21
+
22
+Auto-clarity rule: caveman drops to normal prose for security warnings, irreversible-action confirmations, multi-step
23
+sequences where fragment ambiguity risks misread, and when user repeats a question. Resumes after the clear part.
24
+
25
+## How to invoke
26
+
27
+```
28
+/caveman # full mode (default)
29
+/caveman lite # lighter compression
30
+/caveman ultra # extreme compression
31
+/caveman wenyan # classical Chinese
32
+stop caveman # back to normal prose
33
+```
34
+
35
+## Example output
36
+
37
+Question: "Why does my React component re-render?"
38
+
39
+Normal prose:
40
+> Your component re-renders because you create a new object reference each render. Wrapping it in `useMemo` will fix the
41
+> issue.
42
+
43
+Caveman (full):
44
+> New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`.
45
+
46
+Caveman (ultra):
47
+> Inline obj prop โ new ref โ re-render. `useMemo`.
48
+
49
+## See also
50
+
51
+- [`SKILL.md`](./SKILL.md) โ full LLM-facing instructions
52
+- [Caveman README](../../README.md) โ repo overview, install, benchmarks
ADD
.agents/skills/caveman/SKILL.md
+82 -0
@@ -0,0 +1,82 @@
1
+---
2
+name: caveman
3
+description: >
4
+ Ultra-compressed communication mode. Cuts token usage ~75% by speaking like caveman
5
+ while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra,
6
+ wenyan-lite, wenyan-full, wenyan-ultra.
7
+ Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens",
8
+ "be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested.
9
+---
10
+
11
+Respond terse like smart caveman. All technical substance stay. Only fluff die.
12
+
13
+## Persistence
14
+
15
+ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active if unsure. Off only: "stop caveman" / "
16
+normal mode".
17
+
18
+Default: **full**. Switch: `/caveman lite|full|ultra`.
19
+
20
+## Rules
21
+
22
+Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy
23
+to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). Technical terms
24
+exact. Code blocks unchanged. Errors quoted exact.
25
+
26
+Pattern: `[thing] [action] [reason]. [next step].`
27
+
28
+Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..."
29
+Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"
30
+
31
+## Intensity
32
+
33
+| Level | What change |
34
+|------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
35
+| **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight |
36
+| **full** | Drop articles, fragments OK, short synonyms. Classic caveman |
37
+| **ultra** | Abbreviate prose words (DB/auth/config/req/res/fn/impl), strip conjunctions, arrows for causality (X โ Y), one word when one word enough. Code symbols, function names, API names, error strings: never abbreviate |
38
+| **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register |
39
+| **wenyan-full** | Maximum classical terseness. Fully ๆ่จๆ. 80-90% character reduction. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (ไน/ไน/็บ/ๅ
ถ) |
40
+| **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse |
41
+
42
+Example โ "Why React component re-render?"
43
+
44
+- lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`."
45
+- full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
46
+- ultra: "Inline obj prop โ new ref โ re-render. `useMemo`."
47
+- wenyan-lite: "็ตไปถ้ ป้็นช๏ผไปฅๆฏ็นชๆฐ็ๅฐ่ฑกๅ็
งๆ
ใไปฅ useMemo ๅ
ไนใ"
48
+- wenyan-full: "็ฉๅบๆฐๅ็
ง๏ผ่ด้็นชใuseMemo .Wrapไนใ"
49
+- wenyan-ultra: "ๆฐๅ็
งโ้็นชใuseMemo Wrapใ"
50
+
51
+Example โ "Explain database connection pooling."
52
+
53
+- lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake
54
+ overhead."
55
+- full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead."
56
+- ultra: "Pool = reuse DB conn. Skip handshake โ fast under load."
57
+- wenyan-full: "ๆฑ reuse open connectionใไธๆฏreqๆฐ้ใskip handshake overheadใ"
58
+- wenyan-ultra: "ๆฑ reuse connใskip handshake โ fastใ"
59
+
60
+## Auto-Clarity
61
+
62
+Drop caveman when:
63
+
64
+- Security warnings
65
+- Irreversible action confirmations
66
+- Multi-step sequences where fragment order or omitted conjunctions risk misread
67
+- Compression itself creates technical ambiguity (e.g., `"migrate table drop column backup first"` โ order unclear
68
+ without articles/conjunctions)
69
+- User asks to clarify or repeats question
70
+
71
+Resume caveman after clear part done.
72
+
73
+Example โ destructive op:
74
+> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone.
75
+> ```sql
76
+> DROP TABLE users;
77
+> ```
78
+> Caveman resume. Verify backup exist first.
79
+
80
+## Boundaries
81
+
82
+Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end.
ADD
.claude/commands/opsx/apply.md
+163 -0
@@ -0,0 +1,163 @@
1
+---
2
+name: "OPSX: Apply"
3
+description: Implement tasks from an OpenSpec change (Experimental)
4
+category: Workflow
5
+tags: [workflow, artifacts, experimental]
6
+---
7
+
8
+Implement tasks from an OpenSpec change.
9
+
10
+**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from
11
+conversation context. If vague or ambiguous you MUST prompt for available changes.
12
+
13
+**Steps**
14
+
15
+1. **Select the change**
16
+
17
+ If a name is provided, use it. Otherwise:
18
+ - Infer from conversation context if the user mentioned a change
19
+ - Auto-select if only one active change exists
20
+ - If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the
21
+ user select
22
+
23
+ Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
24
+
25
+2. **Check status to understand the schema**
26
+ ```bash
27
+ openspec status --change "<name>" --json
28
+ ```
29
+ Parse the JSON to understand:
30
+ - `schemaName`: The workflow being used (e.g., "spec-driven")
31
+ - `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints
32
+ - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
33
+
34
+3. **Get apply instructions**
35
+
36
+ ```bash
37
+ openspec instructions apply --change "<name>" --json
38
+ ```
39
+
40
+ This returns:
41
+ - `contextFiles`: artifact ID -> array of concrete file paths (varies by schema)
42
+ - Progress (total, complete, remaining)
43
+ - Task list with status
44
+ - Dynamic instruction based on current state
45
+
46
+ **Handle states:**
47
+ - If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx:continue`
48
+ - If `state: "all_done"`: congratulate, suggest archive
49
+ - Otherwise: proceed to implementation
50
+
51
+ **Workspace guard:** If status JSON reports `actionContext.mode: "workspace-planning"` and `allowedEditRoots` is
52
+ empty, explain that full workspace apply is not supported in this slice. Treat linked repos and folders as read-only
53
+ context, ask the user to select an affected area through an explicit implementation workflow, and STOP before editing
54
+ files.
55
+
56
+4. **Read context files**
57
+
58
+ Read every file path listed under `contextFiles` from the apply instructions output.
59
+ The files depend on the schema being used:
60
+ - **spec-driven**: proposal, specs, design, tasks
61
+ - Other schemas: follow the contextFiles from CLI output
62
+
63
+5. **Show current progress**
64
+
65
+ Display:
66
+ - Schema being used
67
+ - Progress: "N/M tasks complete"
68
+ - Remaining tasks overview
69
+ - Dynamic instruction from CLI
70
+
71
+6. **Implement tasks (loop until done or blocked)**
72
+
73
+ For each pending task:
74
+ - Show which task is being worked on
75
+ - Make the code changes required
76
+ - Keep changes minimal and focused
77
+ - Mark task complete in the tasks file: `- [ ]` โ `- [x]`
78
+ - Continue to next task
79
+
80
+ **Pause if:**
81
+ - Task is unclear โ ask for clarification
82
+ - Implementation reveals a design issue โ suggest updating artifacts
83
+ - Error or blocker encountered โ report and wait for guidance
84
+ - User interrupts
85
+
86
+7. **On completion or pause, show status**
87
+
88
+ Display:
89
+ - Tasks completed this session
90
+ - Overall progress: "N/M tasks complete"
91
+ - If all done: suggest archive
92
+ - If paused: explain why and wait for guidance
93
+
94
+**Output During Implementation**
95
+
96
+```
97
+## Implementing: <change-name> (schema: <schema-name>)
98
+
99
+Working on task 3/7: <task description>
100
+[...implementation happening...]
101
+โ Task complete
102
+
103
+Working on task 4/7: <task description>
104
+[...implementation happening...]
105
+โ Task complete
106
+```
107
+
108
+**Output On Completion**
109
+
110
+```
111
+## Implementation Complete
112
+
113
+**Change:** <change-name>
114
+**Schema:** <schema-name>
115
+**Progress:** 7/7 tasks complete โ
116
+
117
+### Completed This Session
118
+- [x] Task 1
119
+- [x] Task 2
120
+...
121
+
122
+All tasks complete! You can archive this change with `/opsx:archive`.
123
+```
124
+
125
+**Output On Pause (Issue Encountered)**
126
+
127
+```
128
+## Implementation Paused
129
+
130
+**Change:** <change-name>
131
+**Schema:** <schema-name>
132
+**Progress:** 4/7 tasks complete
133
+
134
+### Issue Encountered
135
+<description of the issue>
136
+
137
+**Options:**
138
+1. <option 1>
139
+2. <option 2>
140
+3. Other approach
141
+
142
+What would you like to do?
143
+```
144
+
145
+**Guardrails**
146
+
147
+- Keep going through tasks until done or blocked
148
+- Always read context files before starting (from the apply instructions output)
149
+- If task is ambiguous, pause and ask before implementing
150
+- If implementation reveals issues, pause and suggest artifact updates
151
+- Keep code changes minimal and scoped to each task
152
+- Update task checkbox immediately after completing each task
153
+- Pause on errors, blockers, or unclear requirements - don't guess
154
+- Use contextFiles from CLI output, don't assume specific file names
155
+
156
+**Fluid Workflow Integration**
157
+
158
+This skill supports the "actions on a change" model:
159
+
160
+- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved
161
+ with other actions
162
+- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked,
163
+ work fluidly
ADD
.claude/commands/opsx/archive.md
+166 -0
@@ -0,0 +1,166 @@
1
+---
2
+name: "OPSX: Archive"
3
+description: Archive a completed change in the experimental workflow
4
+category: Workflow
5
+tags: [workflow, archive, experimental]
6
+---
7
+
8
+Archive a completed change in the experimental workflow.
9
+
10
+**Input**: Optionally specify a change name after `/opsx:archive` (e.g., `/opsx:archive add-auth`). If omitted, check if
11
+it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
12
+
13
+**Steps**
14
+
15
+1. **If no change name provided, prompt for selection**
16
+
17
+ Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
18
+
19
+ Show only active changes (not already archived).
20
+ Include the schema used for each change if available.
21
+
22
+ **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
23
+
24
+2. **Check artifact completion status**
25
+
26
+ Run `openspec status --change "<name>" --json` to check artifact completion.
27
+
28
+ Parse the JSON to understand:
29
+ - `schemaName`: The workflow being used
30
+ - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
31
+ - `artifacts`: List of artifacts with their status (`done` or other)
32
+
33
+ If status reports `actionContext.mode: "workspace-planning"`, explain that workspace archive is not supported in this
34
+ slice and STOP. Do not move workspace changes into repo-local archives or edit linked repos.
35
+
36
+ **If any artifacts are not `done`:**
37
+ - Display warning listing incomplete artifacts
38
+ - Prompt user for confirmation to continue
39
+ - Proceed if user confirms
40
+
41
+3. **Check task completion status**
42
+
43
+ Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
44
+
45
+ Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
46
+
47
+ **If incomplete tasks found:**
48
+ - Display warning showing count of incomplete tasks
49
+ - Prompt user for confirmation to continue
50
+ - Proceed if user confirms
51
+
52
+ **If no tasks file exists:** Proceed without task-related warning.
53
+
54
+4. **Assess delta spec sync state**
55
+
56
+ Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed
57
+ without sync prompt.
58
+
59
+ **If delta specs exist:**
60
+ - Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
61
+ - Determine what changes would be applied (adds, modifications, removals, renames)
62
+ - Show a combined summary before prompting
63
+
64
+ **Prompt options:**
65
+ - If changes needed: "Sync now (recommended)", "Archive without syncing"
66
+ - If already synced: "Archive now", "Sync anyway", "Cancel"
67
+
68
+ If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke
69
+ openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to
70
+ archive regardless of choice.
71
+
72
+5. **Perform the archive**
73
+
74
+ Create an `archive` directory under `planningHome.changesDir` if it doesn't exist:
75
+ ```bash
76
+ mkdir -p "<planningHome.changesDir>/archive"
77
+ ```
78
+
79
+ Generate target name using current date: `YYYY-MM-DD-<change-name>`
80
+
81
+ **Check if target already exists:**
82
+ - If yes: Fail with error, suggest renaming existing archive or using different date
83
+ - If no: Move `changeRoot` to the archive directory
84
+
85
+ ```bash
86
+ mv "<changeRoot>" "<planningHome.changesDir>/archive/YYYY-MM-DD-<name>"
87
+ ```
88
+
89
+6. **Display summary**
90
+
91
+ Show archive completion summary including:
92
+ - Change name
93
+ - Schema that was used
94
+ - Archive location
95
+ - Spec sync status (synced / sync skipped / no delta specs)
96
+ - Note about any warnings (incomplete artifacts/tasks)
97
+
98
+**Output On Success**
99
+
100
+```
101
+## Archive Complete
102
+
103
+**Change:** <change-name>
104
+**Schema:** <schema-name>
105
+**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
106
+**Specs:** โ Synced to main specs
107
+
108
+All artifacts complete. All tasks complete.
109
+```
110
+
111
+**Output On Success (No Delta Specs)**
112
+
113
+```
114
+## Archive Complete
115
+
116
+**Change:** <change-name>
117
+**Schema:** <schema-name>
118
+**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
119
+**Specs:** No delta specs
120
+
121
+All artifacts complete. All tasks complete.
122
+```
123
+
124
+**Output On Success With Warnings**
125
+
126
+```
127
+## Archive Complete (with warnings)
128
+
129
+**Change:** <change-name>
130
+**Schema:** <schema-name>
131
+**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
132
+**Specs:** Sync skipped (user chose to skip)
133
+
134
+**Warnings:**
135
+- Archived with 2 incomplete artifacts
136
+- Archived with 3 incomplete tasks
137
+- Delta spec sync was skipped (user chose to skip)
138
+
139
+Review the archive if this was not intentional.
140
+```
141
+
142
+**Output On Error (Archive Exists)**
143
+
144
+```
145
+## Archive Failed
146
+
147
+**Change:** <change-name>
148
+**Target:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
149
+
150
+Target archive directory already exists.
151
+
152
+**Options:**
153
+1. Rename the existing archive
154
+2. Delete the existing archive if it's a duplicate
155
+3. Wait until a different date to archive
156
+```
157
+
158
+**Guardrails**
159
+
160
+- Always prompt for change selection if not provided
161
+- Use artifact graph (openspec status --json) for completion checking
162
+- Don't block archive on warnings - just inform and confirm
163
+- Preserve .openspec.yaml when moving to archive (it moves with the directory)
164
+- Show clear summary of what happened
165
+- If sync is requested, use the Skill tool to invoke `openspec-sync-specs` (agent-driven)
166
+- If delta specs exist, always run the sync assessment and show the combined summary before prompting
ADD
.claude/commands/opsx/explore.md
+186 -0
@@ -0,0 +1,186 @@
1
+---
2
+name: "OPSX: Explore"
3
+description: "Enter explore mode - think through ideas, investigate problems, clarify requirements"
4
+category: Workflow
5
+tags: [workflow, explore, experimental, thinking]
6
+---
7
+
8
+Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
9
+
10
+**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the
11
+codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them
12
+to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs)
13
+if the user asksโthat's capturing thinking, not implementing.
14
+
15
+**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a
16
+thinking partner helping the user explore.
17
+
18
+**Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be:
19
+
20
+- A vague idea: "real-time collaboration"
21
+- A specific problem: "the auth system is getting unwieldy"
22
+- A change name: "add-dark-mode" (to explore in context of that change)
23
+- A comparison: "postgres vs sqlite for this"
24
+- Nothing (just enter explore mode)
25
+
26
+---
27
+
28
+## The Stance
29
+
30
+- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
31
+- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates.
32
+ Don't funnel them through a single path of questions.
33
+- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
34
+- **Adaptive** - Follow interesting threads, pivot when new information emerges
35
+- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
36
+- **Grounded** - Explore the actual codebase when relevant, don't just theorize
37
+
38
+---
39
+
40
+## What You Might Do
41
+
42
+Depending on what the user brings, you might:
43
+
44
+**Explore the problem space**
45
+
46
+- Ask clarifying questions that emerge from what they said
47
+- Challenge assumptions
48
+- Reframe the problem
49
+- Find analogies
50
+
51
+**Investigate the codebase**
52
+
53
+- Map existing architecture relevant to the discussion
54
+- Find integration points
55
+- Identify patterns already in use
56
+- Surface hidden complexity
57
+
58
+**Compare options**
59
+
60
+- Brainstorm multiple approaches
61
+- Build comparison tables
62
+- Sketch tradeoffs
63
+- Recommend a path (if asked)
64
+
65
+**Visualize**
66
+
67
+```
68
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
69
+โ Use ASCII diagrams liberally โ
70
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
71
+โ โ
72
+โ โโโโโโโโโโ โโโโโโโโโโ โ
73
+โ โ State โโโโโโโโโโถโ State โ โ
74
+โ โ A โ โ B โ โ
75
+โ โโโโโโโโโโ โโโโโโโโโโ โ
76
+โ โ
77
+โ System diagrams, state machines, โ
78
+โ data flows, architecture sketches, โ
79
+โ dependency graphs, comparison tables โ
80
+โ โ
81
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
82
+```
83
+
84
+**Surface risks and unknowns**
85
+
86
+- Identify what could go wrong
87
+- Find gaps in understanding
88
+- Suggest spikes or investigations
89
+
90
+---
91
+
92
+## OpenSpec Awareness
93
+
94
+You have full context of the OpenSpec system. Use it naturally, don't force it.
95
+
96
+### Check for context
97
+
98
+At the start, quickly check what exists:
99
+
100
+```bash
101
+openspec list --json
102
+```
103
+
104
+This tells you:
105
+
106
+- If there are active changes
107
+- Their names, schemas, and status
108
+- What the user might be working on
109
+
110
+If the user mentioned a specific change name, read its artifacts for context.
111
+
112
+### When no change exists
113
+
114
+Think freely. When insights crystallize, you might offer:
115
+
116
+- "This feels solid enough to start a change. Want me to create a proposal?"
117
+- Or keep exploring - no pressure to formalize
118
+
119
+### When a change exists
120
+
121
+If the user mentions a change or you detect one is relevant:
122
+
123
+1. **Resolve and read existing artifacts for context**
124
+ - Run `openspec status --change "<name>" --json`.
125
+ - Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON.
126
+ - Read existing files from `artifactPaths.<artifact>.existingOutputPaths`.
127
+
128
+2. **Reference them naturally in conversation**
129
+ - "Your design mentions using Redis, but we just realized SQLite fits better..."
130
+ - "The proposal scopes this to premium users, but we're now thinking everyone..."
131
+
132
+3. **Offer to capture when decisions are made**
133
+
134
+ | Insight Type | Where to Capture |
135
+ |----------------------------|--------------------------------|
136
+ | New requirement discovered | `specs/<capability>/spec.md` |
137
+ | Requirement changed | `specs/<capability>/spec.md` |
138
+ | Design decision made | `design.md` |
139
+ | Scope changed | `proposal.md` |
140
+ | New work identified | `tasks.md` |
141
+ | Assumption invalidated | Relevant artifact |
142
+
143
+ Example offers:
144
+ - "That's a design decision. Capture it in design.md?"
145
+ - "This is a new requirement. Add it to specs?"
146
+ - "This changes scope. Update the proposal?"
147
+
148
+4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
149
+
150
+---
151
+
152
+## What You Don't Have To Do
153
+
154
+- Follow a script
155
+- Ask the same questions every time
156
+- Produce a specific artifact
157
+- Reach a conclusion
158
+- Stay on topic if a tangent is valuable
159
+- Be brief (this is thinking time)
160
+
161
+---
162
+
163
+## Ending Discovery
164
+
165
+There's no required ending. Discovery might:
166
+
167
+- **Flow into a proposal**: "Ready to start? I can create a change proposal."
168
+- **Result in artifact updates**: "Updated design.md with these decisions"
169
+- **Just provide clarity**: User has what they need, moves on
170
+- **Continue later**: "We can pick this up anytime"
171
+
172
+When things crystallize, you might offer a summary - but it's optional. Sometimes the thinking IS the value.
173
+
174
+---
175
+
176
+## Guardrails
177
+
178
+- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application
179
+ code is not.
180
+- **Don't fake understanding** - If something is unclear, dig deeper
181
+- **Don't rush** - Discovery is thinking time, not task time
182
+- **Don't force structure** - Let patterns emerge naturally
183
+- **Don't auto-capture** - Offer to save insights, don't just do it
184
+- **Do visualize** - A good diagram is worth many paragraphs
185
+- **Do explore the codebase** - Ground discussions in reality
186
+- **Do question assumptions** - Including the user's and your own
ADD
.claude/commands/opsx/propose.md
+112 -0
@@ -0,0 +1,112 @@
1
+---
2
+name: "OPSX: Propose"
3
+description: Propose a new change - create it and generate all artifacts in one step
4
+category: Workflow
5
+tags: [workflow, artifacts, experimental]
6
+---
7
+
8
+Propose a new change - create the change and generate all artifacts in one step.
9
+
10
+I'll create a change with artifacts:
11
+
12
+- proposal.md (what & why)
13
+- design.md (how)
14
+- tasks.md (implementation steps)
15
+
16
+When ready to implement, run /opsx:apply
17
+
18
+---
19
+
20
+**Input**: The argument after `/opsx:propose` is the change name (kebab-case), OR a description of what the user wants
21
+to build.
22
+
23
+**Steps**
24
+
25
+1. **If no input provided, ask what they want to build**
26
+
27
+ Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
28
+ > "What change do you want to work on? Describe what you want to build or fix."
29
+
30
+ From their description, derive a kebab-case name (e.g., "add user authentication" โ `add-user-auth`).
31
+
32
+ **IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
33
+
34
+2. **Create the change directory**
35
+ ```bash
36
+ openspec new change "<name>"
37
+ ```
38
+ This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`.
39
+
40
+3. **Get the artifact build order**
41
+ ```bash
42
+ openspec status --change "<name>" --json
43
+ ```
44
+ Parse the JSON to get:
45
+ - `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
46
+ - `artifacts`: list of all artifacts with their status and dependencies
47
+ - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of
48
+ assuming repo-local paths.
49
+
50
+4. **Create artifacts in sequence until apply-ready**
51
+
52
+ Use the **TodoWrite tool** to track progress through the artifacts.
53
+
54
+ Loop through artifacts in dependency order (artifacts with no pending dependencies first):
55
+
56
+ a. **For each artifact that is `ready` (dependencies satisfied)**:
57
+ - Get instructions:
58
+ ```bash
59
+ openspec instructions <artifact-id> --change "<name>" --json
60
+ ```
61
+ - The instructions JSON includes:
62
+ - `context`: Project background (constraints for you - do NOT include in output)
63
+ - `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
64
+ - `template`: The structure to use for your output file
65
+ - `instruction`: Schema-specific guidance for this artifact type
66
+ - `resolvedOutputPath`: Resolved path or pattern to write the artifact
67
+ - `dependencies`: Completed artifacts to read for context
68
+ - Read any completed dependency files for context
69
+ - Create the artifact file using `template` as the structure and write it to `resolvedOutputPath`
70
+ - Apply `context` and `rules` as constraints - but do NOT copy them into the file
71
+ - Show brief progress: "Created <artifact-id>"
72
+
73
+ b. **Continue until all `applyRequires` artifacts are complete**
74
+ - After creating each artifact, re-run `openspec status --change "<name>" --json`
75
+ - Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
76
+ - Stop when all `applyRequires` artifacts are done
77
+
78
+ c. **If an artifact requires user input** (unclear context):
79
+ - Use **AskUserQuestion tool** to clarify
80
+ - Then continue with creation
81
+
82
+5. **Show final status**
83
+ ```bash
84
+ openspec status --change "<name>"
85
+ ```
86
+
87
+**Output**
88
+
89
+After completing all artifacts, summarize:
90
+
91
+- Change name and location
92
+- List of artifacts created with brief descriptions
93
+- What's ready: "All artifacts created! Ready for implementation."
94
+- Prompt: "Run `/opsx:apply` to start implementing."
95
+
96
+**Artifact Creation Guidelines**
97
+
98
+- Follow the `instruction` field from `openspec instructions` for each artifact type
99
+- The schema defines what each artifact should contain - follow it
100
+- Read dependency artifacts for context before creating new ones
101
+- Use `template` as the structure for your output file - fill in its sections
102
+- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
103
+ - Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
104
+ - These guide what you write, but should never appear in the output
105
+
106
+**Guardrails**
107
+
108
+- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
109
+- Always read dependency artifacts before creating a new one
110
+- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
111
+- If a change with that name already exists, ask if user wants to continue it or create a new one
112
+- Verify each artifact file exists after writing before proceeding to next
ADD
.claude/commands/opsx/sync.md
+148 -0
@@ -0,0 +1,148 @@
1
+---
2
+name: "OPSX: Sync"
3
+description: Sync delta specs from a change to main specs
4
+category: Workflow
5
+tags: [workflow, specs, experimental]
6
+---
7
+
8
+Sync delta specs from a change to main specs.
9
+
10
+This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes.
11
+This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
12
+
13
+**Input**: Optionally specify a change name after `/opsx:sync` (e.g., `/opsx:sync add-auth`). If omitted, check if it
14
+can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
15
+
16
+**Steps**
17
+
18
+1. **If no change name provided, prompt for selection**
19
+
20
+ Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
21
+
22
+ Show changes that have delta specs (under `specs/` directory).
23
+
24
+ **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
25
+
26
+2. **Resolve change context**
27
+
28
+ Run:
29
+ ```bash
30
+ openspec status --change "<name>" --json
31
+ ```
32
+
33
+ If status reports `actionContext.mode: "workspace-planning"`, explain that workspace spec sync is not supported in
34
+ this slice and STOP. Do not fall back to repo-local paths or edit linked repos.
35
+
36
+3. **Find delta specs**
37
+
38
+ Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the list of delta spec files.
39
+
40
+ Each delta spec file contains sections like:
41
+ - `## ADDED Requirements` - New requirements to add
42
+ - `## MODIFIED Requirements` - Changes to existing requirements
43
+ - `## REMOVED Requirements` - Requirements to remove
44
+ - `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format)
45
+
46
+ If no delta specs found, inform user and stop.
47
+
48
+4. **For each delta spec, apply changes to main specs**
49
+
50
+ For each repo-local capability delta spec path returned by the CLI:
51
+
52
+ a. **Read the delta spec** to understand the intended changes
53
+
54
+ b. **Read the main spec** at `openspec/specs/<capability>/spec.md` (may not exist yet)
55
+
56
+ c. **Apply changes intelligently**:
57
+
58
+ **ADDED Requirements:**
59
+ - If requirement doesn't exist in main spec โ add it
60
+ - If requirement already exists โ update it to match (treat as implicit MODIFIED)
61
+
62
+ **MODIFIED Requirements:**
63
+ - Find the requirement in main spec
64
+ - Apply the changes - this can be:
65
+ - Adding new scenarios (don't need to copy existing ones)
66
+ - Modifying existing scenarios
67
+ - Changing the requirement description
68
+ - Preserve scenarios/content not mentioned in the delta
69
+
70
+ **REMOVED Requirements:**
71
+ - Remove the entire requirement block from main spec
72
+
73
+ **RENAMED Requirements:**
74
+ - Find the FROM requirement, rename to TO
75
+
76
+ d. **Create new main spec** if capability doesn't exist yet:
77
+ - Create `openspec/specs/<capability>/spec.md`
78
+ - Add Purpose section (can be brief, mark as TBD)
79
+ - Add Requirements section with the ADDED requirements
80
+
81
+5. **Show summary**
82
+
83
+ After applying all changes, summarize:
84
+ - Which capabilities were updated
85
+ - What changes were made (requirements added/modified/removed/renamed)
86
+
87
+**Delta Spec Format Reference**
88
+
89
+```markdown
90
+## ADDED Requirements
91
+
92
+### Requirement: New Feature
93
+The system SHALL do something new.
94
+
95
+#### Scenario: Basic case
96
+- **WHEN** user does X
97
+- **THEN** system does Y
98
+
99
+## MODIFIED Requirements
100
+
101
+### Requirement: Existing Feature
102
+#### Scenario: New scenario to add
103
+- **WHEN** user does A
104
+- **THEN** system does B
105
+
106
+## REMOVED Requirements
107
+
108
+### Requirement: Deprecated Feature
109
+
110
+## RENAMED Requirements
111
+
112
+- FROM: `### Requirement: Old Name`
113
+- TO: `### Requirement: New Name`
114
+```
115
+
116
+**Key Principle: Intelligent Merging**
117
+
118
+Unlike programmatic merging, you can apply **partial updates**:
119
+
120
+- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios
121
+- The delta represents *intent*, not a wholesale replacement
122
+- Use your judgment to merge changes sensibly
123
+
124
+**Output On Success**
125
+
126
+```
127
+## Specs Synced: <change-name>
128
+
129
+Updated main specs:
130
+
131
+**<capability-1>**:
132
+- Added requirement: "New Feature"
133
+- Modified requirement: "Existing Feature" (added 1 scenario)
134
+
135
+**<capability-2>**:
136
+- Created new spec file
137
+- Added requirement: "Another Feature"
138
+
139
+Main specs are now updated. The change remains active - archive when implementation is complete.
140
+```
141
+
142
+**Guardrails**
143
+
144
+- Read both delta and main specs before making changes
145
+- Preserve existing content not mentioned in delta
146
+- If something is unclear, ask for clarification
147
+- Show what you're changing as you go
148
+- The operation should be idempotent - running twice should give same result
ADD
.claude/skills/cavecrew
+1 -0
@@ -0,0 +1 @@
1
+../../.agents/skills/cavecrew
ADD
.claude/skills/caveman
+1 -0
@@ -0,0 +1 @@
1
+../../.agents/skills/caveman
ADD
.claude/skills/caveman-commit
+1 -0
@@ -0,0 +1 @@
1
+../../.agents/skills/caveman-commit
ADD
.claude/skills/caveman-compress
+1 -0
@@ -0,0 +1 @@
1
+../../.agents/skills/caveman-compress
ADD
.claude/skills/caveman-help
+1 -0
@@ -0,0 +1 @@
1
+../../.agents/skills/caveman-help
ADD
.claude/skills/caveman-review
+1 -0
@@ -0,0 +1 @@
1
+../../.agents/skills/caveman-review
ADD
.claude/skills/caveman-stats
+1 -0
@@ -0,0 +1 @@
1
+../../.agents/skills/caveman-stats
ADD
.claude/skills/openspec-apply-change/SKILL.md
+168 -0
@@ -0,0 +1,168 @@
1
+---
2
+name: openspec-apply-change
3
+description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
4
+license: MIT
5
+compatibility: Requires openspec CLI.
6
+metadata:
7
+ author: openspec
8
+ version: "1.0"
9
+ generatedBy: "1.4.1"
10
+---
11
+
12
+Implement tasks from an OpenSpec change.
13
+
14
+**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague
15
+or ambiguous you MUST prompt for available changes.
16
+
17
+**Steps**
18
+
19
+1. **Select the change**
20
+
21
+ If a name is provided, use it. Otherwise:
22
+ - Infer from conversation context if the user mentioned a change
23
+ - Auto-select if only one active change exists
24
+ - If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the
25
+ user select
26
+
27
+ Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
28
+
29
+2. **Check status to understand the schema**
30
+ ```bash
31
+ openspec status --change "<name>" --json
32
+ ```
33
+ Parse the JSON to understand:
34
+ - `schemaName`: The workflow being used (e.g., "spec-driven")
35
+ - `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints
36
+ - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
37
+
38
+3. **Get apply instructions**
39
+
40
+ ```bash
41
+ openspec instructions apply --change "<name>" --json
42
+ ```
43
+
44
+ This returns:
45
+ - `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be
46
+ proposal/specs/design/tasks or spec/tests/implementation/docs)
47
+ - Progress (total, complete, remaining)
48
+ - Task list with status
49
+ - Dynamic instruction based on current state
50
+
51
+ **Handle states:**
52
+ - If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change
53
+ - If `state: "all_done"`: congratulate, suggest archive
54
+ - Otherwise: proceed to implementation
55
+
56
+ **Workspace guard:** If status JSON reports `actionContext.mode: "workspace-planning"` and `allowedEditRoots` is
57
+ empty, explain that full workspace apply is not supported in this slice. Treat linked repos and folders as read-only
58
+ context, ask the user to select an affected area through an explicit implementation workflow, and STOP before editing
59
+ files.
60
+
61
+4. **Read context files**
62
+
63
+ Read every file path listed under `contextFiles` from the apply instructions output.
64
+ The files depend on the schema being used:
65
+ - **spec-driven**: proposal, specs, design, tasks
66
+ - Other schemas: follow the contextFiles from CLI output
67
+
68
+5. **Show current progress**
69
+
70
+ Display:
71
+ - Schema being used
72
+ - Progress: "N/M tasks complete"
73
+ - Remaining tasks overview
74
+ - Dynamic instruction from CLI
75
+
76
+6. **Implement tasks (loop until done or blocked)**
77
+
78
+ For each pending task:
79
+ - Show which task is being worked on
80
+ - Make the code changes required
81
+ - Keep changes minimal and focused
82
+ - Mark task complete in the tasks file: `- [ ]` โ `- [x]`
83
+ - Continue to next task
84
+
85
+ **Pause if:**
86
+ - Task is unclear โ ask for clarification
87
+ - Implementation reveals a design issue โ suggest updating artifacts
88
+ - Error or blocker encountered โ report and wait for guidance
89
+ - User interrupts
90
+
91
+7. **On completion or pause, show status**
92
+
93
+ Display:
94
+ - Tasks completed this session
95
+ - Overall progress: "N/M tasks complete"
96
+ - If all done: suggest archive
97
+ - If paused: explain why and wait for guidance
98
+
99
+**Output During Implementation**
100
+
101
+```
102
+## Implementing: <change-name> (schema: <schema-name>)
103
+
104
+Working on task 3/7: <task description>
105
+[...implementation happening...]
106
+โ Task complete
107
+
108
+Working on task 4/7: <task description>
109
+[...implementation happening...]
110
+โ Task complete
111
+```
112
+
113
+**Output On Completion**
114
+
115
+```
116
+## Implementation Complete
117
+
118
+**Change:** <change-name>
119
+**Schema:** <schema-name>
120
+**Progress:** 7/7 tasks complete โ
121
+
122
+### Completed This Session
123
+- [x] Task 1
124
+- [x] Task 2
125
+...
126
+
127
+All tasks complete! Ready to archive this change.
128
+```
129
+
130
+**Output On Pause (Issue Encountered)**
131
+
132
+```
133
+## Implementation Paused
134
+
135
+**Change:** <change-name>
136
+**Schema:** <schema-name>
137
+**Progress:** 4/7 tasks complete
138
+
139
+### Issue Encountered
140
+<description of the issue>
141
+
142
+**Options:**
143
+1. <option 1>
144
+2. <option 2>
145
+3. Other approach
146
+
147
+What would you like to do?
148
+```
149
+
150
+**Guardrails**
151
+
152
+- Keep going through tasks until done or blocked
153
+- Always read context files before starting (from the apply instructions output)
154
+- If task is ambiguous, pause and ask before implementing
155
+- If implementation reveals issues, pause and suggest artifact updates
156
+- Keep code changes minimal and scoped to each task
157
+- Update task checkbox immediately after completing each task
158
+- Pause on errors, blockers, or unclear requirements - don't guess
159
+- Use contextFiles from CLI output, don't assume specific file names
160
+
161
+**Fluid Workflow Integration**
162
+
163
+This skill supports the "actions on a change" model:
164
+
165
+- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved
166
+ with other actions
167
+- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked,
168
+ work fluidly
ADD
.claude/skills/openspec-archive-change/SKILL.md
+123 -0
@@ -0,0 +1,123 @@
1
+---
2
+name: openspec-archive-change
3
+description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
4
+license: MIT
5
+compatibility: Requires openspec CLI.
6
+metadata:
7
+ author: openspec
8
+ version: "1.0"
9
+ generatedBy: "1.4.1"
10
+---
11
+
12
+Archive a completed change in the experimental workflow.
13
+
14
+**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague
15
+or ambiguous you MUST prompt for available changes.
16
+
17
+**Steps**
18
+
19
+1. **If no change name provided, prompt for selection**
20
+
21
+ Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
22
+
23
+ Show only active changes (not already archived).
24
+ Include the schema used for each change if available.
25
+
26
+ **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
27
+
28
+2. **Check artifact completion status**
29
+
30
+ Run `openspec status --change "<name>" --json` to check artifact completion.
31
+
32
+ Parse the JSON to understand:
33
+ - `schemaName`: The workflow being used
34
+ - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
35
+ - `artifacts`: List of artifacts with their status (`done` or other)
36
+
37
+ If status reports `actionContext.mode: "workspace-planning"`, explain that workspace archive is not supported in this
38
+ slice and STOP. Do not move workspace changes into repo-local archives or edit linked repos.
39
+
40
+ **If any artifacts are not `done`:**
41
+ - Display warning listing incomplete artifacts
42
+ - Use **AskUserQuestion tool** to confirm user wants to proceed
43
+ - Proceed if user confirms
44
+
45
+3. **Check task completion status**
46
+
47
+ Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
48
+
49
+ Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
50
+
51
+ **If incomplete tasks found:**
52
+ - Display warning showing count of incomplete tasks
53
+ - Use **AskUserQuestion tool** to confirm user wants to proceed
54
+ - Proceed if user confirms
55
+
56
+ **If no tasks file exists:** Proceed without task-related warning.
57
+
58
+4. **Assess delta spec sync state**
59
+
60
+ Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed
61
+ without sync prompt.
62
+
63
+ **If delta specs exist:**
64
+ - Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
65
+ - Determine what changes would be applied (adds, modifications, removals, renames)
66
+ - Show a combined summary before prompting
67
+
68
+ **Prompt options:**
69
+ - If changes needed: "Sync now (recommended)", "Archive without syncing"
70
+ - If already synced: "Archive now", "Sync anyway", "Cancel"
71
+
72
+ If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke
73
+ openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to
74
+ archive regardless of choice.
75
+
76
+5. **Perform the archive**
77
+
78
+ Create an `archive` directory under `planningHome.changesDir` if it doesn't exist:
79
+ ```bash
80
+ mkdir -p "<planningHome.changesDir>/archive"
81
+ ```
82
+
83
+ Generate target name using current date: `YYYY-MM-DD-<change-name>`
84
+
85
+ **Check if target already exists:**
86
+ - If yes: Fail with error, suggest renaming existing archive or using different date
87
+ - If no: Move `changeRoot` to the archive directory
88
+
89
+ ```bash
90
+ mv "<changeRoot>" "<planningHome.changesDir>/archive/YYYY-MM-DD-<name>"
91
+ ```
92
+
93
+6. **Display summary**
94
+
95
+ Show archive completion summary including:
96
+ - Change name
97
+ - Schema that was used
98
+ - Archive location
99
+ - Whether specs were synced (if applicable)
100
+ - Note about any warnings (incomplete artifacts/tasks)
101
+
102
+**Output On Success**
103
+
104
+```
105
+## Archive Complete
106
+
107
+**Change:** <change-name>
108
+**Schema:** <schema-name>
109
+**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
110
+**Specs:** โ Synced to main specs (or "No delta specs" or "Sync skipped")
111
+
112
+All artifacts complete. All tasks complete.
113
+```
114
+
115
+**Guardrails**
116
+
117
+- Always prompt for change selection if not provided
118
+- Use artifact graph (openspec status --json) for completion checking
119
+- Don't block archive on warnings - just inform and confirm
120
+- Preserve .openspec.yaml when moving to archive (it moves with the directory)
121
+- Show clear summary of what happened
122
+- If sync is requested, use openspec-sync-specs approach (agent-driven)
123
+- If delta specs exist, always run the sync assessment and show the combined summary before prompting
ADD
.claude/skills/openspec-explore/SKILL.md
+304 -0
@@ -0,0 +1,304 @@
1
+---
2
+name: openspec-explore
3
+description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
4
+license: MIT
5
+compatibility: Requires openspec CLI.
6
+metadata:
7
+ author: openspec
8
+ version: "1.0"
9
+ generatedBy: "1.4.1"
10
+---
11
+
12
+Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
13
+
14
+**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the
15
+codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them
16
+to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs)
17
+if the user asksโthat's capturing thinking, not implementing.
18
+
19
+**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a
20
+thinking partner helping the user explore.
21
+
22
+---
23
+
24
+## The Stance
25
+
26
+- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
27
+- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates.
28
+ Don't funnel them through a single path of questions.
29
+- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
30
+- **Adaptive** - Follow interesting threads, pivot when new information emerges
31
+- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
32
+- **Grounded** - Explore the actual codebase when relevant, don't just theorize
33
+
34
+---
35
+
36
+## What You Might Do
37
+
38
+Depending on what the user brings, you might:
39
+
40
+**Explore the problem space**
41
+
42
+- Ask clarifying questions that emerge from what they said
43
+- Challenge assumptions
44
+- Reframe the problem
45
+- Find analogies
46
+
47
+**Investigate the codebase**
48
+
49
+- Map existing architecture relevant to the discussion
50
+- Find integration points
51
+- Identify patterns already in use
52
+- Surface hidden complexity
53
+
54
+**Compare options**
55
+
56
+- Brainstorm multiple approaches
57
+- Build comparison tables
58
+- Sketch tradeoffs
59
+- Recommend a path (if asked)
60
+
61
+**Visualize**
62
+
63
+```
64
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
65
+โ Use ASCII diagrams liberally โ
66
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
67
+โ โ
68
+โ โโโโโโโโโโ โโโโโโโโโโ โ
69
+โ โ State โโโโโโโโโโถโ State โ โ
70
+โ โ A โ โ B โ โ
71
+โ โโโโโโโโโโ โโโโโโโโโโ โ
72
+โ โ
73
+โ System diagrams, state machines, โ
74
+โ data flows, architecture sketches, โ
75
+โ dependency graphs, comparison tables โ
76
+โ โ
77
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
78
+```
79
+
80
+**Surface risks and unknowns**
81
+
82
+- Identify what could go wrong
83
+- Find gaps in understanding
84
+- Suggest spikes or investigations
85
+
86
+---
87
+
88
+## OpenSpec Awareness
89
+
90
+You have full context of the OpenSpec system. Use it naturally, don't force it.
91
+
92
+### Check for context
93
+
94
+At the start, quickly check what exists:
95
+
96
+```bash
97
+openspec list --json
98
+```
99
+
100
+This tells you:
101
+
102
+- If there are active changes
103
+- Their names, schemas, and status
104
+- What the user might be working on
105
+
106
+### When no change exists
107
+
108
+Think freely. When insights crystallize, you might offer:
109
+
110
+- "This feels solid enough to start a change. Want me to create a proposal?"
111
+- Or keep exploring - no pressure to formalize
112
+
113
+### When a change exists
114
+
115
+If the user mentions a change or you detect one is relevant:
116
+
117
+1. **Resolve and read existing artifacts for context**
118
+ - Run `openspec status --change "<name>" --json`.
119
+ - Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON.
120
+ - Read existing files from `artifactPaths.<artifact>.existingOutputPaths`.
121
+
122
+2. **Reference them naturally in conversation**
123
+ - "Your design mentions using Redis, but we just realized SQLite fits better..."
124
+ - "The proposal scopes this to premium users, but we're now thinking everyone..."
125
+
126
+3. **Offer to capture when decisions are made**
127
+
128
+ | Insight Type | Where to Capture |
129
+ |----------------------------|--------------------------------|
130
+ | New requirement discovered | `specs/<capability>/spec.md` |
131
+ | Requirement changed | `specs/<capability>/spec.md` |
132
+ | Design decision made | `design.md` |
133
+ | Scope changed | `proposal.md` |
134
+ | New work identified | `tasks.md` |
135
+ | Assumption invalidated | Relevant artifact |
136
+
137
+ Example offers:
138
+ - "That's a design decision. Capture it in design.md?"
139
+ - "This is a new requirement. Add it to specs?"
140
+ - "This changes scope. Update the proposal?"
141
+
142
+4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
143
+
144
+---
145
+
146
+## What You Don't Have To Do
147
+
148
+- Follow a script
149
+- Ask the same questions every time
150
+- Produce a specific artifact
151
+- Reach a conclusion
152
+- Stay on topic if a tangent is valuable
153
+- Be brief (this is thinking time)
154
+
155
+---
156
+
157
+## Handling Different Entry Points
158
+
159
+**User brings a vague idea:**
160
+
161
+```
162
+User: I'm thinking about adding real-time collaboration
163
+
164
+You: Real-time collab is a big space. Let me think about this...
165
+
166
+ COLLABORATION SPECTRUM
167
+ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
168
+
169
+ Awareness Coordination Sync
170
+ โ โ โ
171
+ โผ โผ โผ
172
+ โโโโโโโโโโ โโโโโโโโโโ โโโโโโโโโโ
173
+ โPresenceโ โCursors โ โ CRDT โ
174
+ โ "3 โ โ Multi โ โConflictโ
175
+ โonline" โ โ select โ โ free โ
176
+ โโโโโโโโโโ โโโโโโโโโโ โโโโโโโโโโ
177
+ โ โ โ
178
+ trivial moderate complex
179
+
180
+ Where's your head at?
181
+```
182
+
183
+**User brings a specific problem:**
184
+
185
+```
186
+User: The auth system is a mess
187
+
188
+You: [reads codebase]
189
+
190
+ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
191
+ โ CURRENT AUTH FLOW โ
192
+ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
193
+ โ
194
+ โโโโโโโโโโโโโโโผโโโโโโโโโโโโโโ
195
+ โผ โผ โผ
196
+ โโโโโโโโโโโ โโโโโโโโโโโ โโโโโโโโโโโ
197
+ โ Google โ โ GitHub โ โ Email โ
198
+ โ OAuth โ โ OAuth โ โ Magic โ
199
+ โโโโโโฌโโโโโ โโโโโโฌโโโโโ โโโโโโฌโโโโโ
200
+ โ โ โ
201
+ โโโโโโโโโโโโโโโผโโโโโโโโโโโโโโ
202
+ โผ
203
+ โโโโโโโโโโโโโ
204
+ โ Session โ
205
+ โโโโโโโฌโโโโโโ
206
+ โ
207
+ โผ
208
+ โโโโโโโโโโโโโ
209
+ โ Perms โ
210
+ โโโโโโโโโโโโโ
211
+
212
+ I see three tangles. Which one's burning?
213
+```
214
+
215
+**User is stuck mid-implementation:**
216
+
217
+```
218
+User: /opsx:explore add-auth-system
219
+ The OAuth integration is more complex than expected
220
+
221
+You: [reads change artifacts]
222
+
223
+ You're on task 4: "Implement OAuth flow"
224
+
225
+ Let me trace what's involved...
226
+
227
+ [draws diagram, explores options, suggests paths]
228
+
229
+ Want to update the design to reflect this?
230
+ Or add a spike task to investigate?
231
+```
232
+
233
+**User wants to compare options:**
234
+
235
+```
236
+User: Should we use Postgres or SQLite?
237
+
238
+You: Generic answer is boring. What's the context?
239
+
240
+User: A CLI tool that tracks local dev environments
241
+
242
+You: That changes everything.
243
+
244
+ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
245
+ โ CLI TOOL DATA STORAGE โ
246
+ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
247
+
248
+ Key constraints:
249
+ โข No daemon running
250
+ โข Must work offline
251
+ โข Single user
252
+
253
+ SQLite Postgres
254
+ Deployment embedded โ needs server โ
255
+ Offline yes โ no โ
256
+ Single file yes โ no โ
257
+
258
+ SQLite. Not even close.
259
+
260
+ Unless... is there a sync component?
261
+```
262
+
263
+---
264
+
265
+## Ending Discovery
266
+
267
+There's no required ending. Discovery might:
268
+
269
+- **Flow into a proposal**: "Ready to start? I can create a change proposal."
270
+- **Result in artifact updates**: "Updated design.md with these decisions"
271
+- **Just provide clarity**: User has what they need, moves on
272
+- **Continue later**: "We can pick this up anytime"
273
+
274
+When it feels like things are crystallizing, you might summarize:
275
+
276
+```
277
+## What We Figured Out
278
+
279
+**The problem**: [crystallized understanding]
280
+
281
+**The approach**: [if one emerged]
282
+
283
+**Open questions**: [if any remain]
284
+
285
+**Next steps** (if ready):
286
+- Create a change proposal
287
+- Keep exploring: just keep talking
288
+```
289
+
290
+But this summary is optional. Sometimes the thinking IS the value.
291
+
292
+---
293
+
294
+## Guardrails
295
+
296
+- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application
297
+ code is not.
298
+- **Don't fake understanding** - If something is unclear, dig deeper
299
+- **Don't rush** - Discovery is thinking time, not task time
300
+- **Don't force structure** - Let patterns emerge naturally
301
+- **Don't auto-capture** - Offer to save insights, don't just do it
302
+- **Do visualize** - A good diagram is worth many paragraphs
303
+- **Do explore the codebase** - Ground discussions in reality
304
+- **Do question assumptions** - Including the user's and your own
ADD
.claude/skills/openspec-propose/SKILL.md
+115 -0
@@ -0,0 +1,115 @@
1
+---
2
+name: openspec-propose
3
+description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
4
+license: MIT
5
+compatibility: Requires openspec CLI.
6
+metadata:
7
+ author: openspec
8
+ version: "1.0"
9
+ generatedBy: "1.4.1"
10
+---
11
+
12
+Propose a new change - create the change and generate all artifacts in one step.
13
+
14
+I'll create a change with artifacts:
15
+
16
+- proposal.md (what & why)
17
+- design.md (how)
18
+- tasks.md (implementation steps)
19
+
20
+When ready to implement, run /opsx:apply
21
+
22
+---
23
+
24
+**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
25
+
26
+**Steps**
27
+
28
+1. **If no clear input provided, ask what they want to build**
29
+
30
+ Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
31
+ > "What change do you want to work on? Describe what you want to build or fix."
32
+
33
+ From their description, derive a kebab-case name (e.g., "add user authentication" โ `add-user-auth`).
34
+
35
+ **IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
36
+
37
+2. **Create the change directory**
38
+ ```bash
39
+ openspec new change "<name>"
40
+ ```
41
+ This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`.
42
+
43
+3. **Get the artifact build order**
44
+ ```bash
45
+ openspec status --change "<name>" --json
46
+ ```
47
+ Parse the JSON to get:
48
+ - `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
49
+ - `artifacts`: list of all artifacts with their status and dependencies
50
+ - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of
51
+ assuming repo-local paths.
52
+
53
+4. **Create artifacts in sequence until apply-ready**
54
+
55
+ Use the **TodoWrite tool** to track progress through the artifacts.
56
+
57
+ Loop through artifacts in dependency order (artifacts with no pending dependencies first):
58
+
59
+ a. **For each artifact that is `ready` (dependencies satisfied)**:
60
+ - Get instructions:
61
+ ```bash
62
+ openspec instructions <artifact-id> --change "<name>" --json
63
+ ```
64
+ - The instructions JSON includes:
65
+ - `context`: Project background (constraints for you - do NOT include in output)
66
+ - `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
67
+ - `template`: The structure to use for your output file
68
+ - `instruction`: Schema-specific guidance for this artifact type
69
+ - `resolvedOutputPath`: Resolved path or pattern to write the artifact
70
+ - `dependencies`: Completed artifacts to read for context
71
+ - Read any completed dependency files for context
72
+ - Create the artifact file using `template` as the structure and write it to `resolvedOutputPath`
73
+ - Apply `context` and `rules` as constraints - but do NOT copy them into the file
74
+ - Show brief progress: "Created <artifact-id>"
75
+
76
+ b. **Continue until all `applyRequires` artifacts are complete**
77
+ - After creating each artifact, re-run `openspec status --change "<name>" --json`
78
+ - Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
79
+ - Stop when all `applyRequires` artifacts are done
80
+
81
+ c. **If an artifact requires user input** (unclear context):
82
+ - Use **AskUserQuestion tool** to clarify
83
+ - Then continue with creation
84
+
85
+5. **Show final status**
86
+ ```bash
87
+ openspec status --change "<name>"
88
+ ```
89
+
90
+**Output**
91
+
92
+After completing all artifacts, summarize:
93
+
94
+- Change name and location
95
+- List of artifacts created with brief descriptions
96
+- What's ready: "All artifacts created! Ready for implementation."
97
+- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks."
98
+
99
+**Artifact Creation Guidelines**
100
+
101
+- Follow the `instruction` field from `openspec instructions` for each artifact type
102
+- The schema defines what each artifact should contain - follow it
103
+- Read dependency artifacts for context before creating new ones
104
+- Use `template` as the structure for your output file - fill in its sections
105
+- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
106
+ - Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
107
+ - These guide what you write, but should never appear in the output
108
+
109
+**Guardrails**
110
+
111
+- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
112
+- Always read dependency artifacts before creating a new one
113
+- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
114
+- If a change with that name already exists, ask if user wants to continue it or create a new one
115
+- Verify each artifact file exists after writing before proceeding to next
ADD
.claude/skills/openspec-sync-specs/SKILL.md
+152 -0
@@ -0,0 +1,152 @@
1
+---
2
+name: openspec-sync-specs
3
+description: Sync delta specs from a change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change.
4
+license: MIT
5
+compatibility: Requires openspec CLI.
6
+metadata:
7
+ author: openspec
8
+ version: "1.0"
9
+ generatedBy: "1.4.1"
10
+---
11
+
12
+Sync delta specs from a change to main specs.
13
+
14
+This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes.
15
+This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
16
+
17
+**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague
18
+or ambiguous you MUST prompt for available changes.
19
+
20
+**Steps**
21
+
22
+1. **If no change name provided, prompt for selection**
23
+
24
+ Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
25
+
26
+ Show changes that have delta specs (under `specs/` directory).
27
+
28
+ **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
29
+
30
+2. **Resolve change context**
31
+
32
+ Run:
33
+ ```bash
34
+ openspec status --change "<name>" --json
35
+ ```
36
+
37
+ If status reports `actionContext.mode: "workspace-planning"`, explain that workspace spec sync is not supported in
38
+ this slice and STOP. Do not fall back to repo-local paths or edit linked repos.
39
+
40
+3. **Find delta specs**
41
+
42
+ Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the list of delta spec files.
43
+
44
+ Each delta spec file contains sections like:
45
+ - `## ADDED Requirements` - New requirements to add
46
+ - `## MODIFIED Requirements` - Changes to existing requirements
47
+ - `## REMOVED Requirements` - Requirements to remove
48
+ - `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format)
49
+
50
+ If no delta specs found, inform user and stop.
51
+
52
+4. **For each delta spec, apply changes to main specs**
53
+
54
+ For each repo-local capability delta spec path returned by the CLI:
55
+
56
+ a. **Read the delta spec** to understand the intended changes
57
+
58
+ b. **Read the main spec** at `openspec/specs/<capability>/spec.md` (may not exist yet)
59
+
60
+ c. **Apply changes intelligently**:
61
+
62
+ **ADDED Requirements:**
63
+ - If requirement doesn't exist in main spec โ add it
64
+ - If requirement already exists โ update it to match (treat as implicit MODIFIED)
65
+
66
+ **MODIFIED Requirements:**
67
+ - Find the requirement in main spec
68
+ - Apply the changes - this can be:
69
+ - Adding new scenarios (don't need to copy existing ones)
70
+ - Modifying existing scenarios
71
+ - Changing the requirement description
72
+ - Preserve scenarios/content not mentioned in the delta
73
+
74
+ **REMOVED Requirements:**
75
+ - Remove the entire requirement block from main spec
76
+
77
+ **RENAMED Requirements:**
78
+ - Find the FROM requirement, rename to TO
79
+
80
+ d. **Create new main spec** if capability doesn't exist yet:
81
+ - Create `openspec/specs/<capability>/spec.md`
82
+ - Add Purpose section (can be brief, mark as TBD)
83
+ - Add Requirements section with the ADDED requirements
84
+
85
+5. **Show summary**
86
+
87
+ After applying all changes, summarize:
88
+ - Which capabilities were updated
89
+ - What changes were made (requirements added/modified/removed/renamed)
90
+
91
+**Delta Spec Format Reference**
92
+
93
+```markdown
94
+## ADDED Requirements
95
+
96
+### Requirement: New Feature
97
+The system SHALL do something new.
98
+
99
+#### Scenario: Basic case
100
+- **WHEN** user does X
101
+- **THEN** system does Y
102
+
103
+## MODIFIED Requirements
104
+
105
+### Requirement: Existing Feature
106
+#### Scenario: New scenario to add
107
+- **WHEN** user does A
108
+- **THEN** system does B
109
+
110
+## REMOVED Requirements
111
+
112
+### Requirement: Deprecated Feature
113
+
114
+## RENAMED Requirements
115
+
116
+- FROM: `### Requirement: Old Name`
117
+- TO: `### Requirement: New Name`
118
+```
119
+
120
+**Key Principle: Intelligent Merging**
121
+
122
+Unlike programmatic merging, you can apply **partial updates**:
123
+
124
+- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios
125
+- The delta represents *intent*, not a wholesale replacement
126
+- Use your judgment to merge changes sensibly
127
+
128
+**Output On Success**
129
+
130
+```
131
+## Specs Synced: <change-name>
132
+
133
+Updated main specs:
134
+
135
+**<capability-1>**:
136
+- Added requirement: "New Feature"
137
+- Modified requirement: "Existing Feature" (added 1 scenario)
138
+
139
+**<capability-2>**:
140
+- Created new spec file
141
+- Added requirement: "Another Feature"
142
+
143
+Main specs are now updated. The change remains active - archive when implementation is complete.
144
+```
145
+
146
+**Guardrails**
147
+
148
+- Read both delta and main specs before making changes
149
+- Preserve existing content not mentioned in delta
150
+- If something is unclear, ask for clarification
151
+- Show what you're changing as you go
152
+- The operation should be idempotent - running twice should give same result
ADD
.dockerignore
+5 -0
@@ -0,0 +1,5 @@
1
+*
2
+!target/*-runner
3
+!target/*-runner.jar
4
+!target/lib/*
5
+!target/quarkus-app/*
ADD
.forgejo/workflows/ci.yml
+39 -0
@@ -0,0 +1,39 @@
1
+name: CI
2
+
3
+on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+jobs:
9
+ jvm-tests:
10
+ runs-on: docker
11
+ container:
12
+ image: eclipse-temurin:21-jdk
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - name: Cache Maven repository
16
+ uses: actions/cache@v4
17
+ with:
18
+ path: ~/.m2/repository
19
+ key: maven-${{ hashFiles('pom.xml') }}
20
+ - name: Run JVM tests
21
+ run: ./mvnw -B test
22
+
23
+ native-build-and-smoke:
24
+ # Native build + native integration tests gate every main-branch build
25
+ # so native-image regressions surface at merge time, not release time.
26
+ if: github.ref == 'refs/heads/main'
27
+ runs-on: docker
28
+ container:
29
+ image: eclipse-temurin:21-jdk
30
+ needs: jvm-tests
31
+ steps:
32
+ - uses: actions/checkout@v4
33
+ - name: Cache Maven repository
34
+ uses: actions/cache@v4
35
+ with:
36
+ path: ~/.m2/repository
37
+ key: maven-${{ hashFiles('pom.xml') }}
38
+ - name: Native build + integration smoke tests
39
+ run: ./mvnw -B verify -Dnative -Dquarkus.native.container-build=true
ADD
.gitignore
+48 -0
@@ -0,0 +1,48 @@
1
+#Maven
2
+target/
3
+pom.xml.tag
4
+pom.xml.releaseBackup
5
+pom.xml.versionsBackup
6
+release.properties
7
+.flattened-pom.xml
8
+
9
+# Eclipse
10
+.project
11
+.classpath
12
+.settings/
13
+bin/
14
+
15
+# IntelliJ
16
+.idea
17
+*.ipr
18
+*.iml
19
+*.iws
20
+
21
+# NetBeans
22
+nb-configuration.xml
23
+
24
+# Visual Studio Code
25
+.vscode
26
+.factorypath
27
+
28
+# OSX
29
+.DS_Store
30
+
31
+# Vim
32
+*.swp
33
+*.swo
34
+
35
+# patch
36
+*.orig
37
+*.rej
38
+
39
+# Local environment
40
+.env
41
+
42
+# Runtime data (repositories, SSH host key)
43
+/data/
44
+
45
+# Plugin directory
46
+/.quarkus/cli/plugins/
47
+# TLS Certificates
48
+.certs/
ADD
.mvn/wrapper/maven-wrapper.properties
+4 -0
@@ -0,0 +1,4 @@
1
+wrapperVersion=3.3.4
2
+distributionType=only-script
3
+distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip
4
+distributionSha256Sum=5af3b743dd8b876b5c45da33b676251e5f1687712644abb4ee519ca56e1d89ce
ADD
README.md
+66 -0
@@ -0,0 +1,66 @@
1
+# git-shark ๐ฆ
2
+
3
+Self-hosted Git platform (GitHub-like) as a single, natively-compiled Quarkus service.
4
+
5
+Bare Git repositories on disk, served over **smart HTTP** (JGit `GitServlet`) and **SSH**
6
+(embedded Apache MINA SSHD), with a server-rendered **Qute** web UI, **OIDC** login,
7
+**PostgreSQL** metadata (Flyway-managed schema), and per-repository access control.
8
+
9
+## Features
10
+
11
+- Create, browse, and delete personal repositories (public or private)
12
+- Clone/fetch/push over `https://<host>/git/<owner>/<repo>.git`
13
+ - anonymous read on public repositories
14
+ - push and private read authenticate with **personal access tokens** (HTTP Basic password)
15
+- Clone/fetch/push over `ssh://git@<host>:2222/<owner>/<repo>.git`
16
+ - public-key authentication only; keys managed per user in the UI
17
+- Web UI: repository list, file/tree browser, commit log (paginated), branches & tags
18
+- OIDC login (authorization code flow); users provisioned on first login
19
+- Single access policy on all paths: owner read/write, public world-readable, private owner-only
20
+
21
+## Architecture notes
22
+
23
+- Repository names resolve to UUID-based storage paths **through the database only**
24
+ (`<storage-root>/<owner-uuid>/<repo-uuid>.git`); HTTP, SSH, and UI share the same
25
+ resolution and authorization services.
26
+- Tokens are stored as SHA-256 hashes; the plaintext is shown exactly once at creation.
27
+- The SSH host key is generated on first start and persisted, so the host identity is
28
+ stable across restarts.
29
+- SSH serves only `git-upload-pack`/`git-receive-pack`; shells and other commands are rejected.
30
+
31
+## Configuration
32
+
33
+| Property / Env var | Default | Purpose |
34
+|---|---|---|
35
+| `GITSHARK_STORAGE_ROOT` | `data/repositories` | Root directory for bare repositories (persistent volume) |
36
+| `GITSHARK_SSH_PORT` | `2222` | Embedded SSH server port |
37
+| `GITSHARK_SSH_HOST_KEY` | `data/ssh/host-key` | Persisted SSH host key file |
38
+| `QUARKUS_DATASOURCE_JDBC_URL` / `_USERNAME` / `_PASSWORD` | โ (Dev Services in dev/test) | PostgreSQL connection |
39
+| `QUARKUS_OIDC_AUTH_SERVER_URL` / `_CLIENT_ID` / `_CREDENTIALS_SECRET` | โ (Keycloak Dev Services in dev/test) | OIDC provider |
40
+
41
+> **TLS required in production:** personal access tokens travel as HTTP Basic credentials.
42
+> Terminate TLS in front of the service; never expose plain HTTP publicly.
43
+
44
+## Development
45
+
46
+```shell script
47
+./mvnw quarkus:dev # dev mode; PostgreSQL + Keycloak via Dev Services (Docker/Podman required)
48
+./mvnw test # JVM tests
49
+./mvnw verify -Dnative # native build + integration smoke tests (HTTP health, SSH banner)
50
+```
51
+
52
+Native build uses a container build automatically when no local GraalVM is present
53
+(`-Dquarkus.native.container-build=true` to force). Binary: `target/git-shark-1.0-SNAPSHOT-runner`.
54
+
55
+## Persisted data
56
+
57
+| Store | What |
58
+|---|---|
59
+| PostgreSQL | `users`, `repositories` (metadata), `ssh_keys` (public keys + fingerprints), `access_tokens` (SHA-256 hashes, labels, last-used) |
60
+| Filesystem (`GITSHARK_STORAGE_ROOT`) | Bare Git repositories |
61
+| Filesystem (`GITSHARK_SSH_HOST_KEY`) | SSH host key |
62
+
63
+## CI
64
+
65
+`.forgejo/workflows/ci.yml`: JVM tests on every push/PR; native build + native smoke tests
66
+gate main-branch builds.
ADD
mvnw
+295 -0
@@ -0,0 +1,295 @@
1
+#!/bin/sh
2
+# ----------------------------------------------------------------------------
3
+# Licensed to the Apache Software Foundation (ASF) under one
4
+# or more contributor license agreements. See the NOTICE file
5
+# distributed with this work for additional information
6
+# regarding copyright ownership. The ASF licenses this file
7
+# to you under the Apache License, Version 2.0 (the
8
+# "License"); you may not use this file except in compliance
9
+# with the License. You may obtain a copy of the License at
10
+#
11
+# http://www.apache.org/licenses/LICENSE-2.0
12
+#
13
+# Unless required by applicable law or agreed to in writing,
14
+# software distributed under the License is distributed on an
15
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16
+# KIND, either express or implied. See the License for the
17
+# specific language governing permissions and limitations
18
+# under the License.
19
+# ----------------------------------------------------------------------------
20
+
21
+# ----------------------------------------------------------------------------
22
+# Apache Maven Wrapper startup batch script, version 3.3.4
23
+#
24
+# Optional ENV vars
25
+# -----------------
26
+# JAVA_HOME - location of a JDK home dir, required when download maven via java source
27
+# MVNW_REPOURL - repo url base for downloading maven distribution
28
+# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
29
+# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
30
+# ----------------------------------------------------------------------------
31
+
32
+set -euf
33
+[ "${MVNW_VERBOSE-}" != debug ] || set -x
34
+
35
+# OS specific support.
36
+native_path() { printf %s\\n "$1"; }
37
+case "$(uname)" in
38
+CYGWIN* | MINGW*)
39
+ [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
40
+ native_path() { cygpath --path --windows "$1"; }
41
+ ;;
42
+esac
43
+
44
+# set JAVACMD and JAVACCMD
45
+set_java_home() {
46
+ # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
47
+ if [ -n "${JAVA_HOME-}" ]; then
48
+ if [ -x "$JAVA_HOME/jre/sh/java" ]; then
49
+ # IBM's JDK on AIX uses strange locations for the executables
50
+ JAVACMD="$JAVA_HOME/jre/sh/java"
51
+ JAVACCMD="$JAVA_HOME/jre/sh/javac"
52
+ else
53
+ JAVACMD="$JAVA_HOME/bin/java"
54
+ JAVACCMD="$JAVA_HOME/bin/javac"
55
+
56
+ if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
57
+ echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
58
+ echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
59
+ return 1
60
+ fi
61
+ fi
62
+ else
63
+ JAVACMD="$(
64
+ 'set' +e
65
+ 'unset' -f command 2>/dev/null
66
+ 'command' -v java
67
+ )" || :
68
+ JAVACCMD="$(
69
+ 'set' +e
70
+ 'unset' -f command 2>/dev/null
71
+ 'command' -v javac
72
+ )" || :
73
+
74
+ if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
75
+ echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
76
+ return 1
77
+ fi
78
+ fi
79
+}
80
+
81
+# hash string like Java String::hashCode
82
+hash_string() {
83
+ str="${1:-}" h=0
84
+ while [ -n "$str" ]; do
85
+ char="${str%"${str#?}"}"
86
+ h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
87
+ str="${str#?}"
88
+ done
89
+ printf %x\\n $h
90
+}
91
+
92
+verbose() { :; }
93
+[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
94
+
95
+die() {
96
+ printf %s\\n "$1" >&2
97
+ exit 1
98
+}
99
+
100
+trim() {
101
+ # MWRAPPER-139:
102
+ # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
103
+ # Needed for removing poorly interpreted newline sequences when running in more
104
+ # exotic environments such as mingw bash on Windows.
105
+ printf "%s" "${1}" | tr -d '[:space:]'
106
+}
107
+
108
+scriptDir="$(dirname "$0")"
109
+scriptName="$(basename "$0")"
110
+
111
+# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
112
+while IFS="=" read -r key value; do
113
+ case "${key-}" in
114
+ distributionUrl) distributionUrl=$(trim "${value-}") ;;
115
+ distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
116
+ esac
117
+done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
118
+[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
119
+
120
+case "${distributionUrl##*/}" in
121
+maven-mvnd-*bin.*)
122
+ MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
123
+ case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
124
+ *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
125
+ :Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
126
+ :Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
127
+ :Linux*x86_64*) distributionPlatform=linux-amd64 ;;
128
+ *)
129
+ echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
130
+ distributionPlatform=linux-amd64
131
+ ;;
132
+ esac
133
+ distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
134
+ ;;
135
+maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
136
+*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
137
+esac
138
+
139
+# apply MVNW_REPOURL and calculate MAVEN_HOME
140
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
141
+[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
142
+distributionUrlName="${distributionUrl##*/}"
143
+distributionUrlNameMain="${distributionUrlName%.*}"
144
+distributionUrlNameMain="${distributionUrlNameMain%-bin}"
145
+MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
146
+MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
147
+
148
+exec_maven() {
149
+ unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
150
+ exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
151
+}
152
+
153
+if [ -d "$MAVEN_HOME" ]; then
154
+ verbose "found existing MAVEN_HOME at $MAVEN_HOME"
155
+ exec_maven "$@"
156
+fi
157
+
158
+case "${distributionUrl-}" in
159
+*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
160
+*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
161
+esac
162
+
163
+# prepare tmp dir
164
+if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
165
+ clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
166
+ trap clean HUP INT TERM EXIT
167
+else
168
+ die "cannot create temp dir"
169
+fi
170
+
171
+mkdir -p -- "${MAVEN_HOME%/*}"
172
+
173
+# Download and Install Apache Maven
174
+verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
175
+verbose "Downloading from: $distributionUrl"
176
+verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
177
+
178
+# select .zip or .tar.gz
179
+if ! command -v unzip >/dev/null; then
180
+ distributionUrl="${distributionUrl%.zip}.tar.gz"
181
+ distributionUrlName="${distributionUrl##*/}"
182
+fi
183
+
184
+# verbose opt
185
+__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
186
+[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
187
+
188
+# normalize http auth
189
+case "${MVNW_PASSWORD:+has-password}" in
190
+'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
191
+has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
192
+esac
193
+
194
+if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
195
+ verbose "Found wget ... using wget"
196
+ wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
197
+elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
198
+ verbose "Found curl ... using curl"
199
+ curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
200
+elif set_java_home; then
201
+ verbose "Falling back to use Java to download"
202
+ javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
203
+ targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
204
+ cat >"$javaSource" <<-END
205
+ public class Downloader extends java.net.Authenticator
206
+ {
207
+ protected java.net.PasswordAuthentication getPasswordAuthentication()
208
+ {
209
+ return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
210
+ }
211
+ public static void main( String[] args ) throws Exception
212
+ {
213
+ setDefault( new Downloader() );
214
+ java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
215
+ }
216
+ }
217
+ END
218
+ # For Cygwin/MinGW, switch paths to Windows format before running javac and java
219
+ verbose " - Compiling Downloader.java ..."
220
+ "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
221
+ verbose " - Running Downloader.java ..."
222
+ "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
223
+fi
224
+
225
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
226
+if [ -n "${distributionSha256Sum-}" ]; then
227
+ distributionSha256Result=false
228
+ if [ "$MVN_CMD" = mvnd.sh ]; then
229
+ echo "Checksum validation is not supported for maven-mvnd." >&2
230
+ echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
231
+ exit 1
232
+ elif command -v sha256sum >/dev/null; then
233
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
234
+ distributionSha256Result=true
235
+ fi
236
+ elif command -v shasum >/dev/null; then
237
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
238
+ distributionSha256Result=true
239
+ fi
240
+ else
241
+ echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
242
+ echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
243
+ exit 1
244
+ fi
245
+ if [ $distributionSha256Result = false ]; then
246
+ echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
247
+ echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
248
+ exit 1
249
+ fi
250
+fi
251
+
252
+# unzip and move
253
+if command -v unzip >/dev/null; then
254
+ unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
255
+else
256
+ tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
257
+fi
258
+
259
+# Find the actual extracted directory name (handles snapshots where filename != directory name)
260
+actualDistributionDir=""
261
+
262
+# First try the expected directory name (for regular distributions)
263
+if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
264
+ if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
265
+ actualDistributionDir="$distributionUrlNameMain"
266
+ fi
267
+fi
268
+
269
+# If not found, search for any directory with the Maven executable (for snapshots)
270
+if [ -z "$actualDistributionDir" ]; then
271
+ # enable globbing to iterate over items
272
+ set +f
273
+ for dir in "$TMP_DOWNLOAD_DIR"/*; do
274
+ if [ -d "$dir" ]; then
275
+ if [ -f "$dir/bin/$MVN_CMD" ]; then
276
+ actualDistributionDir="$(basename "$dir")"
277
+ break
278
+ fi
279
+ fi
280
+ done
281
+ set -f
282
+fi
283
+
284
+if [ -z "$actualDistributionDir" ]; then
285
+ verbose "Contents of $TMP_DOWNLOAD_DIR:"
286
+ verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
287
+ die "Could not find Maven distribution directory in extracted archive"
288
+fi
289
+
290
+verbose "Found extracted Maven distribution directory: $actualDistributionDir"
291
+printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
292
+mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
293
+
294
+clean || :
295
+exec_maven "$@"
ADD
mvnw.cmd
+189 -0
@@ -0,0 +1,189 @@
1
+<# : batch portion
2
+@REM ----------------------------------------------------------------------------
3
+@REM Licensed to the Apache Software Foundation (ASF) under one
4
+@REM or more contributor license agreements. See the NOTICE file
5
+@REM distributed with this work for additional information
6
+@REM regarding copyright ownership. The ASF licenses this file
7
+@REM to you under the Apache License, Version 2.0 (the
8
+@REM "License"); you may not use this file except in compliance
9
+@REM with the License. You may obtain a copy of the License at
10
+@REM
11
+@REM http://www.apache.org/licenses/LICENSE-2.0
12
+@REM
13
+@REM Unless required by applicable law or agreed to in writing,
14
+@REM software distributed under the License is distributed on an
15
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16
+@REM KIND, either express or implied. See the License for the
17
+@REM specific language governing permissions and limitations
18
+@REM under the License.
19
+@REM ----------------------------------------------------------------------------
20
+
21
+@REM ----------------------------------------------------------------------------
22
+@REM Apache Maven Wrapper startup batch script, version 3.3.4
23
+@REM
24
+@REM Optional ENV vars
25
+@REM MVNW_REPOURL - repo url base for downloading maven distribution
26
+@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
27
+@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
28
+@REM ----------------------------------------------------------------------------
29
+
30
+@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
31
+@SET __MVNW_CMD__=
32
+@SET __MVNW_ERROR__=
33
+@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
34
+@SET PSModulePath=
35
+@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
36
+ IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
37
+)
38
+@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
39
+@SET __MVNW_PSMODULEP_SAVE=
40
+@SET __MVNW_ARG0_NAME__=
41
+@SET MVNW_USERNAME=
42
+@SET MVNW_PASSWORD=
43
+@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
44
+@echo Cannot start maven from wrapper >&2 && exit /b 1
45
+@GOTO :EOF
46
+: end batch / begin powershell #>
47
+
48
+$ErrorActionPreference = "Stop"
49
+if ($env:MVNW_VERBOSE -eq "true") {
50
+ $VerbosePreference = "Continue"
51
+}
52
+
53
+# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
54
+$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
55
+if (!$distributionUrl) {
56
+ Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
57
+}
58
+
59
+switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
60
+ "maven-mvnd-*" {
61
+ $USE_MVND = $true
62
+ $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
63
+ $MVN_CMD = "mvnd.cmd"
64
+ break
65
+ }
66
+ default {
67
+ $USE_MVND = $false
68
+ $MVN_CMD = $script -replace '^mvnw','mvn'
69
+ break
70
+ }
71
+}
72
+
73
+# apply MVNW_REPOURL and calculate MAVEN_HOME
74
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
75
+if ($env:MVNW_REPOURL) {
76
+ $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
77
+ $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
78
+}
79
+$distributionUrlName = $distributionUrl -replace '^.*/',''
80
+$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
81
+
82
+$MAVEN_M2_PATH = "$HOME/.m2"
83
+if ($env:MAVEN_USER_HOME) {
84
+ $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
85
+}
86
+
87
+if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
88
+ New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
89
+}
90
+
91
+$MAVEN_WRAPPER_DISTS = $null
92
+if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
93
+ $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
94
+} else {
95
+ $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
96
+}
97
+
98
+$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
99
+$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
100
+$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
101
+
102
+if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
103
+ Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
104
+ Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
105
+ exit $?
106
+}
107
+
108
+if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
109
+ Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
110
+}
111
+
112
+# prepare tmp dir
113
+$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
114
+$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
115
+$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
116
+trap {
117
+ if ($TMP_DOWNLOAD_DIR.Exists) {
118
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
119
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
120
+ }
121
+}
122
+
123
+New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
124
+
125
+# Download and Install Apache Maven
126
+Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
127
+Write-Verbose "Downloading from: $distributionUrl"
128
+Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
129
+
130
+$webclient = New-Object System.Net.WebClient
131
+if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
132
+ $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
133
+}
134
+[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
135
+$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
136
+
137
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
138
+$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
139
+if ($distributionSha256Sum) {
140
+ if ($USE_MVND) {
141
+ Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
142
+ }
143
+ Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
144
+ if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
145
+ Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
146
+ }
147
+}
148
+
149
+# unzip and move
150
+Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
151
+
152
+# Find the actual extracted directory name (handles snapshots where filename != directory name)
153
+$actualDistributionDir = ""
154
+
155
+# First try the expected directory name (for regular distributions)
156
+$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
157
+$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
158
+if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
159
+ $actualDistributionDir = $distributionUrlNameMain
160
+}
161
+
162
+# If not found, search for any directory with the Maven executable (for snapshots)
163
+if (!$actualDistributionDir) {
164
+ Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
165
+ $testPath = Join-Path $_.FullName "bin/$MVN_CMD"
166
+ if (Test-Path -Path $testPath -PathType Leaf) {
167
+ $actualDistributionDir = $_.Name
168
+ }
169
+ }
170
+}
171
+
172
+if (!$actualDistributionDir) {
173
+ Write-Error "Could not find Maven distribution directory in extracted archive"
174
+}
175
+
176
+Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
177
+Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
178
+try {
179
+ Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
180
+} catch {
181
+ if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
182
+ Write-Error "fail to move MAVEN_HOME"
183
+ }
184
+} finally {
185
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
186
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
187
+}
188
+
189
+Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
ADD
openspec/changes/create-git-platform/.openspec.yaml
+2 -0
@@ -0,0 +1,2 @@
1
+schema: spec-driven
2
+created: 2026-06-11
ADD
openspec/changes/create-git-platform/design.md
+127 -0
@@ -0,0 +1,127 @@
1
+## Context
2
+
3
+Greenfield project. The user bootstraps the Quarkus project skeleton; this change implements the full first iteration of
4
+`git-shark`, a self-hosted Git platform. The hard constraints shaping the design:
5
+
6
+- **Native image is the goal.** Every dependency choice must survive GraalVM native compilation. JGit, MINA SSHD, and
7
+ BouncyCastle are not Quarkus extensions, so reflection/resource/JNI config is on us.
8
+- **JGit's `GitServlet` requires the Servlet API**, which in Quarkus means `quarkus-undertow` alongside Quarkus REST.
9
+- **SSH transport runs outside HTTP** โ MINA SSHD opens its own port and lifecycle, managed as a CDI bean.
10
+- Company standards apply: Panache Next, `WithId.AutoUUID` entity IDs, Flyway migrations, OIDC, test-first development.
11
+
12
+## Goals / Non-Goals
13
+
14
+**Goals:**
15
+
16
+- Host bare Git repositories with clone/fetch/push over smart HTTP and SSH
17
+- OIDC login, per-user SSH keys, per-repo owner/visibility access control
18
+- Qute server-rendered UI: repo list, file browser, commit log, branches/tags
19
+- PostgreSQL persistence with Flyway-managed schema
20
+- Buildable and runnable as a GraalVM native image
21
+
22
+**Non-Goals (this change):**
23
+
24
+- Pull/merge requests, issues, code review, webhooks, CI integration
25
+- Organizations/teams โ only personal repositories owned by a single user
26
+- Repository forks, mirrors, LFS, submodule UI
27
+- HTTP password/token auth beyond what OIDC provides (see Decisions)
28
+- Federation, replication, HA โ single instance, single storage volume
29
+
30
+## Decisions
31
+
32
+### D1: Servlet container via `quarkus-undertow` for `GitServlet`
33
+
34
+JGit's smart HTTP implementation (`org.eclipse.jgit.http.server.GitServlet`) is a plain `HttpServlet`. Quarkus supports
35
+servlets through `quarkus-undertow`; we register `GitServlet` with `@WebServlet` (or a `ServletExtension`) under
36
+`/git/*`. URL scheme: `https://host/git/<owner>/<repo>.git`.
37
+*Alternative considered:* reimplementing upload-pack/receive-pack on Quarkus REST with JGit internals โ more
38
+native-friendly in theory, but re-derives protocol plumbing JGit already ships and tests. Rejected.
39
+
40
+### D2: SSH via Apache MINA SSHD + `sshd-git`, run as CDI-managed server
41
+
42
+`sshd-git` provides `GitPackCommandFactory` that bridges SSH exec channels (`git-upload-pack` / `git-receive-pack`) to
43
+JGit.
44
+
45
+**Deviation (found during implementation):** `sshd-git`'s `GitPackCommandFactory` resolves the repository as
46
+`rootDir + path-from-command`, which cannot map `<owner>/<repo>.git` names onto our UUID-based storage layout
47
+(D3: resolution must go through the database). It is also compiled against JGit 5.13 while we run JGit 7.x. We
48
+therefore implement our own small MINA `CommandFactory` that parses `git-upload-pack`/`git-receive-pack`, resolves
49
+and authorizes through `GitRepositoryService`/`AccessPolicy`, and streams to JGit's `UploadPack`/`ReceivePack`
50
+directly. The `sshd-git` dependency is dropped; only `sshd-core` remains. An `@ApplicationScoped` bean starts `SshServer` on `@Startup` and stops it on shutdown, port configurable (default
51
+2222 โ native binary shouldn't require root for 22; ops can remap). `PublickeyAuthenticator` resolves the presented key
52
+against stored user keys (fingerprint lookup in Postgres). Host key is persisted to the data volume on first start (
53
+`SimpleGeneratorHostKeyProvider` writes the key file) so the host identity is stable across restarts.
54
+*Alternative considered:* external sshd + forced command (gitolite-style). Rejected โ breaks the single-binary goal.
55
+
56
+### D3: Repository storage layout and resolution
57
+
58
+Bare repos live at `<storage-root>/<owner-uuid>/<repo-uuid>.git`; UUID-based paths avoid rename problems and
59
+path-injection from user-chosen names. Resolution from `<owner>/<repo>` names to filesystem paths goes through the
60
+database (single source of truth). A `RepositoryResolver` (HTTP) and the SSH command factory share one
61
+`GitRepositoryService` so both transports resolve and authorize identically.
62
+
63
+### D4: AuthN/AuthZ split per transport
64
+
65
+- **UI:** `quarkus-oidc` authorization code flow; users are provisioned/updated in Postgres on first login (keyed by
66
+ OIDC `sub`).
67
+- **Smart HTTP git ops:** same OIDC tenant, but git clients can't do code flows โ they send HTTP Basic. We accept
68
+ personal access tokens (generated in UI, stored hashed) as Basic password. Public repos allow anonymous read (
69
+ upload-pack); push always requires auth.
70
+- **SSH:** public-key auth only.
71
+- **Authorization (all paths):** owner has read/write; public repos world-readable; private repos owner-only. One
72
+ `AccessPolicy` service used by UI, HTTP filter, and SSH command factory.
73
+ *Alternative considered:* OIDC bearer tokens for git HTTP โ git credential helpers handle static tokens far better;
74
+ PATs are the pragmatic GitHub-proven choice.
75
+
76
+### D5: Persistence โ Panache Next, UUID IDs, Flyway
77
+
78
+Entities: `User` (oidcSub, username, displayName, email), `Repository` (name, owner FK, visibility, description),
79
+`SshKey` (user FK, title, publicKey, fingerprint), `AccessToken` (user FK, tokenHash, label, lastUsed). All UUID ids.
80
+Flyway `V1__init.sql` creates the schema; no Hibernate DDL generation in prod.
81
+
82
+**Deviation (found during implementation):** `WithId.AutoUUID` is broken in Panache Next 3.36.2 โ its generic
83
+`@GeneratedValue IdType id` field resolves to a *sequence* generator, failing at runtime with
84
+`IdentifierGenerationException: Unknown integral data type for ids : java.util.UUID`. Entities therefore declare
85
+`@Id @GeneratedValue(strategy = GenerationType.UUID) public UUID id` explicitly and implement `PanacheEntity.Managed`
86
+directly. Same UUID semantics, no supertype. Revisit when the upstream bug is fixed.
87
+
88
+### D6: UI โ Qute type-safe templates, no JS framework
89
+
90
+Server-rendered pages with Qute (`@CheckedTemplate`), HTML forms for mutations. Repo browsing reads live from JGit (
91
+`TreeWalk` for trees/blobs, `RevWalk` for commit log) โ no Git data duplicated into Postgres. Pagination on commit log.
92
+Binary blobs render as download links; text blobs as escaped `<pre>` (syntax highlighting deferred).
93
+
94
+### D7: Native image strategy
95
+
96
+- Register JGit + MINA SSHD reflection/resources via `reflect-config.json`/`resource-config.json` under
97
+ `src/main/resources/META-INF/native-image/` (or `@RegisterForReflection` holders where simpler).
98
+- Use MINA SSHD's BouncyCastle-free path where possible (Ed25519 via `net.i2p.crypto:eddsa` or JDK 17+ built-in
99
+ algorithms) to dodge BC's heavy native-image friction; if BC proves unavoidable, register it explicitly.
100
+- JGit: avoid `JSch`-era transports; only `http.server` + core packfile machinery are on the hot path, both
101
+ reflection-light.
102
+- CI verifies native build (`-Dnative`) plus a smoke test: native binary boots, clone over HTTP and SSH succeeds.
103
+
104
+## Risks / Trade-offs
105
+
106
+- [JGit/MINA native-image incompatibilities surface late] โ Stand up the native build + clone/push smoke test in the
107
+ very first implementation tasks, not at the end; fail fast while the dependency surface is small.
108
+- [PAT over Basic is plaintext on the wire] โ Document TLS-termination requirement; tokens stored only as SHA-256
109
+ hashes; revocable in UI.
110
+- [Large repo operations (clone of big packs) blow memory in native image] โ JGit streams packs; set sane `http`/
111
+ `receive-pack` limits (`max-object-size`), make container memory configurable, load-test with a large repo before
112
+ release.
113
+- [SSH port 2222 vs user expectation of 22] โ Documented; deployment can remap via service/load balancer.
114
+- [UUID storage paths make on-disk debugging harder] โ Admin endpoint/CLI mapping name โ path; acceptable for integrity
115
+ gain.
116
+- [Anonymous read on public repos enables scraping] โ Acceptable for v1; rate limiting deferred.
117
+
118
+## Migration Plan
119
+
120
+Greenfield โ no migration. Deploy order: Postgres up โ Flyway migrates on boot โ service starts HTTP (8080) + SSH (
121
+2222). Rollback = redeploy previous image; Flyway `V1` only, no destructive migrations in this change.
122
+
123
+## Open Questions
124
+
125
+- Which OIDC provider in dev? (Keycloak Dev Services assumed for tests.)
126
+- Repo size quotas โ defer or enforce minimal global limit in v1?
127
+- Should repo names be unique globally or per-owner? (Design assumes per-owner: `<owner>/<repo>`.)
ADD
openspec/changes/create-git-platform/proposal.md
+47 -0
@@ -0,0 +1,47 @@
1
+## Why
2
+
3
+We want a self-hosted Git platform (GitHub-like) as a single, lightweight, natively-compiled Quarkus service โ no JVM
4
+footprint, fast startup, simple ops. Existing platforms (Gitea, GitLab) are either non-JVM or heavyweight; building on
5
+Quarkus + JGit gives us full control and integrates with our existing OIDC/Postgres infrastructure.
6
+
7
+## What Changes
8
+
9
+- Bootstrap a new Quarkus service (`git-shark`) that hosts bare Git repositories on disk
10
+- Serve Git smart HTTP protocol (clone/fetch/push) via JGit's `GitServlet`, mounted as a servlet
11
+- Serve Git over SSH via Apache MINA SSHD + `sshd-git`, with public-key authentication
12
+- Server-rendered web UI with Qute: repository list, file browser, commit log, branch/tag views
13
+- User accounts via OIDC (login through external identity provider); SSH keys managed per user
14
+- Repository and user metadata persisted in PostgreSQL, schema managed by Flyway migrations
15
+- Access control: repository ownership and read/write permissions enforced on HTTP, SSH, and UI paths
16
+- Whole service must compile to a GraalVM native image (native-image-compatible dependency usage, reflection config
17
+ where JGit/MINA need it)
18
+
19
+## Capabilities
20
+
21
+### New Capabilities
22
+
23
+- `repo-management`: Create, list, and delete repositories; bare repo storage on the filesystem; metadata (name, owner,
24
+ visibility, description) in PostgreSQL
25
+- `git-smart-http`: Clone, fetch, and push over HTTP(S) using the Git smart protocol via JGit `GitServlet`, with
26
+ authentication and per-repo authorization
27
+- `git-ssh-access`: Clone, fetch, and push over SSH using Apache MINA SSHD + `sshd-git`, authenticated by
28
+ user-registered public keys
29
+- `web-ui`: Qute-rendered web interface โ repository overview, file/tree browser, commit history, branch and tag listing
30
+- `auth-accounts`: OIDC-based login and user provisioning, session handling, SSH public key management per user
31
+- `native-build`: GraalVM native image build of the full service, including reflection/resource config for JGit, MINA
32
+ SSHD, and Qute
33
+
34
+### Modified Capabilities
35
+
36
+(none โ greenfield project)
37
+
38
+## Impact
39
+
40
+- New Quarkus project (bootstrapped by the user) โ all code is new
41
+- Dependencies: `quarkus-qute`, `quarkus-rest`/`quarkus-undertow` (servlet support for `GitServlet`), `quarkus-oidc`,
42
+ `quarkus-jdbc-postgresql`, `quarkus-hibernate-orm-panache` (Panache Next), `quarkus-flyway`, `org.eclipse.jgit`,
43
+ `org.eclipse.jgit.http.server`, `org.apache.sshd:sshd-core` + `sshd-git`
44
+- Infrastructure: PostgreSQL database, external OIDC provider, persistent volume for repository storage, exposed SSH
45
+ port in addition to HTTP
46
+- Native image: JGit and MINA SSHD are not Quarkus extensions โ reflection, JNI, and resource configuration must be
47
+ maintained manually; this constrains dependency choices throughout
ADD
openspec/changes/create-git-platform/specs/auth-accounts/spec.md
+45 -0
@@ -0,0 +1,45 @@
1
+## ADDED Requirements
2
+
3
+### Requirement: OIDC login
4
+The system SHALL authenticate UI users via OIDC authorization code flow (`quarkus-oidc`). Unauthenticated access to protected pages SHALL redirect to the identity provider.
5
+
6
+#### Scenario: Login redirect
7
+- **WHEN** an unauthenticated user opens a protected page
8
+- **THEN** the system redirects to the OIDC provider and, after successful login, back to the requested page
9
+
10
+### Requirement: User provisioning on first login
11
+The system SHALL create a local `User` record on first OIDC login, keyed by the OIDC `sub` claim, storing username, display name, and email from the token. Subsequent logins SHALL update changed profile fields.
12
+
13
+#### Scenario: First login creates user
14
+- **WHEN** a user authenticates via OIDC for the first time
15
+- **THEN** a `User` record with their `sub`, username, display name, and email is persisted
16
+
17
+#### Scenario: Repeated login updates profile
18
+- **WHEN** an existing user logs in with a changed display name in the token
19
+- **THEN** the stored display name is updated and no duplicate user is created
20
+
21
+### Requirement: SSH key management
22
+The system SHALL allow an authenticated user to add, list, and remove SSH public keys on their account. The system SHALL validate the key format on add, compute and store the fingerprint, and reject keys already registered to any account.
23
+
24
+#### Scenario: Add valid key
25
+- **WHEN** a user submits a valid OpenSSH public key with a title
26
+- **THEN** the key is stored with its computed fingerprint and appears in their key list
27
+
28
+#### Scenario: Invalid key rejected
29
+- **WHEN** a user submits malformed key material
30
+- **THEN** the system rejects it with a validation error
31
+
32
+#### Scenario: Duplicate key rejected
33
+- **WHEN** a user submits a key already registered to any account
34
+- **THEN** the system rejects it with a conflict error
35
+
36
+#### Scenario: Removed key stops working
37
+- **WHEN** a user removes an SSH key
38
+- **THEN** subsequent SSH authentication with that key fails
39
+
40
+### Requirement: Logout
41
+The system SHALL allow users to terminate their session via a logout action.
42
+
43
+#### Scenario: Logout clears session
44
+- **WHEN** a logged-in user triggers logout
45
+- **THEN** their session is invalidated and protected pages require re-authentication
ADD
openspec/changes/create-git-platform/specs/git-smart-http/spec.md
+43 -0
@@ -0,0 +1,43 @@
1
+## ADDED Requirements
2
+
3
+### Requirement: Smart HTTP clone and fetch
4
+The system SHALL serve the Git smart HTTP protocol (upload-pack) via JGit's `GitServlet` mounted at `/git/*`, so standard Git clients can clone and fetch `https://<host>/git/<owner>/<repo>.git`.
5
+
6
+#### Scenario: Clone public repository anonymously
7
+- **WHEN** a Git client clones a public repository over HTTP without credentials
8
+- **THEN** the clone succeeds and the working copy matches the repository content
9
+
10
+#### Scenario: Clone private repository with valid token
11
+- **WHEN** a Git client clones a private repository using HTTP Basic with username and a valid personal access token
12
+- **THEN** the clone succeeds
13
+
14
+#### Scenario: Clone private repository unauthenticated
15
+- **WHEN** a Git client attempts to clone a private repository without credentials
16
+- **THEN** the server responds `401 Unauthorized` with a `WWW-Authenticate: Basic` challenge
17
+
18
+### Requirement: Smart HTTP push
19
+The system SHALL serve receive-pack over smart HTTP. Push SHALL always require authentication and write permission, including on public repositories.
20
+
21
+#### Scenario: Authorized push
22
+- **WHEN** the repository owner pushes commits using a valid personal access token
23
+- **THEN** the push succeeds and the refs are updated in the bare repository
24
+
25
+#### Scenario: Anonymous push rejected
26
+- **WHEN** an unauthenticated client attempts to push to any repository
27
+- **THEN** the server responds `401 Unauthorized` and no refs change
28
+
29
+#### Scenario: Push without write permission rejected
30
+- **WHEN** an authenticated user without write permission pushes to a repository
31
+- **THEN** the server responds `403 Forbidden` and no refs change
32
+
33
+### Requirement: Personal access tokens for HTTP git operations
34
+The system SHALL allow users to generate, label, and revoke personal access tokens in the UI. Tokens SHALL be shown in plaintext exactly once at creation and stored only as a cryptographic hash.
35
+
36
+#### Scenario: Token creation
37
+- **WHEN** a user generates a new access token
38
+- **THEN** the plaintext token is displayed once
39
+- **AND** only the token hash and label are persisted
40
+
41
+#### Scenario: Revoked token rejected
42
+- **WHEN** a Git client authenticates with a revoked token
43
+- **THEN** the server responds `401 Unauthorized`
ADD
openspec/changes/create-git-platform/specs/git-ssh-access/spec.md
+45 -0
@@ -0,0 +1,45 @@
1
+## ADDED Requirements
2
+
3
+### Requirement: SSH Git server
4
+The system SHALL run an embedded SSH server (Apache MINA SSHD) on a configurable port (default 2222) inside the same service process, supporting `git-upload-pack` and `git-receive-pack` for URLs of the form `ssh://git@<host>:<port>/<owner>/<repo>.git`.
5
+
6
+#### Scenario: Clone over SSH
7
+- **WHEN** a user with a registered SSH key clones a repository they can read over SSH
8
+- **THEN** the clone succeeds
9
+
10
+#### Scenario: Push over SSH
11
+- **WHEN** the repository owner pushes over SSH with a registered key
12
+- **THEN** the push succeeds and refs are updated
13
+
14
+#### Scenario: Non-git command rejected
15
+- **WHEN** an SSH client requests a shell or any command other than `git-upload-pack`/`git-receive-pack`
16
+- **THEN** the server rejects the request without executing anything
17
+
18
+### Requirement: Public-key authentication
19
+The system SHALL authenticate SSH connections exclusively by public key, resolving the presented key against registered user keys via fingerprint lookup. Password authentication MUST be disabled.
20
+
21
+#### Scenario: Registered key accepted
22
+- **WHEN** a client authenticates with a key registered to a user account
23
+- **THEN** the session is authenticated as that user
24
+
25
+#### Scenario: Unknown key rejected
26
+- **WHEN** a client authenticates with a key not registered to any user
27
+- **THEN** authentication fails
28
+
29
+### Requirement: SSH authorization matches HTTP authorization
30
+The system SHALL enforce the same per-repository access policy on SSH as on HTTP: read requires read permission, receive-pack requires write permission.
31
+
32
+#### Scenario: Read of private repository denied
33
+- **WHEN** an authenticated SSH user without read permission runs upload-pack on a private repository
34
+- **THEN** the operation is denied
35
+
36
+#### Scenario: Write without permission denied
37
+- **WHEN** an authenticated SSH user without write permission runs receive-pack
38
+- **THEN** the operation is denied and no refs change
39
+
40
+### Requirement: Stable host key
41
+The system SHALL generate an SSH host key on first startup and persist it on the data volume so the host identity remains stable across restarts.
42
+
43
+#### Scenario: Restart keeps host identity
44
+- **WHEN** the service restarts
45
+- **THEN** SSH clients with the previously recorded host key connect without a host-key-changed warning
ADD
openspec/changes/create-git-platform/specs/native-build/spec.md
+30 -0
@@ -0,0 +1,30 @@
1
+## ADDED Requirements
2
+
3
+### Requirement: Native image build
4
+The system SHALL compile to a GraalVM native image via the standard Quarkus native build (`mvn package -Dnative`). All required reflection, resource, and JNI configuration for JGit, MINA SSHD, and their crypto dependencies SHALL be included in the build.
5
+
6
+#### Scenario: Native build succeeds
7
+- **WHEN** the native build is executed
8
+- **THEN** it produces a runnable native binary without build-time errors
9
+
10
+### Requirement: Native runtime parity
11
+The native binary SHALL provide the same functionality as JVM mode: HTTP server, SSH server, OIDC login, database access via Flyway-migrated schema, and Git operations over both transports.
12
+
13
+#### Scenario: Native smoke test โ HTTP clone and push
14
+- **WHEN** the native binary runs against Postgres and a Git client clones and pushes over smart HTTP
15
+- **THEN** both operations succeed identically to JVM mode
16
+
17
+#### Scenario: Native smoke test โ SSH clone
18
+- **WHEN** a Git client clones over SSH from the running native binary
19
+- **THEN** the clone succeeds
20
+
21
+#### Scenario: Native startup
22
+- **WHEN** the native binary starts with a reachable database
23
+- **THEN** Flyway migrations run and HTTP and SSH ports are listening
24
+
25
+### Requirement: CI native verification
26
+The CI pipeline SHALL build the native image and run the native smoke tests (`@QuarkusIntegrationTest`) on every main-branch build, so native incompatibilities are caught at merge time, not release time.
27
+
28
+#### Scenario: Regression caught in CI
29
+- **WHEN** a change introduces a dependency or code path that breaks the native build or native smoke tests
30
+- **THEN** the CI build fails
ADD
openspec/changes/create-git-platform/specs/repo-management/spec.md
+52 -0
@@ -0,0 +1,52 @@
1
+## ADDED Requirements
2
+
3
+### Requirement: Create repository
4
+The system SHALL allow an authenticated user to create a repository with a name, optional description, and visibility (public or private). The system SHALL initialize a bare Git repository on the filesystem and persist its metadata in PostgreSQL. Repository names MUST be unique per owner and MUST match `[a-zA-Z0-9._-]+` (no path traversal characters).
5
+
6
+#### Scenario: Successful creation
7
+- **WHEN** an authenticated user submits a valid repository name and visibility
8
+- **THEN** a bare Git repository is initialized under the storage root at a UUID-based path
9
+- **AND** a `Repository` record with name, owner, visibility, and description is persisted
10
+- **AND** the repository is immediately clonable by authorized users
11
+
12
+#### Scenario: Duplicate name rejected
13
+- **WHEN** a user creates a repository with a name they already own
14
+- **THEN** the system rejects the request with a conflict error and no repository is created
15
+
16
+#### Scenario: Invalid name rejected
17
+- **WHEN** a user submits a repository name containing `/`, `..`, or other disallowed characters
18
+- **THEN** the system rejects the request with a validation error
19
+
20
+### Requirement: List repositories
21
+The system SHALL list repositories visible to the current user: their own repositories plus all public repositories. Anonymous users SHALL see only public repositories.
22
+
23
+#### Scenario: Authenticated listing
24
+- **WHEN** an authenticated user requests the repository list
25
+- **THEN** the response contains all repositories they own and all public repositories
26
+
27
+#### Scenario: Anonymous listing
28
+- **WHEN** an unauthenticated user requests the repository list
29
+- **THEN** the response contains only public repositories
30
+
31
+### Requirement: Delete repository
32
+The system SHALL allow the repository owner to delete a repository. Deletion SHALL remove both the database record and the bare repository from the filesystem. Deletion MUST require explicit confirmation of the repository name.
33
+
34
+#### Scenario: Owner deletes repository
35
+- **WHEN** the owner confirms deletion by typing the repository name
36
+- **THEN** the database record and the on-disk bare repository are removed
37
+- **AND** subsequent clone or fetch attempts fail with not-found
38
+
39
+#### Scenario: Non-owner cannot delete
40
+- **WHEN** a user who is not the owner attempts deletion
41
+- **THEN** the system denies the request with a forbidden error
42
+
43
+### Requirement: Repository name resolution
44
+The system SHALL resolve `<owner>/<repo>` names to on-disk storage paths exclusively through the database. Both HTTP and SSH transports MUST use the same resolution service.
45
+
46
+#### Scenario: Existing repository resolves
47
+- **WHEN** a transport resolves `alice/project.git`
48
+- **THEN** the service returns the filesystem path of the corresponding bare repository
49
+
50
+#### Scenario: Unknown repository
51
+- **WHEN** a transport resolves a name with no database record
52
+- **THEN** resolution fails with not-found and no filesystem probing occurs
ADD
openspec/changes/create-git-platform/specs/web-ui/spec.md
+55 -0
@@ -0,0 +1,55 @@
1
+## ADDED Requirements
2
+
3
+### Requirement: Repository overview page
4
+The system SHALL render a server-side (Qute) repository list page showing the repositories visible to the current user, with name, owner, visibility, and description.
5
+
6
+#### Scenario: List rendered for authenticated user
7
+- **WHEN** an authenticated user opens the repository list page
8
+- **THEN** the page shows their repositories and all public repositories
9
+
10
+### Requirement: File and tree browser
11
+The system SHALL render the file tree of a repository at a selected branch, tag, or commit, reading directly from the bare repository via JGit. Text file contents SHALL be rendered HTML-escaped; binary files SHALL be offered as downloads.
12
+
13
+#### Scenario: Browse directory
14
+- **WHEN** a user opens a directory path at a given ref
15
+- **THEN** the page lists the directory's files and subdirectories at that ref
16
+
17
+#### Scenario: View text file
18
+- **WHEN** a user opens a text file
19
+- **THEN** its content is displayed HTML-escaped
20
+
21
+#### Scenario: Binary file
22
+- **WHEN** a user opens a binary file
23
+- **THEN** the page offers a download link instead of inline rendering
24
+
25
+#### Scenario: Empty repository
26
+- **WHEN** a user opens a repository with no commits
27
+- **THEN** the page shows setup instructions including clone URLs instead of a file tree
28
+
29
+### Requirement: Commit history view
30
+The system SHALL render a paginated commit log for a selected ref, showing commit ID (abbreviated), message, author, and date.
31
+
32
+#### Scenario: Paginated log
33
+- **WHEN** a user opens the commit history of a branch with more commits than the page size
34
+- **THEN** the page shows one page of commits with navigation to older commits
35
+
36
+### Requirement: Branch and tag listing
37
+The system SHALL render the repository's branches and tags with links into the file browser at that ref.
38
+
39
+#### Scenario: Branches listed
40
+- **WHEN** a user opens the branches page
41
+- **THEN** all branches are listed with the default branch marked
42
+
43
+### Requirement: Clone URL display
44
+The system SHALL display both HTTP and SSH clone URLs on the repository page.
45
+
46
+#### Scenario: Clone URLs shown
47
+- **WHEN** a user views a repository page
48
+- **THEN** the HTTP URL (`https://<host>/git/<owner>/<repo>.git`) and SSH URL (`ssh://git@<host>:<port>/<owner>/<repo>.git`) are displayed
49
+
50
+### Requirement: UI access control
51
+The system SHALL deny access to private repository pages for users without read permission and SHALL hide mutation controls (delete, settings) from non-owners.
52
+
53
+#### Scenario: Private repository hidden
54
+- **WHEN** a user without read permission opens a private repository URL
55
+- **THEN** the system responds with not-found or forbidden, revealing no repository details
ADD
openspec/changes/create-git-platform/tasks.md
+62 -0
@@ -0,0 +1,62 @@
1
+## 1. Project Foundation
2
+
3
+- [x] 1.1 Add dependencies to the bootstrapped Quarkus project: `quarkus-qute`, `quarkus-undertow`, `quarkus-rest`, `quarkus-oidc`, `quarkus-jdbc-postgresql`, Panache Next, `quarkus-flyway`, `org.eclipse.jgit`, `org.eclipse.jgit.http.server`, `sshd-core`, `sshd-git`
4
+- [x] 1.2 Configure `application.properties`: datasource, Flyway, OIDC (Keycloak Dev Services for dev/test), storage root path, SSH port (default 2222)
5
+- [x] 1.3 Write Flyway `V1__init.sql`: `users`, `repositories`, `ssh_keys`, `access_tokens` tables (UUID PKs, unique constraints per design D5)
6
+- [x] 1.4 Create Panache Next entities `User`, `Repository`, `SshKey`, `AccessToken` with `WithId.AutoUUID`; failing persistence test first, then green
7
+
8
+## 2. Native Build Baseline (fail fast per design risk)
9
+
10
+- [x] 2.1 Add native-image config skeleton under `src/main/resources/META-INF/native-image/` for JGit and MINA SSHD
11
+- [x] 2.2 Verify `mvn package -Dnative` builds with all dependencies on the classpath (empty service, no features yet)
12
+- [x] 2.3 Add `@QuarkusIntegrationTest` smoke test scaffold that boots the binary and checks HTTP + SSH ports listening
13
+
14
+## 3. Repository Management
15
+
16
+- [x] 3.1 Test-first: `GitRepositoryService` โ create bare repo at UUID path, persist metadata, reject duplicate/invalid names
17
+- [x] 3.2 Test-first: name resolution `<owner>/<repo>` โ filesystem path via DB only; not-found for unknown names
18
+- [x] 3.3 Test-first: repository deletion โ removes DB record and on-disk repo, owner-only
19
+- [x] 3.4 Test-first: visibility-filtered repository listing (own + public; anonymous sees public only)
20
+- [x] 3.5 Implement `AccessPolicy` service: owner read/write, public world-readable, private owner-only (shared by all transports)
21
+
22
+## 4. Git Smart HTTP
23
+
24
+- [x] 4.1 Test-first (integration, real git client or JGit client): clone public repo anonymously over `/git/<owner>/<repo>.git`
25
+- [x] 4.2 Mount JGit `GitServlet` via `quarkus-undertow` at `/git/*` with `RepositoryResolver` backed by `GitRepositoryService`
26
+- [x] 4.3 Test-first: push requires auth โ anonymous push gets 401, no refs change
27
+- [x] 4.4 Implement HTTP Basic auth filter for `/git/*` accepting personal access tokens; wire `AccessPolicy` (401 unauthenticated private read, 403 no-write push)
28
+- [x] 4.5 Test-first: PAT lifecycle โ create (plaintext shown once, hash stored), authenticate, revoke (revoked token โ 401)
29
+- [x] 4.6 Implement `AccessToken` generation/revocation service with SHA-256 hashing
30
+
31
+## 5. Git SSH Access
32
+
33
+- [x] 5.1 Test-first (integration): clone over SSH with registered key succeeds; unknown key rejected
34
+- [x] 5.2 Implement `@ApplicationScoped` `SshServer` lifecycle bean: start on startup, stop on shutdown, configurable port, persistent host key on data volume
35
+- [x] 5.3 Implement `PublickeyAuthenticator` with fingerprint lookup against `ssh_keys` table
36
+- [x] 5.4 Wire `GitPackCommandFactory` to `GitRepositoryService` + `AccessPolicy`; reject non-git commands and shell requests
37
+- [x] 5.5 Test-first: SSH authorization parity โ private repo read denied without permission, receive-pack denied without write
38
+- [x] 5.6 Test: host key stable across restarts
39
+
40
+## 6. Auth & Accounts (UI side)
41
+
42
+- [x] 6.1 Test-first: OIDC login provisions `User` on first login (keyed by `sub`), updates profile on subsequent logins
43
+- [x] 6.2 Configure `quarkus-oidc` code flow, protected UI paths, logout endpoint
44
+- [x] 6.3 Test-first: SSH key management โ add valid key (fingerprint computed), reject malformed, reject duplicate, remove key stops SSH auth
45
+- [x] 6.4 Implement SSH key management pages (Qute forms) and PAT management page (create with one-time display, list, revoke)
46
+
47
+## 7. Web UI
48
+
49
+- [x] 7.1 Test-first: repository list page shows visible repos per user; anonymous sees only public
50
+- [x] 7.2 Implement Qute layout + repository list and create/delete pages (delete with name confirmation)
51
+- [x] 7.3 Test-first: file browser โ directory listing at ref, text blob escaped, binary blob download, empty repo shows setup instructions
52
+- [x] 7.4 Implement tree/blob browser with JGit `TreeWalk`; ref selector for branches/tags
53
+- [x] 7.5 Test-first: paginated commit log (abbreviated ID, message, author, date)
54
+- [x] 7.6 Implement commit log (`RevWalk`) and branches/tags pages with default-branch marker
55
+- [x] 7.7 Repository page shows HTTP and SSH clone URLs; mutation controls hidden from non-owners; private repos return not-found/forbidden to unauthorized users
56
+
57
+## 8. Native Build Completion & CI
58
+
59
+- [ ] 8.1 Extend native-image reflection/resource config for all features (Qute templates, OIDC, JGit pack machinery, MINA crypto); native build green
60
+- [ ] 8.2 Native integration smoke tests: HTTP clone+push, SSH clone, OIDC-protected page, Flyway migration on boot
61
+- [x] 8.3 CI pipeline: JVM tests + native build + native smoke tests on main-branch builds
62
+- [x] 8.4 README: setup, configuration (storage root, SSH port, OIDC, Postgres), clone URL schemes, TLS-termination requirement for PAT auth
ADD
openspec/config.yaml
+20 -0
@@ -0,0 +1,20 @@
1
+schema: spec-driven
2
+
3
+# Project context (optional)
4
+# This is shown to AI when creating artifacts.
5
+# Add your tech stack, conventions, style guides, domain knowledge, etc.
6
+# Example:
7
+# context: |
8
+# Tech stack: TypeScript, React, Node.js
9
+# We use conventional commits
10
+# Domain: e-commerce platform
11
+
12
+# Per-artifact rules (optional)
13
+# Add custom rules for specific artifacts.
14
+# Example:
15
+# rules:
16
+# proposal:
17
+# - Keep proposals under 500 words
18
+# - Always include a "Non-goals" section
19
+# tasks:
20
+# - Break tasks into chunks of max 2 hours
ADD
pom.xml
+211 -0
@@ -0,0 +1,211 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
4
+ <modelVersion>4.0.0</modelVersion>
5
+ <groupId>de.workaround</groupId>
6
+ <artifactId>git-shark</artifactId>
7
+ <version>1.0-SNAPSHOT</version>
8
+ <packaging>quarkus</packaging>
9
+
10
+ <properties>
11
+ <compiler-plugin.version>3.15.0</compiler-plugin.version>
12
+ <maven.compiler.release>21</maven.compiler.release>
13
+ <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
14
+ <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
15
+ <quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
16
+ <quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
17
+ <quarkus.platform.version>3.36.2</quarkus.platform.version>
18
+ <jgit.version>7.3.0.202506031305-r</jgit.version>
19
+ <sshd.version>2.18.0</sshd.version>
20
+ <bouncycastle.version>1.84</bouncycastle.version>
21
+ <skipITs>true</skipITs>
22
+ <surefire-plugin.version>3.5.6</surefire-plugin.version>
23
+ </properties>
24
+
25
+ <dependencyManagement>
26
+ <dependencies>
27
+ <dependency>
28
+ <groupId>${quarkus.platform.group-id}</groupId>
29
+ <artifactId>${quarkus.platform.artifact-id}</artifactId>
30
+ <version>${quarkus.platform.version}</version>
31
+ <type>pom</type>
32
+ <scope>import</scope>
33
+ </dependency>
34
+ </dependencies>
35
+ </dependencyManagement>
36
+
37
+ <dependencies>
38
+ <dependency>
39
+ <groupId>io.quarkus</groupId>
40
+ <artifactId>quarkus-rest</artifactId>
41
+ </dependency>
42
+ <dependency>
43
+ <groupId>io.quarkus</groupId>
44
+ <artifactId>quarkus-flyway</artifactId>
45
+ </dependency>
46
+ <dependency>
47
+ <groupId>io.quarkus</groupId>
48
+ <artifactId>quarkus-rest-qute</artifactId>
49
+ </dependency>
50
+ <dependency>
51
+ <groupId>io.quarkus</groupId>
52
+ <artifactId>quarkus-smallrye-openapi</artifactId>
53
+ </dependency>
54
+ <dependency>
55
+ <groupId>io.quarkus</groupId>
56
+ <artifactId>quarkus-rest-jackson</artifactId>
57
+ </dependency>
58
+ <dependency>
59
+ <groupId>io.quarkus</groupId>
60
+ <artifactId>quarkus-hibernate-panache-next</artifactId>
61
+ </dependency>
62
+ <dependency>
63
+ <groupId>io.quarkus</groupId>
64
+ <artifactId>quarkus-undertow</artifactId>
65
+ </dependency>
66
+ <dependency>
67
+ <groupId>io.quarkus</groupId>
68
+ <artifactId>quarkus-oidc</artifactId>
69
+ </dependency>
70
+ <dependency>
71
+ <groupId>org.eclipse.jgit</groupId>
72
+ <artifactId>org.eclipse.jgit</artifactId>
73
+ <version>${jgit.version}</version>
74
+ </dependency>
75
+ <dependency>
76
+ <groupId>org.eclipse.jgit</groupId>
77
+ <artifactId>org.eclipse.jgit.http.server</artifactId>
78
+ <version>${jgit.version}</version>
79
+ </dependency>
80
+ <dependency>
81
+ <groupId>org.apache.sshd</groupId>
82
+ <artifactId>sshd-core</artifactId>
83
+ <version>${sshd.version}</version>
84
+ </dependency>
85
+ <dependency>
86
+ <groupId>org.bouncycastle</groupId>
87
+ <artifactId>bcprov-jdk18on</artifactId>
88
+ <version>${bouncycastle.version}</version>
89
+ </dependency>
90
+ <dependency>
91
+ <groupId>org.bouncycastle</groupId>
92
+ <artifactId>bcpkix-jdk18on</artifactId>
93
+ <version>${bouncycastle.version}</version>
94
+ </dependency>
95
+ <dependency>
96
+ <groupId>org.bouncycastle</groupId>
97
+ <artifactId>bcutil-jdk18on</artifactId>
98
+ <version>${bouncycastle.version}</version>
99
+ </dependency>
100
+ <dependency>
101
+ <groupId>org.eclipse.jgit</groupId>
102
+ <artifactId>org.eclipse.jgit.ssh.apache</artifactId>
103
+ <version>${jgit.version}</version>
104
+ <scope>test</scope>
105
+ </dependency>
106
+ <dependency>
107
+ <groupId>io.quarkus</groupId>
108
+ <artifactId>quarkus-smallrye-health</artifactId>
109
+ </dependency>
110
+ <dependency>
111
+ <groupId>io.quarkus</groupId>
112
+ <artifactId>quarkus-jdbc-postgresql</artifactId>
113
+ </dependency>
114
+ <dependency>
115
+ <groupId>io.quarkus</groupId>
116
+ <artifactId>quarkus-arc</artifactId>
117
+ </dependency>
118
+ <dependency>
119
+ <groupId>io.quarkus</groupId>
120
+ <artifactId>quarkus-hibernate-orm</artifactId>
121
+ </dependency>
122
+ <dependency>
123
+ <groupId>io.quarkus</groupId>
124
+ <artifactId>quarkus-junit</artifactId>
125
+ <scope>test</scope>
126
+ </dependency>
127
+ <dependency>
128
+ <groupId>io.quarkus</groupId>
129
+ <artifactId>quarkus-test-security</artifactId>
130
+ <scope>test</scope>
131
+ </dependency>
132
+ <dependency>
133
+ <groupId>io.rest-assured</groupId>
134
+ <artifactId>rest-assured</artifactId>
135
+ <scope>test</scope>
136
+ </dependency>
137
+ </dependencies>
138
+
139
+ <build>
140
+ <plugins>
141
+ <plugin>
142
+ <groupId>${quarkus.platform.group-id}</groupId>
143
+ <artifactId>quarkus-maven-plugin</artifactId>
144
+ <version>${quarkus.platform.version}</version>
145
+ <extensions>true</extensions>
146
+ </plugin>
147
+ <plugin>
148
+ <artifactId>maven-compiler-plugin</artifactId>
149
+ <version>${compiler-plugin.version}</version>
150
+ <configuration>
151
+ <parameters>true</parameters>
152
+ <annotationProcessorPathsUseDepMgmt>true</annotationProcessorPathsUseDepMgmt>
153
+ <annotationProcessorPaths>
154
+ <path>
155
+ <groupId>org.hibernate.orm</groupId>
156
+ <artifactId>hibernate-processor</artifactId>
157
+ </path>
158
+ </annotationProcessorPaths>
159
+ </configuration>
160
+ </plugin>
161
+ <plugin>
162
+ <artifactId>maven-surefire-plugin</artifactId>
163
+ <version>${surefire-plugin.version}</version>
164
+ <configuration>
165
+ <argLine>@{argLine}</argLine>
166
+ <systemPropertyVariables>
167
+ <java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
168
+ <maven.home>${maven.home}</maven.home>
169
+ </systemPropertyVariables>
170
+ </configuration>
171
+ </plugin>
172
+ <plugin>
173
+ <artifactId>maven-failsafe-plugin</artifactId>
174
+ <version>${surefire-plugin.version}</version>
175
+ <executions>
176
+ <execution>
177
+ <goals>
178
+ <goal>integration-test</goal>
179
+ <goal>verify</goal>
180
+ </goals>
181
+ </execution>
182
+ </executions>
183
+ <configuration>
184
+ <argLine>@{argLine}</argLine>
185
+ <systemPropertyVariables>
186
+ <native.image.path>${project.build.directory}/${project.build.finalName}-runner
187
+ </native.image.path>
188
+ <java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
189
+ <maven.home>${maven.home}</maven.home>
190
+ </systemPropertyVariables>
191
+ </configuration>
192
+ </plugin>
193
+ </plugins>
194
+ </build>
195
+
196
+ <profiles>
197
+ <profile>
198
+ <id>native</id>
199
+ <activation>
200
+ <property>
201
+ <name>native</name>
202
+ </property>
203
+ </activation>
204
+ <properties>
205
+ <quarkus.package.jar.enabled>false</quarkus.package.jar.enabled>
206
+ <skipITs>false</skipITs>
207
+ <quarkus.native.enabled>true</quarkus.native.enabled>
208
+ </properties>
209
+ </profile>
210
+ </profiles>
211
+</project>
ADD
skills-lock.json
+47 -0
@@ -0,0 +1,47 @@
1
+{
2
+ "version": 1,
3
+ "skills": {
4
+ "cavecrew": {
5
+ "source": "JuliusBrussee/caveman",
6
+ "sourceType": "github",
7
+ "skillPath": "skills/cavecrew/SKILL.md",
8
+ "computedHash": "505d836228d1c5e14834ff5d62aad72390c7d27f79c6aa7f9a7a55ed6606d6a2"
9
+ },
10
+ "caveman": {
11
+ "source": "JuliusBrussee/caveman",
12
+ "sourceType": "github",
13
+ "skillPath": "skills/caveman/SKILL.md",
14
+ "computedHash": "dfbf85749fd474feeb0bbe60c779795ecd5dbec0083299b56e68916bc3ddd8c9"
15
+ },
16
+ "caveman-commit": {
17
+ "source": "JuliusBrussee/caveman",
18
+ "sourceType": "github",
19
+ "skillPath": "skills/caveman-commit/SKILL.md",
20
+ "computedHash": "f456ea0564875e46858890ac39ee701ecb9c601c72a2da1e6ce6bd5f9fc5d817"
21
+ },
22
+ "caveman-compress": {
23
+ "source": "JuliusBrussee/caveman",
24
+ "sourceType": "github",
25
+ "skillPath": "skills/caveman-compress/SKILL.md",
26
+ "computedHash": "b8cbfec00b620f0944960a9bb4072f262cf816c1d3c82e6dbded35c30b4c5d0b"
27
+ },
28
+ "caveman-help": {
29
+ "source": "JuliusBrussee/caveman",
30
+ "sourceType": "github",
31
+ "skillPath": "skills/caveman-help/SKILL.md",
32
+ "computedHash": "2c21437427e98df1eacabaa0b055b5256a308b2191feea3ca2df6a9418e76cb1"
33
+ },
34
+ "caveman-review": {
35
+ "source": "JuliusBrussee/caveman",
36
+ "sourceType": "github",
37
+ "skillPath": "skills/caveman-review/SKILL.md",
38
+ "computedHash": "fb7214a1c5793bae6ba8b1be4329e2e6f40dbec6dd911dfb335ad29f09c316a1"
39
+ },
40
+ "caveman-stats": {
41
+ "source": "JuliusBrussee/caveman",
42
+ "sourceType": "github",
43
+ "skillPath": "skills/caveman-stats/SKILL.md",
44
+ "computedHash": "47ce2de3d6cb39a75047b5c962e4eb3da15594e7397c94103e9a104d42626553"
45
+ }
46
+ }
47
+}
ADD
src/main/docker/Dockerfile.jvm
+100 -0
@@ -0,0 +1,100 @@
1
+####
2
+# This Dockerfile is used in order to build a container that runs the Quarkus application in JVM mode
3
+#
4
+# Before building the container image run:
5
+#
6
+# ./mvnw package
7
+#
8
+# Then, build the image with:
9
+#
10
+# docker build -f src/main/docker/Dockerfile.jvm -t quarkus/git-shark-jvm .
11
+#
12
+# Then run the container using:
13
+#
14
+# docker run -i --rm -p 8080:8080 quarkus/git-shark-jvm
15
+#
16
+# If you want to include the debug port into your docker image
17
+# you will have to expose the debug port (default 5005 being the default) like this : EXPOSE 8080 5005.
18
+# Additionally you will have to set -e JAVA_DEBUG=true and -e JAVA_DEBUG_PORT=*:5005
19
+# when running the container
20
+#
21
+# Then run the container using :
22
+#
23
+# docker run -i --rm -p 8080:8080 quarkus/git-shark-jvm
24
+#
25
+# This image uses the `run-java.sh` script to run the application.
26
+# This scripts computes the command line to execute your Java application, and
27
+# includes memory/GC tuning.
28
+# You can configure the behavior using the following environment properties:
29
+# - JAVA_OPTS: JVM options passed to the `java` command (example: "-verbose:class") - Be aware that this will override
30
+# the default JVM options, use `JAVA_OPTS_APPEND` to append options
31
+# - JAVA_OPTS_APPEND: User specified Java options to be appended to generated options
32
+# in JAVA_OPTS (example: "-Dsome.property=foo")
33
+# - JAVA_MAX_MEM_RATIO: Is used when no `-Xmx` option is given in JAVA_OPTS. This is
34
+# used to calculate a default maximal heap memory based on a containers restriction.
35
+# If used in a container without any memory constraints for the container then this
36
+# option has no effect. If there is a memory constraint then `-Xmx` is set to a ratio
37
+# of the container available memory as set here. The default is `50` which means 50%
38
+# of the available memory is used as an upper boundary. You can skip this mechanism by
39
+# setting this value to `0` in which case no `-Xmx` option is added.
40
+# - JAVA_INITIAL_MEM_RATIO: Is used when no `-Xms` option is given in JAVA_OPTS. This
41
+# is used to calculate a default initial heap memory based on the maximum heap memory.
42
+# If used in a container without any memory constraints for the container then this
43
+# option has no effect. If there is a memory constraint then `-Xms` is set to a ratio
44
+# of the `-Xmx` memory as set here. The default is `25` which means 25% of the `-Xmx`
45
+# is used as the initial heap size. You can skip this mechanism by setting this value
46
+# to `0` in which case no `-Xms` option is added (example: "25")
47
+# - JAVA_MAX_INITIAL_MEM: Is used when no `-Xms` option is given in JAVA_OPTS.
48
+# This is used to calculate the maximum value of the initial heap memory. If used in
49
+# a container without any memory constraints for the container then this option has
50
+# no effect. If there is a memory constraint then `-Xms` is limited to the value set
51
+# here. The default is 4096MB which means the calculated value of `-Xms` never will
52
+# be greater than 4096MB. The value of this variable is expressed in MB (example: "4096")
53
+# - JAVA_DIAGNOSTICS: Set this to get some diagnostics information to standard output
54
+# when things are happening. This option, if set to true, will set
55
+# `-XX:+UnlockDiagnosticVMOptions`. Disabled by default (example: "true").
56
+# - JAVA_DEBUG: If set remote debugging will be switched on. Disabled by default (example:
57
+# true").
58
+# - JAVA_DEBUG_PORT: Port used for remote debugging. Defaults to 5005 (example: "8787").
59
+# - CONTAINER_CORE_LIMIT: A calculated core limit as described in
60
+# https://www.kernel.org/doc/Documentation/scheduler/sched-bwc.txt. (example: "2")
61
+# - CONTAINER_MAX_MEMORY: Memory limit given to the container (example: "1024").
62
+# - GC_MIN_HEAP_FREE_RATIO: Minimum percentage of heap free after GC to avoid expansion.
63
+# (example: "20")
64
+# - GC_MAX_HEAP_FREE_RATIO: Maximum percentage of heap free after GC to avoid shrinking.
65
+# (example: "40")
66
+# - GC_TIME_RATIO: Specifies the ratio of the time spent outside the garbage collection.
67
+# (example: "4")
68
+# - GC_ADAPTIVE_SIZE_POLICY_WEIGHT: The weighting given to the current GC time versus
69
+# previous GC times. (example: "90")
70
+# - GC_METASPACE_SIZE: The initial metaspace size. (example: "20")
71
+# - GC_MAX_METASPACE_SIZE: The maximum metaspace size. (example: "100")
72
+# - GC_CONTAINER_OPTIONS: Specify Java GC to use. The value of this variable should
73
+# contain the necessary JRE command-line options to specify the required GC, which
74
+# will override the default of `-XX:+UseParallelGC` (example: -XX:+UseG1GC).
75
+# - HTTPS_PROXY: The location of the https proxy. (example: "myuser@127.0.0.1:8080")
76
+# - HTTP_PROXY: The location of the http proxy. (example: "myuser@127.0.0.1:8080")
77
+# - NO_PROXY: A comma separated lists of hosts, IP addresses or domains that can be
78
+# accessed directly. (example: "foo.example.com,bar.example.com")
79
+#
80
+# You can find more information about the UBI base runtime images and their configuration here:
81
+# https://rh-openjdk.github.io/redhat-openjdk-containers/
82
+###
83
+FROM registry.access.redhat.com/ubi9/openjdk-21-runtime:1.24
84
+
85
+ENV LANGUAGE='en_US:en'
86
+
87
+
88
+# We make four distinct layers so if there are application changes the library layers can be re-used
89
+COPY --chown=185 target/quarkus-app/lib/ /deployments/lib/
90
+COPY --chown=185 target/quarkus-app/*.jar /deployments/
91
+COPY --chown=185 target/quarkus-app/app/ /deployments/app/
92
+COPY --chown=185 target/quarkus-app/quarkus/ /deployments/quarkus/
93
+
94
+EXPOSE 8080
95
+USER 185
96
+ENV JAVA_OPTS_APPEND="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager"
97
+ENV JAVA_APP_JAR="/deployments/quarkus-run.jar"
98
+
99
+ENTRYPOINT [ "/opt/jboss/container/java/run/run-java.sh" ]
100
+
ADD
src/main/docker/Dockerfile.legacy-jar
+96 -0
@@ -0,0 +1,96 @@
1
+####
2
+# This Dockerfile is used in order to build a container that runs the Quarkus application in JVM mode
3
+#
4
+# Before building the container image run:
5
+#
6
+# ./mvnw package -Dquarkus.package.jar.type=legacy-jar
7
+#
8
+# Then, build the image with:
9
+#
10
+# docker build -f src/main/docker/Dockerfile.legacy-jar -t quarkus/git-shark-legacy-jar .
11
+#
12
+# Then run the container using:
13
+#
14
+# docker run -i --rm -p 8080:8080 quarkus/git-shark-legacy-jar
15
+#
16
+# If you want to include the debug port into your docker image
17
+# you will have to expose the debug port (default 5005 being the default) like this : EXPOSE 8080 5005.
18
+# Additionally you will have to set -e JAVA_DEBUG=true and -e JAVA_DEBUG_PORT=*:5005
19
+# when running the container
20
+#
21
+# Then run the container using :
22
+#
23
+# docker run -i --rm -p 8080:8080 quarkus/git-shark-legacy-jar
24
+#
25
+# This image uses the `run-java.sh` script to run the application.
26
+# This scripts computes the command line to execute your Java application, and
27
+# includes memory/GC tuning.
28
+# You can configure the behavior using the following environment properties:
29
+# - JAVA_OPTS: JVM options passed to the `java` command (example: "-verbose:class") - Be aware that this will override
30
+# the default JVM options, use `JAVA_OPTS_APPEND` to append options
31
+# - JAVA_OPTS_APPEND: User specified Java options to be appended to generated options
32
+# in JAVA_OPTS (example: "-Dsome.property=foo")
33
+# - JAVA_MAX_MEM_RATIO: Is used when no `-Xmx` option is given in JAVA_OPTS. This is
34
+# used to calculate a default maximal heap memory based on a containers restriction.
35
+# If used in a container without any memory constraints for the container then this
36
+# option has no effect. If there is a memory constraint then `-Xmx` is set to a ratio
37
+# of the container available memory as set here. The default is `50` which means 50%
38
+# of the available memory is used as an upper boundary. You can skip this mechanism by
39
+# setting this value to `0` in which case no `-Xmx` option is added.
40
+# - JAVA_INITIAL_MEM_RATIO: Is used when no `-Xms` option is given in JAVA_OPTS. This
41
+# is used to calculate a default initial heap memory based on the maximum heap memory.
42
+# If used in a container without any memory constraints for the container then this
43
+# option has no effect. If there is a memory constraint then `-Xms` is set to a ratio
44
+# of the `-Xmx` memory as set here. The default is `25` which means 25% of the `-Xmx`
45
+# is used as the initial heap size. You can skip this mechanism by setting this value
46
+# to `0` in which case no `-Xms` option is added (example: "25")
47
+# - JAVA_MAX_INITIAL_MEM: Is used when no `-Xms` option is given in JAVA_OPTS.
48
+# This is used to calculate the maximum value of the initial heap memory. If used in
49
+# a container without any memory constraints for the container then this option has
50
+# no effect. If there is a memory constraint then `-Xms` is limited to the value set
51
+# here. The default is 4096MB which means the calculated value of `-Xms` never will
52
+# be greater than 4096MB. The value of this variable is expressed in MB (example: "4096")
53
+# - JAVA_DIAGNOSTICS: Set this to get some diagnostics information to standard output
54
+# when things are happening. This option, if set to true, will set
55
+# `-XX:+UnlockDiagnosticVMOptions`. Disabled by default (example: "true").
56
+# - JAVA_DEBUG: If set remote debugging will be switched on. Disabled by default (example:
57
+# true").
58
+# - JAVA_DEBUG_PORT: Port used for remote debugging. Defaults to 5005 (example: "8787").
59
+# - CONTAINER_CORE_LIMIT: A calculated core limit as described in
60
+# https://www.kernel.org/doc/Documentation/scheduler/sched-bwc.txt. (example: "2")
61
+# - CONTAINER_MAX_MEMORY: Memory limit given to the container (example: "1024").
62
+# - GC_MIN_HEAP_FREE_RATIO: Minimum percentage of heap free after GC to avoid expansion.
63
+# (example: "20")
64
+# - GC_MAX_HEAP_FREE_RATIO: Maximum percentage of heap free after GC to avoid shrinking.
65
+# (example: "40")
66
+# - GC_TIME_RATIO: Specifies the ratio of the time spent outside the garbage collection.
67
+# (example: "4")
68
+# - GC_ADAPTIVE_SIZE_POLICY_WEIGHT: The weighting given to the current GC time versus
69
+# previous GC times. (example: "90")
70
+# - GC_METASPACE_SIZE: The initial metaspace size. (example: "20")
71
+# - GC_MAX_METASPACE_SIZE: The maximum metaspace size. (example: "100")
72
+# - GC_CONTAINER_OPTIONS: Specify Java GC to use. The value of this variable should
73
+# contain the necessary JRE command-line options to specify the required GC, which
74
+# will override the default of `-XX:+UseParallelGC` (example: -XX:+UseG1GC).
75
+# - HTTPS_PROXY: The location of the https proxy. (example: "myuser@127.0.0.1:8080")
76
+# - HTTP_PROXY: The location of the http proxy. (example: "myuser@127.0.0.1:8080")
77
+# - NO_PROXY: A comma separated lists of hosts, IP addresses or domains that can be
78
+# accessed directly. (example: "foo.example.com,bar.example.com")
79
+#
80
+# You can find more information about the UBI base runtime images and their configuration here:
81
+# https://rh-openjdk.github.io/redhat-openjdk-containers/
82
+###
83
+FROM registry.access.redhat.com/ubi9/openjdk-21-runtime:1.24
84
+
85
+ENV LANGUAGE='en_US:en'
86
+
87
+
88
+COPY target/lib/* /deployments/lib/
89
+COPY target/*-runner.jar /deployments/quarkus-run.jar
90
+
91
+EXPOSE 8080
92
+USER 185
93
+ENV JAVA_OPTS_APPEND="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager"
94
+ENV JAVA_APP_JAR="/deployments/quarkus-run.jar"
95
+
96
+ENTRYPOINT [ "/opt/jboss/container/java/run/run-java.sh" ]
ADD
src/main/docker/Dockerfile.native
+29 -0
@@ -0,0 +1,29 @@
1
+####
2
+# This Dockerfile is used in order to build a container that runs the Quarkus application in native (no JVM) mode.
3
+#
4
+# Before building the container image run:
5
+#
6
+# ./mvnw package -Dnative
7
+#
8
+# Then, build the image with:
9
+#
10
+# docker build -f src/main/docker/Dockerfile.native -t quarkus/git-shark .
11
+#
12
+# Then run the container using:
13
+#
14
+# docker run -i --rm -p 8080:8080 quarkus/git-shark
15
+#
16
+# The ` registry.access.redhat.com/ubi9/ubi-minimal:9.7` base image is based on UBI 9.
17
+# To use UBI 8, switch to `quay.io/ubi8/ubi-minimal:8.10`.
18
+###
19
+FROM registry.access.redhat.com/ubi9/ubi-minimal:9.7
20
+WORKDIR /work/
21
+RUN chown 1001 /work \
22
+ && chmod "g+rwX" /work \
23
+ && chown 1001:root /work
24
+COPY --chown=1001:root --chmod=0755 target/*-runner /work/application
25
+
26
+EXPOSE 8080
27
+USER 1001
28
+
29
+ENTRYPOINT ["./application", "-Dquarkus.http.host=0.0.0.0"]
ADD
src/main/docker/Dockerfile.native-micro
+32 -0
@@ -0,0 +1,32 @@
1
+####
2
+# This Dockerfile is used in order to build a container that runs the Quarkus application in native (no JVM) mode.
3
+# It uses a micro base image, tuned for Quarkus native executables.
4
+# It reduces the size of the resulting container image.
5
+# Check https://quarkus.io/guides/quarkus-runtime-base-image for further information about this image.
6
+#
7
+# Before building the container image run:
8
+#
9
+# ./mvnw package -Dnative
10
+#
11
+# Then, build the image with:
12
+#
13
+# docker build -f src/main/docker/Dockerfile.native-micro -t quarkus/git-shark .
14
+#
15
+# Then run the container using:
16
+#
17
+# docker run -i --rm -p 8080:8080 quarkus/git-shark
18
+#
19
+# The `quay.io/quarkus/ubi9-quarkus-micro-image:2.0` base image is based on UBI 9.
20
+# To use UBI 8, switch to `quay.io/quarkus/quarkus-micro-image:2.0`.
21
+###
22
+FROM quay.io/quarkus/ubi9-quarkus-micro-image:2.0
23
+WORKDIR /work/
24
+RUN chown 1001 /work \
25
+ && chmod "g+rwX" /work \
26
+ && chown 1001:root /work
27
+COPY --chown=1001:root --chmod=0755 target/*-runner /work/application
28
+
29
+EXPOSE 8080
30
+USER 1001
31
+
32
+ENTRYPOINT ["./application", "-Dquarkus.http.host=0.0.0.0"]
ADD
src/main/java/de/workaround/MyLivenessCheck.java
+17 -0
@@ -0,0 +1,17 @@
1
+package de.workaround;
2
+
3
+import org.eclipse.microprofile.health.HealthCheck;
4
+import org.eclipse.microprofile.health.HealthCheckResponse;
5
+import org.eclipse.microprofile.health.Liveness;
6
+
7
+@Liveness
8
+public class MyLivenessCheck implements HealthCheck
9
+{
10
+
11
+ @Override
12
+ public HealthCheckResponse call()
13
+ {
14
+ return HealthCheckResponse.up("alive");
15
+ }
16
+
17
+}
ADD
src/main/java/de/workaround/account/CurrentUser.java
+71 -0
@@ -0,0 +1,71 @@
1
+package de.workaround.account;
2
+
3
+import de.workaround.model.User;
4
+import io.quarkus.security.identity.SecurityIdentity;
5
+import jakarta.enterprise.context.RequestScoped;
6
+import jakarta.inject.Inject;
7
+import org.eclipse.microprofile.jwt.JsonWebToken;
8
+
9
+/**
10
+ * Resolves the logged-in UI user, provisioning the local record from token claims on access.
11
+ * Returns null for anonymous requests.
12
+ */
13
+@RequestScoped
14
+public class CurrentUser
15
+{
16
+ @Inject
17
+ SecurityIdentity identity;
18
+
19
+ @Inject
20
+ UserProvisioningService provisioning;
21
+
22
+ private User cached;
23
+
24
+ public User get()
25
+ {
26
+ if (identity.isAnonymous())
27
+ {
28
+ return null;
29
+ }
30
+ if (cached == null)
31
+ {
32
+ cached = provision();
33
+ }
34
+ return cached;
35
+ }
36
+
37
+ public User require()
38
+ {
39
+ User user = get();
40
+ if (user == null)
41
+ {
42
+ throw new de.workaround.git.ForbiddenOperationException("Authentication required");
43
+ }
44
+ return user;
45
+ }
46
+
47
+ private User provision()
48
+ {
49
+ String name = identity.getPrincipal().getName();
50
+ if (identity.getPrincipal() instanceof JsonWebToken jwt)
51
+ {
52
+ String sub = jwt.getSubject() != null ? jwt.getSubject() : name;
53
+ String username = firstNonNull(jwt.getClaim("preferred_username"), name, sub);
54
+ return provisioning.provision(sub, username, jwt.getClaim("name"), jwt.getClaim("email"));
55
+ }
56
+ return provisioning.provision(name, name, null, null);
57
+ }
58
+
59
+ private static String firstNonNull(String... values)
60
+ {
61
+ for (String value : values)
62
+ {
63
+ if (value != null && !value.isBlank())
64
+ {
65
+ return value;
66
+ }
67
+ }
68
+ return null;
69
+ }
70
+
71
+}
ADD
src/main/java/de/workaround/account/DuplicateSshKeyException.java
+10 -0
@@ -0,0 +1,10 @@
1
+package de.workaround.account;
2
+
3
+public class DuplicateSshKeyException extends RuntimeException
4
+{
5
+ public DuplicateSshKeyException(String fingerprint)
6
+ {
7
+ super("SSH key already registered: " + fingerprint);
8
+ }
9
+
10
+}
ADD
src/main/java/de/workaround/account/InvalidSshKeyException.java
+15 -0
@@ -0,0 +1,15 @@
1
+package de.workaround.account;
2
+
3
+public class InvalidSshKeyException extends RuntimeException
4
+{
5
+ public InvalidSshKeyException(String message)
6
+ {
7
+ super(message);
8
+ }
9
+
10
+ public InvalidSshKeyException(String message, Throwable cause)
11
+ {
12
+ super(message, cause);
13
+ }
14
+
15
+}
ADD
src/main/java/de/workaround/account/SettingsResource.java
+103 -0
@@ -0,0 +1,103 @@
1
+package de.workaround.account;
2
+
3
+import java.net.URI;
4
+import java.util.List;
5
+import java.util.UUID;
6
+
7
+import de.workaround.http.AccessTokenService;
8
+import de.workaround.model.AccessToken;
9
+import de.workaround.model.SshKey;
10
+import io.quarkus.qute.CheckedTemplate;
11
+import io.quarkus.qute.TemplateInstance;
12
+import jakarta.inject.Inject;
13
+import jakarta.ws.rs.Consumes;
14
+import jakarta.ws.rs.FormParam;
15
+import jakarta.ws.rs.GET;
16
+import jakarta.ws.rs.POST;
17
+import jakarta.ws.rs.Path;
18
+import jakarta.ws.rs.PathParam;
19
+import jakarta.ws.rs.Produces;
20
+import jakarta.ws.rs.core.MediaType;
21
+import jakarta.ws.rs.core.Response;
22
+
23
+@Path("/settings")
24
+@Produces(MediaType.TEXT_HTML)
25
+public class SettingsResource
26
+{
27
+ @CheckedTemplate
28
+ static class Templates
29
+ {
30
+ static native TemplateInstance keys(List<SshKey> keys, String error);
31
+
32
+ static native TemplateInstance tokens(List<AccessToken> tokens);
33
+
34
+ static native TemplateInstance tokenCreated(String plaintext);
35
+ }
36
+
37
+ @Inject
38
+ CurrentUser currentUser;
39
+
40
+ @Inject
41
+ SshKeyService keyService;
42
+
43
+ @Inject
44
+ AccessTokenService tokenService;
45
+
46
+ @GET
47
+ @Path("/keys")
48
+ public TemplateInstance keys()
49
+ {
50
+ return Templates.keys(keyService.list(currentUser.require()), null);
51
+ }
52
+
53
+ @POST
54
+ @Path("/keys")
55
+ @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
56
+ public Response addKey(@FormParam("title") String title, @FormParam("key") String key)
57
+ {
58
+ try
59
+ {
60
+ keyService.add(currentUser.require(), title, key);
61
+ return Response.seeOther(URI.create("/settings/keys")).build();
62
+ }
63
+ catch (InvalidSshKeyException | DuplicateSshKeyException e)
64
+ {
65
+ return Response.status(Response.Status.BAD_REQUEST)
66
+ .entity(Templates.keys(keyService.list(currentUser.require()), e.getMessage()))
67
+ .build();
68
+ }
69
+ }
70
+
71
+ @POST
72
+ @Path("/keys/{id}/delete")
73
+ public Response removeKey(@PathParam("id") UUID id)
74
+ {
75
+ keyService.remove(currentUser.require(), id);
76
+ return Response.seeOther(URI.create("/settings/keys")).build();
77
+ }
78
+
79
+ @GET
80
+ @Path("/tokens")
81
+ public TemplateInstance tokens()
82
+ {
83
+ return Templates.tokens(tokenService.list(currentUser.require()));
84
+ }
85
+
86
+ @POST
87
+ @Path("/tokens")
88
+ @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
89
+ public TemplateInstance createToken(@FormParam("label") String label)
90
+ {
91
+ AccessTokenService.CreatedToken created = tokenService.create(currentUser.require(), label);
92
+ return Templates.tokenCreated(created.plaintext());
93
+ }
94
+
95
+ @POST
96
+ @Path("/tokens/{id}/revoke")
97
+ public Response revokeToken(@PathParam("id") UUID id)
98
+ {
99
+ tokenService.revoke(currentUser.require(), id);
100
+ return Response.seeOther(URI.create("/settings/tokens")).build();
101
+ }
102
+
103
+}
ADD
src/main/java/de/workaround/account/SshKeyService.java
+83 -0
@@ -0,0 +1,83 @@
1
+package de.workaround.account;
2
+
3
+import java.security.PublicKey;
4
+import java.util.List;
5
+import java.util.UUID;
6
+
7
+import org.apache.sshd.common.config.keys.AuthorizedKeyEntry;
8
+import org.apache.sshd.common.config.keys.KeyUtils;
9
+import org.apache.sshd.common.digest.BuiltinDigests;
10
+
11
+import de.workaround.git.ForbiddenOperationException;
12
+import de.workaround.model.SshKey;
13
+import de.workaround.model.User;
14
+import jakarta.enterprise.context.ApplicationScoped;
15
+import jakarta.inject.Inject;
16
+import jakarta.transaction.Transactional;
17
+
18
+/**
19
+ * Manages user SSH public keys: validates OpenSSH-format key material, computes the SHA-256
20
+ * fingerprint used by the SSH authenticator, and enforces global key uniqueness.
21
+ */
22
+@ApplicationScoped
23
+public class SshKeyService
24
+{
25
+ @Inject
26
+ SshKey.Repo sshKeys;
27
+
28
+ @Transactional
29
+ public SshKey add(User user, String title, String openSshKeyText)
30
+ {
31
+ String fingerprint = fingerprintOf(openSshKeyText);
32
+ if (sshKeys.findByFingerprint(fingerprint).isPresent())
33
+ {
34
+ throw new DuplicateSshKeyException(fingerprint);
35
+ }
36
+ SshKey key = new SshKey();
37
+ key.user = user;
38
+ key.title = title;
39
+ key.publicKey = openSshKeyText.trim();
40
+ key.fingerprint = fingerprint;
41
+ key.persist();
42
+ return key;
43
+ }
44
+
45
+ @Transactional
46
+ public void remove(User actor, UUID keyId)
47
+ {
48
+ SshKey key = sshKeys.findById(keyId);
49
+ if (key == null)
50
+ {
51
+ return;
52
+ }
53
+ if (!key.user.id.equals(actor.id))
54
+ {
55
+ throw new ForbiddenOperationException("Only the owner may remove an SSH key");
56
+ }
57
+ sshKeys.deleteById(keyId);
58
+ }
59
+
60
+ public List<SshKey> list(User user)
61
+ {
62
+ return sshKeys.findByUser(user);
63
+ }
64
+
65
+ private static String fingerprintOf(String openSshKeyText)
66
+ {
67
+ if (openSshKeyText == null || openSshKeyText.isBlank())
68
+ {
69
+ throw new InvalidSshKeyException("SSH key must not be empty");
70
+ }
71
+ try
72
+ {
73
+ AuthorizedKeyEntry entry = AuthorizedKeyEntry.parseAuthorizedKeyEntry(openSshKeyText.trim());
74
+ PublicKey publicKey = entry.resolvePublicKey(null, null, null);
75
+ return KeyUtils.getFingerPrint(BuiltinDigests.sha256, publicKey);
76
+ }
77
+ catch (Exception e)
78
+ {
79
+ throw new InvalidSshKeyException("Not a valid OpenSSH public key", e);
80
+ }
81
+ }
82
+
83
+}
ADD
src/main/java/de/workaround/account/UserProvisioningService.java
+33 -0
@@ -0,0 +1,33 @@
1
+package de.workaround.account;
2
+
3
+import de.workaround.model.User;
4
+import jakarta.enterprise.context.ApplicationScoped;
5
+import jakarta.inject.Inject;
6
+import jakarta.transaction.Transactional;
7
+
8
+/**
9
+ * Creates or updates the local user record from OIDC token claims. The OIDC subject is the
10
+ * stable identity; username, display name and email follow the identity provider.
11
+ */
12
+@ApplicationScoped
13
+public class UserProvisioningService
14
+{
15
+ @Inject
16
+ User.Repo users;
17
+
18
+ @Transactional
19
+ public User provision(String oidcSub, String username, String displayName, String email)
20
+ {
21
+ User user = users.findByOidcSubOptional(oidcSub).orElseGet(() -> {
22
+ User created = new User();
23
+ created.oidcSub = oidcSub;
24
+ created.username = username;
25
+ created.persist();
26
+ return created;
27
+ });
28
+ user.displayName = displayName;
29
+ user.email = email;
30
+ return user;
31
+ }
32
+
33
+}
ADD
src/main/java/de/workaround/git/AccessPolicy.java
+29 -0
@@ -0,0 +1,29 @@
1
+package de.workaround.git;
2
+
3
+import de.workaround.model.Repository;
4
+import de.workaround.model.User;
5
+import jakarta.enterprise.context.ApplicationScoped;
6
+
7
+/**
8
+ * Single authorization rule set shared by HTTP, SSH and UI: the owner has full access,
9
+ * public repositories are world-readable, private repositories are owner-only.
10
+ */
11
+@ApplicationScoped
12
+public class AccessPolicy
13
+{
14
+ public boolean canRead(User user, Repository repository)
15
+ {
16
+ return repository.visibility == Repository.Visibility.PUBLIC || isOwner(user, repository);
17
+ }
18
+
19
+ public boolean canWrite(User user, Repository repository)
20
+ {
21
+ return isOwner(user, repository);
22
+ }
23
+
24
+ private static boolean isOwner(User user, Repository repository)
25
+ {
26
+ return user != null && user.id != null && user.id.equals(repository.owner.id);
27
+ }
28
+
29
+}
ADD
src/main/java/de/workaround/git/ForbiddenOperationException.java
+10 -0
@@ -0,0 +1,10 @@
1
+package de.workaround.git;
2
+
3
+public class ForbiddenOperationException extends RuntimeException
4
+{
5
+ public ForbiddenOperationException(String message)
6
+ {
7
+ super(message);
8
+ }
9
+
10
+}
ADD
src/main/java/de/workaround/git/GitBrowseService.java
+241 -0
@@ -0,0 +1,241 @@
1
+package de.workaround.git;
2
+
3
+import java.io.IOException;
4
+import java.io.UncheckedIOException;
5
+import java.nio.file.Path;
6
+import java.time.Instant;
7
+import java.util.ArrayList;
8
+import java.util.Comparator;
9
+import java.util.List;
10
+import java.util.Optional;
11
+
12
+import org.eclipse.jgit.diff.RawText;
13
+import org.eclipse.jgit.lib.Constants;
14
+import org.eclipse.jgit.lib.ObjectId;
15
+import org.eclipse.jgit.lib.Ref;
16
+import org.eclipse.jgit.lib.Repository;
17
+import org.eclipse.jgit.revwalk.RevCommit;
18
+import org.eclipse.jgit.revwalk.RevWalk;
19
+import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
20
+import org.eclipse.jgit.treewalk.TreeWalk;
21
+import org.eclipse.jgit.treewalk.filter.PathFilter;
22
+
23
+import jakarta.enterprise.context.ApplicationScoped;
24
+
25
+/**
26
+ * Read-only views into bare repositories for the web UI. All data is read live from JGit;
27
+ * nothing is duplicated into the database.
28
+ */
29
+@ApplicationScoped
30
+public class GitBrowseService
31
+{
32
+ private static final int MAX_DISPLAY_BYTES = 1024 * 1024;
33
+
34
+ public record TreeEntry(String name, String path, boolean directory)
35
+ {
36
+ }
37
+
38
+ public record BlobView(byte[] content, boolean binary)
39
+ {
40
+ }
41
+
42
+ public record CommitInfo(String id, String shortId, String message, String author, Instant date)
43
+ {
44
+ }
45
+
46
+ public record CommitPage(List<CommitInfo> commits, boolean hasNext)
47
+ {
48
+ }
49
+
50
+ public record BranchInfo(String name, boolean defaultBranch)
51
+ {
52
+ }
53
+
54
+ public boolean isEmpty(Path barePath)
55
+ {
56
+ try (Repository repo = open(barePath))
57
+ {
58
+ return repo.resolve(Constants.HEAD) == null;
59
+ }
60
+ catch (IOException e)
61
+ {
62
+ throw new UncheckedIOException(e);
63
+ }
64
+ }
65
+
66
+ public String defaultBranch(Path barePath)
67
+ {
68
+ try (Repository repo = open(barePath))
69
+ {
70
+ Ref head = repo.exactRef(Constants.HEAD);
71
+ if (head != null && head.isSymbolic())
72
+ {
73
+ return Repository.shortenRefName(head.getTarget().getName());
74
+ }
75
+ return "main";
76
+ }
77
+ catch (IOException e)
78
+ {
79
+ throw new UncheckedIOException(e);
80
+ }
81
+ }
82
+
83
+ public Optional<List<TreeEntry>> listTree(Path barePath, String ref, String path)
84
+ {
85
+ try (Repository repo = open(barePath); RevWalk revWalk = new RevWalk(repo))
86
+ {
87
+ RevCommit commit = resolveCommit(repo, revWalk, ref);
88
+ if (commit == null)
89
+ {
90
+ return Optional.empty();
91
+ }
92
+ List<TreeEntry> entries = new ArrayList<>();
93
+ try (TreeWalk walk = new TreeWalk(repo))
94
+ {
95
+ walk.addTree(commit.getTree());
96
+ walk.setRecursive(false);
97
+ if (!path.isEmpty())
98
+ {
99
+ walk.setFilter(PathFilter.create(path));
100
+ boolean entered = false;
101
+ while (walk.next())
102
+ {
103
+ if (walk.getPathString().equals(path))
104
+ {
105
+ if (!walk.isSubtree())
106
+ {
107
+ return Optional.empty();
108
+ }
109
+ walk.enterSubtree();
110
+ entered = true;
111
+ break;
112
+ }
113
+ if (walk.isSubtree())
114
+ {
115
+ walk.enterSubtree();
116
+ }
117
+ }
118
+ if (!entered)
119
+ {
120
+ return Optional.empty();
121
+ }
122
+ }
123
+ while (walk.next())
124
+ {
125
+ entries.add(new TreeEntry(walk.getNameString(), walk.getPathString(), walk.isSubtree()));
126
+ }
127
+ }
128
+ entries.sort(Comparator.comparing(TreeEntry::directory).reversed().thenComparing(TreeEntry::name));
129
+ return Optional.of(entries);
130
+ }
131
+ catch (IOException e)
132
+ {
133
+ throw new UncheckedIOException(e);
134
+ }
135
+ }
136
+
137
+ public Optional<BlobView> blob(Path barePath, String ref, String path)
138
+ {
139
+ try (Repository repo = open(barePath); RevWalk revWalk = new RevWalk(repo))
140
+ {
141
+ RevCommit commit = resolveCommit(repo, revWalk, ref);
142
+ if (commit == null)
143
+ {
144
+ return Optional.empty();
145
+ }
146
+ try (TreeWalk walk = TreeWalk.forPath(repo, path, commit.getTree()))
147
+ {
148
+ if (walk == null || walk.isSubtree())
149
+ {
150
+ return Optional.empty();
151
+ }
152
+ byte[] bytes = repo.open(walk.getObjectId(0)).getCachedBytes(MAX_DISPLAY_BYTES);
153
+ return Optional.of(new BlobView(bytes, RawText.isBinary(bytes, bytes.length, true)));
154
+ }
155
+ }
156
+ catch (IOException e)
157
+ {
158
+ throw new UncheckedIOException(e);
159
+ }
160
+ }
161
+
162
+ public Optional<CommitPage> commits(Path barePath, String ref, int page, int size)
163
+ {
164
+ try (Repository repo = open(barePath); RevWalk revWalk = new RevWalk(repo))
165
+ {
166
+ RevCommit start = resolveCommit(repo, revWalk, ref);
167
+ if (start == null)
168
+ {
169
+ return Optional.empty();
170
+ }
171
+ revWalk.markStart(start);
172
+ List<CommitInfo> commits = new ArrayList<>();
173
+ boolean hasNext = false;
174
+ int skip = page * size;
175
+ int index = 0;
176
+ for (RevCommit commit : revWalk)
177
+ {
178
+ if (index >= skip + size)
179
+ {
180
+ hasNext = true;
181
+ break;
182
+ }
183
+ if (index >= skip)
184
+ {
185
+ commits.add(new CommitInfo(
186
+ commit.name(),
187
+ commit.abbreviate(8).name(),
188
+ commit.getShortMessage(),
189
+ commit.getAuthorIdent().getName(),
190
+ commit.getAuthorIdent().getWhenAsInstant()));
191
+ }
192
+ index++;
193
+ }
194
+ return Optional.of(new CommitPage(commits, hasNext));
195
+ }
196
+ catch (IOException e)
197
+ {
198
+ throw new UncheckedIOException(e);
199
+ }
200
+ }
201
+
202
+ public List<BranchInfo> branches(Path barePath)
203
+ {
204
+ String defaultBranch = defaultBranch(barePath);
205
+ return refs(barePath, Constants.R_HEADS).stream()
206
+ .map(name -> new BranchInfo(name, name.equals(defaultBranch)))
207
+ .toList();
208
+ }
209
+
210
+ public List<String> tags(Path barePath)
211
+ {
212
+ return refs(barePath, Constants.R_TAGS);
213
+ }
214
+
215
+ private List<String> refs(Path barePath, String prefix)
216
+ {
217
+ try (Repository repo = open(barePath))
218
+ {
219
+ return repo.getRefDatabase().getRefsByPrefix(prefix).stream()
220
+ .map(ref -> ref.getName().substring(prefix.length()))
221
+ .sorted()
222
+ .toList();
223
+ }
224
+ catch (IOException e)
225
+ {
226
+ throw new UncheckedIOException(e);
227
+ }
228
+ }
229
+
230
+ private static RevCommit resolveCommit(Repository repo, RevWalk revWalk, String ref) throws IOException
231
+ {
232
+ ObjectId id = repo.resolve(ref + "^{commit}");
233
+ return id == null ? null : revWalk.parseCommit(id);
234
+ }
235
+
236
+ private static Repository open(Path barePath) throws IOException
237
+ {
238
+ return new FileRepositoryBuilder().setGitDir(barePath.toFile()).setMustExist(true).build();
239
+ }
240
+
241
+}
ADD
src/main/java/de/workaround/git/GitRepositoryService.java
+117 -0
@@ -0,0 +1,117 @@
1
+package de.workaround.git;
2
+
3
+import java.io.IOException;
4
+import java.io.UncheckedIOException;
5
+import java.nio.file.Path;
6
+import java.util.List;
7
+import java.util.Optional;
8
+import java.util.regex.Pattern;
9
+
10
+import org.eclipse.jgit.api.Git;
11
+import org.eclipse.jgit.api.errors.GitAPIException;
12
+import org.eclipse.jgit.lib.Constants;
13
+import org.eclipse.jgit.util.FileUtils;
14
+
15
+import de.workaround.model.Repository;
16
+import de.workaround.model.User;
17
+import jakarta.enterprise.context.ApplicationScoped;
18
+import jakarta.inject.Inject;
19
+import jakarta.transaction.Transactional;
20
+import org.eclipse.microprofile.config.inject.ConfigProperty;
21
+
22
+/**
23
+ * Owns the mapping between repository metadata (PostgreSQL) and bare repositories on disk.
24
+ * All transports (HTTP, SSH) and the UI resolve repositories exclusively through this service.
25
+ */
26
+@ApplicationScoped
27
+public class GitRepositoryService
28
+{
29
+ private static final Pattern VALID_NAME = Pattern.compile("[a-zA-Z0-9._-]+");
30
+
31
+ @Inject
32
+ Repository.Repo repositories;
33
+
34
+ @Inject
35
+ User.Repo users;
36
+
37
+ @ConfigProperty(name = "gitshark.storage.root")
38
+ Path storageRoot;
39
+
40
+ @Transactional
41
+ public Repository create(User owner, String name, Repository.Visibility visibility, String description)
42
+ {
43
+ validateName(name);
44
+ if (repositories.findByOwnerAndName(owner, name).isPresent())
45
+ {
46
+ throw new RepositoryAlreadyExistsException(owner.username, name);
47
+ }
48
+
49
+ Repository repository = new Repository();
50
+ repository.name = name;
51
+ repository.owner = owner;
52
+ repository.visibility = visibility;
53
+ repository.description = description;
54
+ repository.persist();
55
+
56
+ Path path = repositoryPath(repository);
57
+ try (Git git = Git.init().setBare(true).setDirectory(path.toFile()).call())
58
+ {
59
+ git.getRepository().updateRef(Constants.HEAD).link("refs/heads/main");
60
+ }
61
+ catch (GitAPIException | IOException e)
62
+ {
63
+ throw new IllegalStateException("Failed to initialize bare repository at " + path, e);
64
+ }
65
+ return repository;
66
+ }
67
+
68
+ public Optional<Repository> find(String ownerName, String repositoryName)
69
+ {
70
+ return users.findByUsername(ownerName)
71
+ .flatMap(owner -> repositories.findByOwnerAndName(owner, stripDotGit(repositoryName)));
72
+ }
73
+
74
+ public Path repositoryPath(Repository repository)
75
+ {
76
+ return storageRoot.resolve(repository.owner.id.toString()).resolve(repository.id.toString() + ".git");
77
+ }
78
+
79
+ @Transactional
80
+ public void delete(User actor, Repository repository)
81
+ {
82
+ if (actor == null || actor.id == null || !actor.id.equals(repository.owner.id))
83
+ {
84
+ throw new ForbiddenOperationException("Only the owner may delete a repository");
85
+ }
86
+ Path path = repositoryPath(repository);
87
+ repositories.deleteById(repository.id);
88
+ try
89
+ {
90
+ FileUtils.delete(path.toFile(), FileUtils.RECURSIVE | FileUtils.SKIP_MISSING);
91
+ }
92
+ catch (IOException e)
93
+ {
94
+ throw new UncheckedIOException("Failed to delete repository directory " + path, e);
95
+ }
96
+ }
97
+
98
+ public List<Repository> listVisibleTo(User user)
99
+ {
100
+ return user == null ? repositories.findPublic() : repositories.findVisibleTo(user);
101
+ }
102
+
103
+ private static void validateName(String name)
104
+ {
105
+ if (name == null || name.isEmpty() || name.equals(".") || name.equals("..")
106
+ || !VALID_NAME.matcher(name).matches())
107
+ {
108
+ throw new InvalidRepositoryNameException(name);
109
+ }
110
+ }
111
+
112
+ private static String stripDotGit(String name)
113
+ {
114
+ return name != null && name.endsWith(".git") ? name.substring(0, name.length() - 4) : name;
115
+ }
116
+
117
+}
ADD
src/main/java/de/workaround/git/InvalidRepositoryNameException.java
+10 -0
@@ -0,0 +1,10 @@
1
+package de.workaround.git;
2
+
3
+public class InvalidRepositoryNameException extends RuntimeException
4
+{
5
+ public InvalidRepositoryNameException(String name)
6
+ {
7
+ super("Invalid repository name: " + name);
8
+ }
9
+
10
+}
ADD
src/main/java/de/workaround/git/RepositoryAlreadyExistsException.java
+10 -0
@@ -0,0 +1,10 @@
1
+package de.workaround.git;
2
+
3
+public class RepositoryAlreadyExistsException extends RuntimeException
4
+{
5
+ public RepositoryAlreadyExistsException(String owner, String name)
6
+ {
7
+ super("Repository already exists: " + owner + "/" + name);
8
+ }
9
+
10
+}
ADD
src/main/java/de/workaround/http/AccessTokenService.java
+100 -0
@@ -0,0 +1,100 @@
1
+package de.workaround.http;
2
+
3
+import java.nio.charset.StandardCharsets;
4
+import java.security.MessageDigest;
5
+import java.security.NoSuchAlgorithmException;
6
+import java.security.SecureRandom;
7
+import java.time.Instant;
8
+import java.util.Base64;
9
+import java.util.HexFormat;
10
+import java.util.List;
11
+import java.util.Optional;
12
+import java.util.UUID;
13
+
14
+import de.workaround.git.ForbiddenOperationException;
15
+import de.workaround.model.AccessToken;
16
+import de.workaround.model.User;
17
+import jakarta.enterprise.context.ApplicationScoped;
18
+import jakarta.inject.Inject;
19
+import jakarta.transaction.Transactional;
20
+
21
+/**
22
+ * Personal access tokens for Git-over-HTTP Basic authentication. The plaintext token is
23
+ * returned exactly once at creation; only its SHA-256 hash is persisted.
24
+ */
25
+@ApplicationScoped
26
+public class AccessTokenService
27
+{
28
+ private static final String PREFIX = "gs_";
29
+
30
+ @Inject
31
+ AccessToken.Repo tokens;
32
+
33
+ public record CreatedToken(AccessToken token, String plaintext)
34
+ {
35
+ }
36
+
37
+ @Transactional
38
+ public CreatedToken create(User user, String label)
39
+ {
40
+ byte[] bytes = new byte[32];
41
+ // created per call: a SecureRandom held in a bean field would be snapshotted
42
+ // into the native image heap, which GraalVM rejects
43
+ new SecureRandom().nextBytes(bytes);
44
+ String plaintext = PREFIX + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
45
+
46
+ AccessToken token = new AccessToken();
47
+ token.user = user;
48
+ token.label = label;
49
+ token.tokenHash = hash(plaintext);
50
+ token.persist();
51
+ return new CreatedToken(token, plaintext);
52
+ }
53
+
54
+ @Transactional
55
+ public Optional<User> authenticate(String plaintext)
56
+ {
57
+ if (plaintext == null || plaintext.isEmpty())
58
+ {
59
+ return Optional.empty();
60
+ }
61
+ return tokens.findByTokenHash(hash(plaintext)).map(token -> {
62
+ token.lastUsed = Instant.now();
63
+ return token.user;
64
+ });
65
+ }
66
+
67
+ @Transactional
68
+ public void revoke(User actor, UUID tokenId)
69
+ {
70
+ AccessToken token = tokens.findById(tokenId);
71
+ if (token == null)
72
+ {
73
+ return;
74
+ }
75
+ if (!token.user.id.equals(actor.id))
76
+ {
77
+ throw new ForbiddenOperationException("Only the owner may revoke a token");
78
+ }
79
+ tokens.deleteById(tokenId);
80
+ }
81
+
82
+ public List<AccessToken> list(User user)
83
+ {
84
+ return tokens.findByUser(user);
85
+ }
86
+
87
+ public String hash(String plaintext)
88
+ {
89
+ try
90
+ {
91
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
92
+ return HexFormat.of().formatHex(digest.digest(plaintext.getBytes(StandardCharsets.UTF_8)));
93
+ }
94
+ catch (NoSuchAlgorithmException e)
95
+ {
96
+ throw new IllegalStateException("SHA-256 unavailable", e);
97
+ }
98
+ }
99
+
100
+}
ADD
src/main/java/de/workaround/http/GitBasicAuthFilter.java
+108 -0
@@ -0,0 +1,108 @@
1
+package de.workaround.http;
2
+
3
+import java.io.IOException;
4
+import java.nio.charset.StandardCharsets;
5
+import java.util.Base64;
6
+import java.util.Optional;
7
+
8
+import de.workaround.model.User;
9
+import jakarta.inject.Inject;
10
+import jakarta.servlet.FilterChain;
11
+import jakarta.servlet.ServletException;
12
+import jakarta.servlet.annotation.WebFilter;
13
+import jakarta.servlet.http.HttpFilter;
14
+import jakarta.servlet.http.HttpServletRequest;
15
+import jakarta.servlet.http.HttpServletResponse;
16
+import jakarta.servlet.http.HttpServletResponseWrapper;
17
+
18
+/**
19
+ * HTTP Basic authentication for Git smart HTTP. The Basic password is a personal access
20
+ * token; the username part is informational only (like GitHub PATs). Requests without
21
+ * credentials continue anonymously โ the GitServlet decides whether anonymous is enough.
22
+ * Invalid credentials are rejected immediately. All 401 responses carry a Basic challenge
23
+ * so Git clients prompt for credentials and retry.
24
+ */
25
+@WebFilter(urlPatterns = "/git/*")
26
+public class GitBasicAuthFilter extends HttpFilter
27
+{
28
+ private static final String CHALLENGE = "Basic realm=\"git-shark\"";
29
+
30
+ @Inject
31
+ AccessTokenService tokenService;
32
+
33
+ @Override
34
+ protected void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
35
+ throws IOException, ServletException
36
+ {
37
+ String header = request.getHeader("Authorization");
38
+ if (header != null && header.regionMatches(true, 0, "Basic ", 0, 6))
39
+ {
40
+ Optional<User> user = decodePassword(header).flatMap(tokenService::authenticate);
41
+ if (user.isEmpty())
42
+ {
43
+ response.setHeader("WWW-Authenticate", CHALLENGE);
44
+ response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
45
+ return;
46
+ }
47
+ request.setAttribute(GitHttpServlet.ATTR_USER, user.get());
48
+ }
49
+ chain.doFilter(request, new ChallengeOn401(response));
50
+ }
51
+
52
+ private static Optional<String> decodePassword(String header)
53
+ {
54
+ try
55
+ {
56
+ String decoded = new String(Base64.getDecoder().decode(header.substring(6).trim()),
57
+ StandardCharsets.UTF_8);
58
+ int colon = decoded.indexOf(':');
59
+ return colon >= 0 ? Optional.of(decoded.substring(colon + 1)) : Optional.empty();
60
+ }
61
+ catch (IllegalArgumentException e)
62
+ {
63
+ return Optional.empty();
64
+ }
65
+ }
66
+
67
+ /**
68
+ * JGit's GitServlet sends bare 401s; git clients only retry with credentials when a
69
+ * WWW-Authenticate challenge is present, so add it before the response is committed.
70
+ */
71
+ private static final class ChallengeOn401 extends HttpServletResponseWrapper
72
+ {
73
+ ChallengeOn401(HttpServletResponse response)
74
+ {
75
+ super(response);
76
+ }
77
+
78
+ @Override
79
+ public void sendError(int sc) throws IOException
80
+ {
81
+ challenge(sc);
82
+ super.sendError(sc);
83
+ }
84
+
85
+ @Override
86
+ public void sendError(int sc, String msg) throws IOException
87
+ {
88
+ challenge(sc);
89
+ super.sendError(sc, msg);
90
+ }
91
+
92
+ @Override
93
+ public void setStatus(int sc)
94
+ {
95
+ challenge(sc);
96
+ super.setStatus(sc);
97
+ }
98
+
99
+ private void challenge(int sc)
100
+ {
101
+ if (sc == HttpServletResponse.SC_UNAUTHORIZED)
102
+ {
103
+ setHeader("WWW-Authenticate", CHALLENGE);
104
+ }
105
+ }
106
+ }
107
+
108
+}
ADD
src/main/java/de/workaround/http/GitHttpServlet.java
+103 -0
@@ -0,0 +1,103 @@
1
+package de.workaround.http;
2
+
3
+import java.io.IOException;
4
+
5
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
6
+import org.eclipse.jgit.http.server.GitServlet;
7
+import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
8
+import org.eclipse.jgit.transport.ReceivePack;
9
+import org.eclipse.jgit.transport.resolver.ServiceNotAuthorizedException;
10
+import org.eclipse.jgit.transport.resolver.ServiceNotEnabledException;
11
+
12
+import de.workaround.git.AccessPolicy;
13
+import de.workaround.git.GitRepositoryService;
14
+import de.workaround.model.Repository;
15
+import de.workaround.model.User;
16
+import jakarta.inject.Inject;
17
+import jakarta.servlet.ServletConfig;
18
+import jakarta.servlet.ServletException;
19
+import jakarta.servlet.annotation.WebServlet;
20
+import jakarta.servlet.http.HttpServletRequest;
21
+
22
+/**
23
+ * Serves the Git smart HTTP protocol under /git/<owner>/<repo>.git. Repositories are resolved
24
+ * through {@link GitRepositoryService} (database is the single source of truth) and authorization
25
+ * goes through the shared {@link AccessPolicy}. The authenticated user is taken from a request
26
+ * attribute populated by the Basic-auth filter; absence means anonymous.
27
+ */
28
+@WebServlet(urlPatterns = "/git/*")
29
+public class GitHttpServlet extends GitServlet
30
+{
31
+ public static final String ATTR_USER = "gitshark.user";
32
+
33
+ public static final String ATTR_REPOSITORY = "gitshark.repository";
34
+
35
+ @Inject
36
+ GitRepositoryService service;
37
+
38
+ @Inject
39
+ AccessPolicy accessPolicy;
40
+
41
+ @Override
42
+ public void init(ServletConfig config) throws ServletException
43
+ {
44
+ setRepositoryResolver(this::resolve);
45
+ setReceivePackFactory(this::createReceivePack);
46
+ super.init(config);
47
+ }
48
+
49
+ private org.eclipse.jgit.lib.Repository resolve(HttpServletRequest request, String name)
50
+ throws RepositoryNotFoundException, ServiceNotAuthorizedException
51
+ {
52
+ String[] parts = name.split("/");
53
+ if (parts.length != 2)
54
+ {
55
+ throw new RepositoryNotFoundException(name);
56
+ }
57
+ Repository repository = service.find(parts[0], parts[1])
58
+ .orElseThrow(() -> new RepositoryNotFoundException(name));
59
+
60
+ User user = (User) request.getAttribute(ATTR_USER);
61
+ if (!accessPolicy.canRead(user, repository))
62
+ {
63
+ if (user == null)
64
+ {
65
+ // trigger a Basic challenge so git clients retry with credentials
66
+ throw new ServiceNotAuthorizedException();
67
+ }
68
+ // authenticated but not allowed: hide the repository
69
+ throw new RepositoryNotFoundException(name);
70
+ }
71
+ request.setAttribute(ATTR_REPOSITORY, repository);
72
+ try
73
+ {
74
+ return new FileRepositoryBuilder()
75
+ .setGitDir(service.repositoryPath(repository).toFile())
76
+ .setMustExist(true)
77
+ .build();
78
+ }
79
+ catch (IOException e)
80
+ {
81
+ throw new RepositoryNotFoundException(name, e);
82
+ }
83
+ }
84
+
85
+ private ReceivePack createReceivePack(HttpServletRequest request, org.eclipse.jgit.lib.Repository db)
86
+ throws ServiceNotAuthorizedException, ServiceNotEnabledException
87
+ {
88
+ User user = (User) request.getAttribute(ATTR_USER);
89
+ if (user == null)
90
+ {
91
+ throw new ServiceNotAuthorizedException();
92
+ }
93
+ Repository repository = (Repository) request.getAttribute(ATTR_REPOSITORY);
94
+ if (repository == null || !accessPolicy.canWrite(user, repository))
95
+ {
96
+ throw new ServiceNotEnabledException();
97
+ }
98
+ ReceivePack receivePack = new ReceivePack(db);
99
+ receivePack.setRefLogIdent(new org.eclipse.jgit.lib.PersonIdent(user.username, user.email != null ? user.email : user.username + "@git-shark"));
100
+ return receivePack;
101
+ }
102
+
103
+}
ADD
src/main/java/de/workaround/model/AccessToken.java
+47 -0
@@ -0,0 +1,47 @@
1
+package de.workaround.model;
2
+
3
+import java.time.Instant;
4
+import java.util.List;
5
+import java.util.Optional;
6
+import java.util.UUID;
7
+
8
+import org.hibernate.annotations.processing.Find;
9
+
10
+import io.quarkus.hibernate.panache.PanacheEntity;
11
+import io.quarkus.hibernate.panache.PanacheRepository;
12
+import jakarta.persistence.Entity;
13
+import jakarta.persistence.GeneratedValue;
14
+import jakarta.persistence.GenerationType;
15
+import jakarta.persistence.Id;
16
+import jakarta.persistence.ManyToOne;
17
+import jakarta.persistence.Table;
18
+
19
+@Entity
20
+@Table(name = "access_tokens")
21
+public class AccessToken implements PanacheEntity.Managed
22
+{
23
+ @Id
24
+ @GeneratedValue(strategy = GenerationType.UUID)
25
+ public UUID id;
26
+
27
+ @ManyToOne(optional = false)
28
+ public User user;
29
+
30
+ public String tokenHash;
31
+
32
+ public String label;
33
+
34
+ public Instant lastUsed;
35
+
36
+ public Instant createdAt = Instant.now();
37
+
38
+ public interface Repo extends PanacheRepository.Managed<AccessToken, UUID>
39
+ {
40
+ @Find
41
+ Optional<AccessToken> findByTokenHash(String tokenHash);
42
+
43
+ @Find
44
+ List<AccessToken> findByUser(User user);
45
+ }
46
+
47
+}
ADD
src/main/java/de/workaround/model/Repository.java
+60 -0
@@ -0,0 +1,60 @@
1
+package de.workaround.model;
2
+
3
+import java.time.Instant;
4
+import java.util.List;
5
+import java.util.Optional;
6
+import java.util.UUID;
7
+
8
+import org.hibernate.annotations.processing.Find;
9
+import org.hibernate.annotations.processing.HQL;
10
+
11
+import io.quarkus.hibernate.panache.PanacheEntity;
12
+import io.quarkus.hibernate.panache.PanacheRepository;
13
+import jakarta.persistence.Entity;
14
+import jakarta.persistence.EnumType;
15
+import jakarta.persistence.Enumerated;
16
+import jakarta.persistence.GeneratedValue;
17
+import jakarta.persistence.GenerationType;
18
+import jakarta.persistence.Id;
19
+import jakarta.persistence.ManyToOne;
20
+import jakarta.persistence.Table;
21
+
22
+@Entity
23
+@Table(name = "repositories")
24
+public class Repository implements PanacheEntity.Managed
25
+{
26
+ @Id
27
+ @GeneratedValue(strategy = GenerationType.UUID)
28
+ public UUID id;
29
+
30
+ public String name;
31
+
32
+ @ManyToOne(optional = false)
33
+ public User owner;
34
+
35
+ @Enumerated(EnumType.STRING)
36
+ public Visibility visibility;
37
+
38
+ public String description;
39
+
40
+ public Instant createdAt = Instant.now();
41
+
42
+ public enum Visibility
43
+ {
44
+ PUBLIC,
45
+ PRIVATE
46
+ }
47
+
48
+ public interface Repo extends PanacheRepository.Managed<Repository, UUID>
49
+ {
50
+ @Find
51
+ Optional<Repository> findByOwnerAndName(User owner, String name);
52
+
53
+ @HQL("where owner = :owner or visibility = PUBLIC order by name")
54
+ List<Repository> findVisibleTo(User owner);
55
+
56
+ @HQL("where visibility = PUBLIC order by name")
57
+ List<Repository> findPublic();
58
+ }
59
+
60
+}
ADD
src/main/java/de/workaround/model/SshKey.java
+49 -0
@@ -0,0 +1,49 @@
1
+package de.workaround.model;
2
+
3
+import java.time.Instant;
4
+import java.util.List;
5
+import java.util.Optional;
6
+import java.util.UUID;
7
+
8
+import org.hibernate.annotations.processing.Find;
9
+
10
+import io.quarkus.hibernate.panache.PanacheEntity;
11
+import io.quarkus.hibernate.panache.PanacheRepository;
12
+import jakarta.persistence.Column;
13
+import jakarta.persistence.Entity;
14
+import jakarta.persistence.GeneratedValue;
15
+import jakarta.persistence.GenerationType;
16
+import jakarta.persistence.Id;
17
+import jakarta.persistence.ManyToOne;
18
+import jakarta.persistence.Table;
19
+
20
+@Entity
21
+@Table(name = "ssh_keys")
22
+public class SshKey implements PanacheEntity.Managed
23
+{
24
+ @Id
25
+ @GeneratedValue(strategy = GenerationType.UUID)
26
+ public UUID id;
27
+
28
+ @ManyToOne(optional = false)
29
+ public User user;
30
+
31
+ public String title;
32
+
33
+ @Column(columnDefinition = "text")
34
+ public String publicKey;
35
+
36
+ public String fingerprint;
37
+
38
+ public Instant createdAt = Instant.now();
39
+
40
+ public interface Repo extends PanacheRepository.Managed<SshKey, UUID>
41
+ {
42
+ @Find
43
+ Optional<SshKey> findByFingerprint(String fingerprint);
44
+
45
+ @Find
46
+ List<SshKey> findByUser(User user);
47
+ }
48
+
49
+}
ADD
src/main/java/de/workaround/model/User.java
+49 -0
@@ -0,0 +1,49 @@
1
+package de.workaround.model;
2
+
3
+import java.time.Instant;
4
+import java.util.Optional;
5
+import java.util.UUID;
6
+
7
+import org.hibernate.annotations.processing.Find;
8
+
9
+import io.quarkus.hibernate.panache.PanacheEntity;
10
+import io.quarkus.hibernate.panache.PanacheRepository;
11
+import jakarta.persistence.Entity;
12
+import jakarta.persistence.GeneratedValue;
13
+import jakarta.persistence.GenerationType;
14
+import jakarta.persistence.Id;
15
+import jakarta.persistence.Table;
16
+
17
+@Entity
18
+@Table(name = "users")
19
+public class User implements PanacheEntity.Managed
20
+{
21
+ // Explicit UUID id: WithId.AutoUUID resolves to a sequence generator in Panache Next 3.36.2
22
+ // (generic @GeneratedValue field), which cannot produce UUIDs.
23
+ @Id
24
+ @GeneratedValue(strategy = GenerationType.UUID)
25
+ public UUID id;
26
+
27
+ public String oidcSub;
28
+
29
+ public String username;
30
+
31
+ public String displayName;
32
+
33
+ public String email;
34
+
35
+ public Instant createdAt = Instant.now();
36
+
37
+ public interface Repo extends PanacheRepository.Managed<User, UUID>
38
+ {
39
+ @Find
40
+ User findByOidcSub(String oidcSub);
41
+
42
+ @Find
43
+ Optional<User> findByOidcSubOptional(String oidcSub);
44
+
45
+ @Find
46
+ Optional<User> findByUsername(String username);
47
+ }
48
+
49
+}
ADD
src/main/java/de/workaround/ssh/GitSshAuthenticator.java
+65 -0
@@ -0,0 +1,65 @@
1
+package de.workaround.ssh;
2
+
3
+import java.security.PublicKey;
4
+import java.util.UUID;
5
+
6
+import org.apache.sshd.common.AttributeRepository;
7
+import org.apache.sshd.common.config.keys.KeyUtils;
8
+import org.apache.sshd.common.digest.BuiltinDigests;
9
+import org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator;
10
+import org.apache.sshd.server.session.ServerSession;
11
+
12
+import de.workaround.model.SshKey;
13
+import jakarta.enterprise.context.ApplicationScoped;
14
+import jakarta.inject.Inject;
15
+import jakarta.transaction.Transactional;
16
+import io.quarkus.arc.Arc;
17
+
18
+/**
19
+ * Authenticates SSH sessions exclusively by public key: the offered key's SHA-256 fingerprint
20
+ * is looked up in the ssh_keys table. On success the owning user's id is attached to the
21
+ * session for later authorization in the Git command.
22
+ */
23
+@ApplicationScoped
24
+public class GitSshAuthenticator implements PublickeyAuthenticator
25
+{
26
+ public static final AttributeRepository.AttributeKey<UUID> USER_ID = new AttributeRepository.AttributeKey<>();
27
+
28
+ @Inject
29
+ SshKey.Repo sshKeys;
30
+
31
+ @Override
32
+ public boolean authenticate(String username, PublicKey key, ServerSession session)
33
+ {
34
+ var requestContext = Arc.container().requestContext();
35
+ boolean activated = !requestContext.isActive();
36
+ if (activated)
37
+ {
38
+ requestContext.activate();
39
+ }
40
+ try
41
+ {
42
+ return lookup(key, session);
43
+ }
44
+ finally
45
+ {
46
+ if (activated)
47
+ {
48
+ requestContext.terminate();
49
+ }
50
+ }
51
+ }
52
+
53
+ @Transactional
54
+ boolean lookup(PublicKey key, ServerSession session)
55
+ {
56
+ String fingerprint = KeyUtils.getFingerPrint(BuiltinDigests.sha256, key);
57
+ return sshKeys.findByFingerprint(fingerprint)
58
+ .map(sshKey -> {
59
+ session.setAttribute(USER_ID, sshKey.user.id);
60
+ return true;
61
+ })
62
+ .orElse(false);
63
+ }
64
+
65
+}
ADD
src/main/java/de/workaround/ssh/GitSshCommandFactory.java
+176 -0
@@ -0,0 +1,176 @@
1
+package de.workaround.ssh;
2
+
3
+import java.io.IOException;
4
+import java.io.InputStream;
5
+import java.io.OutputStream;
6
+import java.nio.charset.StandardCharsets;
7
+import java.nio.file.Path;
8
+import java.util.Optional;
9
+import java.util.UUID;
10
+
11
+import org.apache.sshd.server.Environment;
12
+import org.apache.sshd.server.ExitCallback;
13
+import org.apache.sshd.server.channel.ChannelSession;
14
+import org.apache.sshd.server.command.Command;
15
+import org.apache.sshd.server.command.CommandFactory;
16
+import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
17
+import org.eclipse.jgit.transport.ReceivePack;
18
+import org.eclipse.jgit.transport.UploadPack;
19
+
20
+import jakarta.enterprise.context.ApplicationScoped;
21
+import jakarta.inject.Inject;
22
+
23
+/**
24
+ * Accepts only git-upload-pack and git-receive-pack; everything else (shells, other commands)
25
+ * is rejected. Repository resolution and authorization run through the shared services.
26
+ */
27
+@ApplicationScoped
28
+public class GitSshCommandFactory implements CommandFactory
29
+{
30
+ @Inject
31
+ SshGitBridge bridge;
32
+
33
+ @Override
34
+ public Command createCommand(ChannelSession channel, String command)
35
+ {
36
+ return new GitPackCommand(command);
37
+ }
38
+
39
+ private final class GitPackCommand implements Command, Runnable
40
+ {
41
+ private final String commandLine;
42
+
43
+ private InputStream in;
44
+
45
+ private OutputStream out;
46
+
47
+ private OutputStream err;
48
+
49
+ private ExitCallback exit;
50
+
51
+ private ChannelSession channel;
52
+
53
+ private Thread worker;
54
+
55
+ private GitPackCommand(String commandLine)
56
+ {
57
+ this.commandLine = commandLine;
58
+ }
59
+
60
+ @Override
61
+ public void setInputStream(InputStream in)
62
+ {
63
+ this.in = in;
64
+ }
65
+
66
+ @Override
67
+ public void setOutputStream(OutputStream out)
68
+ {
69
+ this.out = out;
70
+ }
71
+
72
+ @Override
73
+ public void setErrorStream(OutputStream err)
74
+ {
75
+ this.err = err;
76
+ }
77
+
78
+ @Override
79
+ public void setExitCallback(ExitCallback callback)
80
+ {
81
+ this.exit = callback;
82
+ }
83
+
84
+ @Override
85
+ public void start(ChannelSession channel, Environment env) throws IOException
86
+ {
87
+ this.channel = channel;
88
+ worker = new Thread(this, "git-ssh-" + channel.getServerSession().getIoSession().getRemoteAddress());
89
+ worker.start();
90
+ }
91
+
92
+ @Override
93
+ public void destroy(ChannelSession channel)
94
+ {
95
+ if (worker != null)
96
+ {
97
+ worker.interrupt();
98
+ }
99
+ }
100
+
101
+ @Override
102
+ public void run()
103
+ {
104
+ try
105
+ {
106
+ boolean receive;
107
+ String rawPath;
108
+ if (commandLine.startsWith("git-upload-pack "))
109
+ {
110
+ receive = false;
111
+ rawPath = argument(commandLine);
112
+ }
113
+ else if (commandLine.startsWith("git-receive-pack "))
114
+ {
115
+ receive = true;
116
+ rawPath = argument(commandLine);
117
+ }
118
+ else
119
+ {
120
+ fail("Only git-upload-pack and git-receive-pack are supported");
121
+ return;
122
+ }
123
+
124
+ UUID userId = channel.getServerSession().getAttribute(GitSshAuthenticator.USER_ID);
125
+ Optional<Path> repoPath = bridge.resolveAuthorized(userId, rawPath, receive);
126
+ if (repoPath.isEmpty())
127
+ {
128
+ fail("Repository not found or access denied: " + rawPath);
129
+ return;
130
+ }
131
+
132
+ try (var db = new FileRepositoryBuilder().setGitDir(repoPath.get().toFile()).setMustExist(true).build())
133
+ {
134
+ if (receive)
135
+ {
136
+ new ReceivePack(db).receive(in, out, err);
137
+ }
138
+ else
139
+ {
140
+ new UploadPack(db).upload(in, out, err);
141
+ }
142
+ }
143
+ exit.onExit(0);
144
+ }
145
+ catch (Exception e)
146
+ {
147
+ try
148
+ {
149
+ fail("git-shark: " + e.getMessage());
150
+ }
151
+ catch (IOException ignored)
152
+ {
153
+ exit.onExit(1);
154
+ }
155
+ }
156
+ }
157
+
158
+ private void fail(String message) throws IOException
159
+ {
160
+ err.write((message + "\n").getBytes(StandardCharsets.UTF_8));
161
+ err.flush();
162
+ exit.onExit(1);
163
+ }
164
+ }
165
+
166
+ private static String argument(String commandLine)
167
+ {
168
+ String arg = commandLine.substring(commandLine.indexOf(' ') + 1).trim();
169
+ if (arg.startsWith("'") && arg.endsWith("'") && arg.length() >= 2)
170
+ {
171
+ arg = arg.substring(1, arg.length() - 1);
172
+ }
173
+ return arg.startsWith("/") ? arg.substring(1) : arg;
174
+ }
175
+
176
+}
ADD
src/main/java/de/workaround/ssh/GitSshServer.java
+66 -0
@@ -0,0 +1,66 @@
1
+package de.workaround.ssh;
2
+
3
+import java.io.IOException;
4
+import java.nio.file.Files;
5
+import java.nio.file.Path;
6
+
7
+import org.apache.sshd.server.SshServer;
8
+import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
9
+
10
+import io.quarkus.runtime.ShutdownEvent;
11
+import io.quarkus.runtime.StartupEvent;
12
+import jakarta.enterprise.context.ApplicationScoped;
13
+import jakarta.enterprise.event.Observes;
14
+import jakarta.inject.Inject;
15
+import org.eclipse.microprofile.config.inject.ConfigProperty;
16
+
17
+/**
18
+ * Embedded SSH server for Git transport. Listens on a dedicated port (default 2222), uses
19
+ * public-key authentication only (no passwords, no shell), and persists its host key on the
20
+ * data volume so the host identity is stable across restarts.
21
+ */
22
+@ApplicationScoped
23
+public class GitSshServer
24
+{
25
+ @ConfigProperty(name = "gitshark.ssh.port")
26
+ int port;
27
+
28
+ @ConfigProperty(name = "gitshark.ssh.host-key-path")
29
+ Path hostKeyPath;
30
+
31
+ @Inject
32
+ GitSshAuthenticator authenticator;
33
+
34
+ @Inject
35
+ GitSshCommandFactory commandFactory;
36
+
37
+ private SshServer sshd;
38
+
39
+ void onStart(@Observes StartupEvent event) throws IOException
40
+ {
41
+ if (hostKeyPath.getParent() != null)
42
+ {
43
+ Files.createDirectories(hostKeyPath.getParent());
44
+ }
45
+ sshd = SshServer.setUpDefaultServer();
46
+ sshd.setPort(port);
47
+ sshd.setKeyPairProvider(new SimpleGeneratorHostKeyProvider(hostKeyPath));
48
+ sshd.setPublickeyAuthenticator(authenticator);
49
+ sshd.setCommandFactory(commandFactory);
50
+ sshd.start();
51
+ }
52
+
53
+ void onStop(@Observes ShutdownEvent event) throws IOException
54
+ {
55
+ if (sshd != null)
56
+ {
57
+ sshd.stop();
58
+ }
59
+ }
60
+
61
+ public int actualPort()
62
+ {
63
+ return sshd.getPort();
64
+ }
65
+
66
+}
ADD
src/main/java/de/workaround/ssh/SshGitBridge.java
+68 -0
@@ -0,0 +1,68 @@
1
+package de.workaround.ssh;
2
+
3
+import java.nio.file.Path;
4
+import java.util.Optional;
5
+import java.util.UUID;
6
+
7
+import de.workaround.git.AccessPolicy;
8
+import de.workaround.git.GitRepositoryService;
9
+import de.workaround.model.Repository;
10
+import de.workaround.model.User;
11
+import io.quarkus.arc.Arc;
12
+import jakarta.enterprise.context.ApplicationScoped;
13
+import jakarta.inject.Inject;
14
+import jakarta.transaction.Transactional;
15
+
16
+/**
17
+ * Database access for SSH threads: activates the CDI request context (SSH worker threads have
18
+ * none) and applies the shared access policy. Read requires canRead, receive-pack canWrite.
19
+ */
20
+@ApplicationScoped
21
+public class SshGitBridge
22
+{
23
+ @Inject
24
+ GitRepositoryService service;
25
+
26
+ @Inject
27
+ AccessPolicy accessPolicy;
28
+
29
+ @Inject
30
+ User.Repo users;
31
+
32
+ public Optional<Path> resolveAuthorized(UUID userId, String rawPath, boolean write)
33
+ {
34
+ var requestContext = Arc.container().requestContext();
35
+ boolean activated = !requestContext.isActive();
36
+ if (activated)
37
+ {
38
+ requestContext.activate();
39
+ }
40
+ try
41
+ {
42
+ return lookup(userId, rawPath, write);
43
+ }
44
+ finally
45
+ {
46
+ if (activated)
47
+ {
48
+ requestContext.terminate();
49
+ }
50
+ }
51
+ }
52
+
53
+ @Transactional
54
+ Optional<Path> lookup(UUID userId, String rawPath, boolean write)
55
+ {
56
+ String[] parts = rawPath.split("/");
57
+ if (parts.length != 2)
58
+ {
59
+ return Optional.empty();
60
+ }
61
+ User user = userId == null ? null : users.findById(userId);
62
+ return service.find(parts[0], parts[1])
63
+ .filter(repository -> write ? accessPolicy.canWrite(user, repository)
64
+ : accessPolicy.canRead(user, repository))
65
+ .map(service::repositoryPath);
66
+ }
67
+
68
+}
ADD
src/main/java/de/workaround/web/HomeResource.java
+79 -0
@@ -0,0 +1,79 @@
1
+package de.workaround.web;
2
+
3
+import java.net.URI;
4
+import java.util.List;
5
+
6
+import de.workaround.account.CurrentUser;
7
+import de.workaround.git.GitRepositoryService;
8
+import de.workaround.git.InvalidRepositoryNameException;
9
+import de.workaround.git.RepositoryAlreadyExistsException;
10
+import de.workaround.model.Repository;
11
+import de.workaround.model.User;
12
+import io.quarkus.qute.CheckedTemplate;
13
+import io.quarkus.qute.TemplateInstance;
14
+import jakarta.inject.Inject;
15
+import jakarta.ws.rs.Consumes;
16
+import jakarta.ws.rs.FormParam;
17
+import jakarta.ws.rs.GET;
18
+import jakarta.ws.rs.POST;
19
+import jakarta.ws.rs.Path;
20
+import jakarta.ws.rs.Produces;
21
+import jakarta.ws.rs.core.MediaType;
22
+import jakarta.ws.rs.core.Response;
23
+
24
+@Path("/")
25
+@Produces(MediaType.TEXT_HTML)
26
+public class HomeResource
27
+{
28
+ @CheckedTemplate
29
+ static class Templates
30
+ {
31
+ static native TemplateInstance home(List<Repository> repositories, User user);
32
+
33
+ static native TemplateInstance newRepo(String error);
34
+ }
35
+
36
+ @Inject
37
+ CurrentUser currentUser;
38
+
39
+ @Inject
40
+ GitRepositoryService service;
41
+
42
+ @GET
43
+ public TemplateInstance home()
44
+ {
45
+ User user = currentUser.get();
46
+ return Templates.home(service.listVisibleTo(user), user);
47
+ }
48
+
49
+ @GET
50
+ @Path("repos/new")
51
+ public TemplateInstance newRepo()
52
+ {
53
+ currentUser.require();
54
+ return Templates.newRepo(null);
55
+ }
56
+
57
+ @POST
58
+ @Path("repos")
59
+ @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
60
+ public Response create(@FormParam("name") String name, @FormParam("visibility") String visibility,
61
+ @FormParam("description") String description)
62
+ {
63
+ User user = currentUser.require();
64
+ try
65
+ {
66
+ Repository.Visibility parsed = "PRIVATE".equalsIgnoreCase(visibility)
67
+ ? Repository.Visibility.PRIVATE : Repository.Visibility.PUBLIC;
68
+ service.create(user, name, parsed, description == null || description.isBlank() ? null : description);
69
+ return Response.seeOther(URI.create("/repos/" + user.username + "/" + name)).build();
70
+ }
71
+ catch (InvalidRepositoryNameException | RepositoryAlreadyExistsException e)
72
+ {
73
+ return Response.status(Response.Status.BAD_REQUEST)
74
+ .entity(Templates.newRepo(e.getMessage()))
75
+ .build();
76
+ }
77
+ }
78
+
79
+}
ADD
src/main/java/de/workaround/web/RepositoryResource.java
+182 -0
@@ -0,0 +1,182 @@
1
+package de.workaround.web;
2
+
3
+import java.net.URI;
4
+import java.nio.charset.StandardCharsets;
5
+import java.nio.file.Path;
6
+import java.util.List;
7
+
8
+import de.workaround.account.CurrentUser;
9
+import de.workaround.git.AccessPolicy;
10
+import de.workaround.git.GitBrowseService;
11
+import de.workaround.git.GitRepositoryService;
12
+import de.workaround.model.Repository;
13
+import de.workaround.model.User;
14
+import io.quarkus.qute.CheckedTemplate;
15
+import io.quarkus.qute.TemplateInstance;
16
+import jakarta.inject.Inject;
17
+import jakarta.ws.rs.Consumes;
18
+import jakarta.ws.rs.DefaultValue;
19
+import jakarta.ws.rs.FormParam;
20
+import jakarta.ws.rs.GET;
21
+import jakarta.ws.rs.NotFoundException;
22
+import jakarta.ws.rs.POST;
23
+import jakarta.ws.rs.PathParam;
24
+import jakarta.ws.rs.Produces;
25
+import jakarta.ws.rs.QueryParam;
26
+import jakarta.ws.rs.core.Context;
27
+import jakarta.ws.rs.core.MediaType;
28
+import jakarta.ws.rs.core.Response;
29
+import jakarta.ws.rs.core.UriInfo;
30
+import org.eclipse.microprofile.config.inject.ConfigProperty;
31
+
32
+@jakarta.ws.rs.Path("/repos/{owner}/{name}")
33
+@Produces(MediaType.TEXT_HTML)
34
+public class RepositoryResource
35
+{
36
+ @CheckedTemplate
37
+ static class Templates
38
+ {
39
+ static native TemplateInstance overview(Repository repo, boolean owner, boolean empty, String defaultBranch,
40
+ List<GitBrowseService.TreeEntry> entries, String httpUrl, String sshUrl);
41
+
42
+ static native TemplateInstance tree(Repository repo, String ref, String path,
43
+ List<GitBrowseService.TreeEntry> entries);
44
+
45
+ static native TemplateInstance blob(Repository repo, String ref, String path, boolean binary, String content);
46
+
47
+ static native TemplateInstance commits(Repository repo, String ref, List<GitBrowseService.CommitInfo> commits,
48
+ int page, int prevPage, int nextPage, int size, boolean hasNext);
49
+
50
+ static native TemplateInstance branches(Repository repo, List<GitBrowseService.BranchInfo> branches,
51
+ List<String> tags);
52
+ }
53
+
54
+ @Inject
55
+ CurrentUser currentUser;
56
+
57
+ @Inject
58
+ GitRepositoryService service;
59
+
60
+ @Inject
61
+ GitBrowseService browse;
62
+
63
+ @Inject
64
+ AccessPolicy accessPolicy;
65
+
66
+ @ConfigProperty(name = "gitshark.ssh.port")
67
+ int sshPort;
68
+
69
+ @Context
70
+ UriInfo uriInfo;
71
+
72
+ @GET
73
+ public TemplateInstance overview(@PathParam("owner") String owner, @PathParam("name") String name)
74
+ {
75
+ Repository repo = requireReadable(owner, name);
76
+ Path path = service.repositoryPath(repo);
77
+ boolean empty = browse.isEmpty(path);
78
+ String defaultBranch = empty ? null : browse.defaultBranch(path);
79
+ List<GitBrowseService.TreeEntry> entries = empty
80
+ ? List.of()
81
+ : browse.listTree(path, defaultBranch, "").orElse(List.of());
82
+ User user = currentUser.get();
83
+ boolean isOwner = user != null && user.id.equals(repo.owner.id);
84
+ return Templates.overview(repo, isOwner, empty, defaultBranch, entries, httpUrl(repo), sshUrl(repo));
85
+ }
86
+
87
+ @GET
88
+ @jakarta.ws.rs.Path("tree/{ref}{path:(/.*)?}")
89
+ public TemplateInstance tree(@PathParam("owner") String owner, @PathParam("name") String name,
90
+ @PathParam("ref") String ref, @PathParam("path") String rawPath)
91
+ {
92
+ String path = rawPath == null || rawPath.isEmpty() ? "" : rawPath.substring(1);
93
+ Repository repo = requireReadable(owner, name);
94
+ Path repoPath = service.repositoryPath(repo);
95
+ return browse.listTree(repoPath, ref, path)
96
+ .map(entries -> Templates.tree(repo, ref, path, entries))
97
+ .orElseGet(() -> blobView(repo, repoPath, ref, path));
98
+ }
99
+
100
+ @GET
101
+ @jakarta.ws.rs.Path("raw/{ref}/{path:.*}")
102
+ @Produces(MediaType.APPLICATION_OCTET_STREAM)
103
+ public Response raw(@PathParam("owner") String owner, @PathParam("name") String name,
104
+ @PathParam("ref") String ref, @PathParam("path") String path)
105
+ {
106
+ Repository repo = requireReadable(owner, name);
107
+ GitBrowseService.BlobView blob = browse.blob(service.repositoryPath(repo), ref, path)
108
+ .orElseThrow(NotFoundException::new);
109
+ return Response.ok(blob.content()).build();
110
+ }
111
+
112
+ @GET
113
+ @jakarta.ws.rs.Path("commits/{ref}")
114
+ public TemplateInstance commits(@PathParam("owner") String owner, @PathParam("name") String name,
115
+ @PathParam("ref") String ref, @QueryParam("page") @DefaultValue("0") int page,
116
+ @QueryParam("size") @DefaultValue("50") int size)
117
+ {
118
+ Repository repo = requireReadable(owner, name);
119
+ int boundedSize = Math.min(Math.max(size, 1), 100);
120
+ int boundedPage = Math.max(page, 0);
121
+ GitBrowseService.CommitPage commitPage = browse
122
+ .commits(service.repositoryPath(repo), ref, boundedPage, boundedSize)
123
+ .orElseThrow(NotFoundException::new);
124
+ return Templates.commits(repo, ref, commitPage.commits(), boundedPage, boundedPage - 1, boundedPage + 1,
125
+ boundedSize, commitPage.hasNext());
126
+ }
127
+
128
+ @GET
129
+ @jakarta.ws.rs.Path("branches")
130
+ public TemplateInstance branches(@PathParam("owner") String owner, @PathParam("name") String name)
131
+ {
132
+ Repository repo = requireReadable(owner, name);
133
+ Path path = service.repositoryPath(repo);
134
+ return Templates.branches(repo, browse.branches(path), browse.tags(path));
135
+ }
136
+
137
+ @POST
138
+ @jakarta.ws.rs.Path("delete")
139
+ @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
140
+ public Response delete(@PathParam("owner") String owner, @PathParam("name") String name,
141
+ @FormParam("confirm") String confirm)
142
+ {
143
+ Repository repo = requireReadable(owner, name);
144
+ if (!repo.name.equals(confirm))
145
+ {
146
+ return Response.status(Response.Status.BAD_REQUEST)
147
+ .entity("Confirmation does not match the repository name").build();
148
+ }
149
+ service.delete(currentUser.require(), repo);
150
+ return Response.seeOther(URI.create("/")).build();
151
+ }
152
+
153
+ private TemplateInstance blobView(Repository repo, Path repoPath, String ref, String path)
154
+ {
155
+ GitBrowseService.BlobView blob = browse.blob(repoPath, ref, path).orElseThrow(NotFoundException::new);
156
+ String content = blob.binary() ? null : new String(blob.content(), StandardCharsets.UTF_8);
157
+ return Templates.blob(repo, ref, path, blob.binary(), content);
158
+ }
159
+
160
+ private Repository requireReadable(String owner, String name)
161
+ {
162
+ Repository repo = service.find(owner, name).orElseThrow(NotFoundException::new);
163
+ if (!accessPolicy.canRead(currentUser.get(), repo))
164
+ {
165
+ // hide existence of private repositories
166
+ throw new NotFoundException();
167
+ }
168
+ return repo;
169
+ }
170
+
171
+ private String httpUrl(Repository repo)
172
+ {
173
+ return uriInfo.getBaseUri().resolve("/git/" + repo.owner.username + "/" + repo.name + ".git").toString();
174
+ }
175
+
176
+ private String sshUrl(Repository repo)
177
+ {
178
+ return "ssh://git@" + uriInfo.getBaseUri().getHost() + ":" + sshPort
179
+ + "/" + repo.owner.username + "/" + repo.name + ".git";
180
+ }
181
+
182
+}
ADD
src/main/resources/META-INF/native-image/de.workaround/git-shark/native-image.properties
+1 -0
@@ -0,0 +1 @@
1
+Args = -H:ReflectionConfigurationResources=${.}/reflect-config.json
ADD
src/main/resources/META-INF/native-image/de.workaround/git-shark/reachability-metadata.json
+25 -0
@@ -0,0 +1,25 @@
1
+{
2
+ "reflection": [
3
+ {
4
+ "type": {
5
+ "proxy": [
6
+ "org.apache.sshd.common.session.SessionListener"
7
+ ]
8
+ }
9
+ },
10
+ {
11
+ "type": {
12
+ "proxy": [
13
+ "org.apache.sshd.common.channel.ChannelListener"
14
+ ]
15
+ }
16
+ },
17
+ {
18
+ "type": {
19
+ "proxy": [
20
+ "org.apache.sshd.common.forward.PortForwardingEventListener"
21
+ ]
22
+ }
23
+ }
24
+ ]
25
+}
ADD
src/main/resources/META-INF/native-image/de.workaround/git-shark/reflect-config.json
+70 -0
@@ -0,0 +1,70 @@
1
+[
2
+ {
3
+ "name": "java.security.KeyPairGenerator",
4
+ "queryAllDeclaredMethods": true,
5
+ "methods": [
6
+ {"name": "getInstance", "parameterTypes": ["java.lang.String", "java.lang.String"]},
7
+ {"name": "getInstance", "parameterTypes": ["java.lang.String", "java.security.Provider"]},
8
+ {"name": "getInstance", "parameterTypes": ["java.lang.String"]}
9
+ ]
10
+ },
11
+ {
12
+ "name": "java.security.KeyFactory",
13
+ "queryAllDeclaredMethods": true,
14
+ "methods": [
15
+ {"name": "getInstance", "parameterTypes": ["java.lang.String", "java.lang.String"]},
16
+ {"name": "getInstance", "parameterTypes": ["java.lang.String", "java.security.Provider"]},
17
+ {"name": "getInstance", "parameterTypes": ["java.lang.String"]}
18
+ ]
19
+ },
20
+ {
21
+ "name": "javax.crypto.KeyAgreement",
22
+ "queryAllDeclaredMethods": true,
23
+ "methods": [
24
+ {"name": "getInstance", "parameterTypes": ["java.lang.String", "java.lang.String"]},
25
+ {"name": "getInstance", "parameterTypes": ["java.lang.String", "java.security.Provider"]},
26
+ {"name": "getInstance", "parameterTypes": ["java.lang.String"]}
27
+ ]
28
+ },
29
+ {
30
+ "name": "java.security.Signature",
31
+ "queryAllDeclaredMethods": true,
32
+ "methods": [
33
+ {"name": "getInstance", "parameterTypes": ["java.lang.String", "java.lang.String"]},
34
+ {"name": "getInstance", "parameterTypes": ["java.lang.String", "java.security.Provider"]},
35
+ {"name": "getInstance", "parameterTypes": ["java.lang.String"]}
36
+ ]
37
+ },
38
+ {
39
+ "name": "javax.crypto.Cipher",
40
+ "queryAllDeclaredMethods": true,
41
+ "methods": [
42
+ {"name": "getInstance", "parameterTypes": ["java.lang.String", "java.lang.String"]},
43
+ {"name": "getInstance", "parameterTypes": ["java.lang.String", "java.security.Provider"]},
44
+ {"name": "getInstance", "parameterTypes": ["java.lang.String"]}
45
+ ]
46
+ },
47
+ {
48
+ "name": "java.security.MessageDigest",
49
+ "queryAllDeclaredMethods": true,
50
+ "methods": [
51
+ {"name": "getInstance", "parameterTypes": ["java.lang.String", "java.lang.String"]},
52
+ {"name": "getInstance", "parameterTypes": ["java.lang.String", "java.security.Provider"]},
53
+ {"name": "getInstance", "parameterTypes": ["java.lang.String"]}
54
+ ]
55
+ },
56
+ {
57
+ "name": "javax.crypto.Mac",
58
+ "queryAllDeclaredMethods": true,
59
+ "methods": [
60
+ {"name": "getInstance", "parameterTypes": ["java.lang.String", "java.lang.String"]},
61
+ {"name": "getInstance", "parameterTypes": ["java.lang.String", "java.security.Provider"]},
62
+ {"name": "getInstance", "parameterTypes": ["java.lang.String"]}
63
+ ]
64
+ },
65
+ {
66
+ "name": "org.apache.sshd.common.io.nio2.Nio2ServiceFactoryFactory",
67
+ "allDeclaredConstructors": true,
68
+ "queryAllDeclaredConstructors": true
69
+ }
70
+]
ADD
src/main/resources/META-INF/native-image/de.workaround/git-shark/resource-config.json
+10 -0
@@ -0,0 +1,10 @@
1
+{
2
+ "resources": {
3
+ "includes": [
4
+ {"pattern": "org/eclipse/jgit/internal/JGitText\\.properties"},
5
+ {"pattern": ".*JGitText.*\\.properties"}
6
+ ]
7
+ },
8
+ "bundles": [
9
+ ]
10
+}
ADD
src/main/resources/application.properties
+31 -0
@@ -0,0 +1,31 @@
1
+# Datasource: dev/test/integration-tests use Quarkus Dev Services for PostgreSQL.
2
+# Production supplies QUARKUS_DATASOURCE_JDBC_URL / _USERNAME / _PASSWORD via environment.
3
+quarkus.datasource.db-kind=postgresql
4
+
5
+# Flyway owns the schema; Hibernate only validates
6
+quarkus.flyway.migrate-at-start=true
7
+quarkus.hibernate-orm.schema-management.strategy=validate
8
+quarkus.hibernate-orm.physical-naming-strategy=org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy
9
+
10
+# OIDC: dev/test use Keycloak Dev Services.
11
+# Production supplies QUARKUS_OIDC_AUTH_SERVER_URL / _CLIENT_ID / _CREDENTIALS_SECRET via environment.
12
+quarkus.oidc.application-type=web-app
13
+quarkus.oidc.logout.path=/logout
14
+quarkus.oidc.logout.post-logout-path=/
15
+
16
+# Pages that require a logged-in user (git transport does its own auth)
17
+quarkus.http.auth.permission.authenticated.paths=/settings/*,/repos/new
18
+quarkus.http.auth.permission.authenticated.policy=authenticated
19
+
20
+# Native image: JGit holds static Random/lazy state that must not be build-time initialized
21
+quarkus.native.additional-build-args=--initialize-at-run-time=org.eclipse.jgit,--initialize-at-run-time=org.apache.sshd.common.random,--initialize-at-run-time=org.apache.sshd.common.util.security.bouncycastle.BouncyCastleEncryptedPrivateKeyInfoDecryptor
22
+
23
+# Git repository storage
24
+gitshark.storage.root=${GITSHARK_STORAGE_ROOT:data/repositories}
25
+%test.gitshark.storage.root=target/test-repositories
26
+
27
+# Embedded SSH server
28
+gitshark.ssh.port=${GITSHARK_SSH_PORT:2222}
29
+gitshark.ssh.host-key-path=${GITSHARK_SSH_HOST_KEY:data/ssh/host-key}
30
+%test.gitshark.ssh.port=0
31
+%test.gitshark.ssh.host-key-path=target/test-ssh/host-key
ADD
src/main/resources/db/migration/V1__init.sql
+40 -0
@@ -0,0 +1,40 @@
1
+create table users
2
+(
3
+ id uuid primary key,
4
+ oidc_sub varchar(255) not null unique,
5
+ username varchar(255) not null unique,
6
+ display_name varchar(255),
7
+ email varchar(255),
8
+ created_at timestamptz not null default now()
9
+);
10
+
11
+create table repositories
12
+(
13
+ id uuid primary key,
14
+ name varchar(255) not null,
15
+ owner_id uuid not null references users (id),
16
+ visibility varchar(16) not null check (visibility in ('PUBLIC', 'PRIVATE')),
17
+ description text,
18
+ created_at timestamptz not null default now(),
19
+ unique (owner_id, name)
20
+);
21
+
22
+create table ssh_keys
23
+(
24
+ id uuid primary key,
25
+ user_id uuid not null references users (id) on delete cascade,
26
+ title varchar(255) not null,
27
+ public_key text not null,
28
+ fingerprint varchar(128) not null unique,
29
+ created_at timestamptz not null default now()
30
+);
31
+
32
+create table access_tokens
33
+(
34
+ id uuid primary key,
35
+ user_id uuid not null references users (id) on delete cascade,
36
+ token_hash varchar(128) not null unique,
37
+ label varchar(255) not null,
38
+ last_used timestamptz,
39
+ created_at timestamptz not null default now()
40
+);
ADD
src/main/resources/templates/HomeResource/home.html
+17 -0
@@ -0,0 +1,17 @@
1
+{#include layout}
2
+{#title}git-shark{/title}
3
+<h1>Repositories</h1>
4
+{#if user}
5
+<p><a href="/repos/new">New repository</a></p>
6
+{/if}
7
+<table>
8
+ <tr><th>Repository</th><th>Visibility</th><th>Description</th></tr>
9
+ {#for repo in repositories}
10
+ <tr>
11
+ <td><a href="/repos/{repo.owner.username}/{repo.name}">{repo.owner.username}/{repo.name}</a></td>
12
+ <td>{repo.visibility}</td>
13
+ <td>{repo.description ?: ''}</td>
14
+ </tr>
15
+ {/for}
16
+</table>
17
+{/include}
ADD
src/main/resources/templates/HomeResource/newRepo.html
+18 -0
@@ -0,0 +1,18 @@
1
+{#include layout}
2
+{#title}New repository โ git-shark{/title}
3
+<h1>New repository</h1>
4
+{#if error}
5
+<p class="error">{error}</p>
6
+{/if}
7
+<form method="post" action="/repos">
8
+ <p><label>Name <input name="name" required pattern="[a-zA-Z0-9._-]+"></label></p>
9
+ <p><label>Visibility
10
+ <select name="visibility">
11
+ <option value="PUBLIC">Public</option>
12
+ <option value="PRIVATE">Private</option>
13
+ </select>
14
+ </label></p>
15
+ <p><label>Description <input name="description"></label></p>
16
+ <button>Create repository</button>
17
+</form>
18
+{/include}
ADD
src/main/resources/templates/RepositoryResource/blob.html
+10 -0
@@ -0,0 +1,10 @@
1
+{#include layout}
2
+{#title}{path} at {ref} โ {repo.name}{/title}
3
+<h1><a href="/repos/{repo.owner.username}/{repo.name}">{repo.owner.username}/{repo.name}</a></h1>
4
+<p><code>{ref}</code> · /{path}</p>
5
+{#if binary}
6
+<p>Binary file. <a href="/repos/{repo.owner.username}/{repo.name}/raw/{ref}/{path}">Download</a></p>
7
+{#else}
8
+<pre>{content}</pre>
9
+{/if}
10
+{/include}
ADD
src/main/resources/templates/RepositoryResource/branches.html
+25 -0
@@ -0,0 +1,25 @@
1
+{#include layout}
2
+{#title}Branches โ {repo.name}{/title}
3
+<h1><a href="/repos/{repo.owner.username}/{repo.name}">{repo.owner.username}/{repo.name}</a></h1>
4
+<h2>Branches</h2>
5
+<table>
6
+ <tr><th>Branch</th><th></th></tr>
7
+ {#for branch in branches}
8
+ <tr>
9
+ <td><a href="/repos/{repo.owner.username}/{repo.name}/tree/{branch.name}/">{branch.name}</a></td>
10
+ <td>{#if branch.defaultBranch}default{/if}</td>
11
+ </tr>
12
+ {/for}
13
+</table>
14
+<h2>Tags</h2>
15
+{#if tags.isEmpty()}
16
+<p>No tags.</p>
17
+{#else}
18
+<table>
19
+ <tr><th>Tag</th></tr>
20
+ {#for tag in tags}
21
+ <tr><td><a href="/repos/{repo.owner.username}/{repo.name}/tree/{tag}/">{tag}</a></td></tr>
22
+ {/for}
23
+</table>
24
+{/if}
25
+{/include}
ADD
src/main/resources/templates/RepositoryResource/commits.html
+24 -0
@@ -0,0 +1,24 @@
1
+{#include layout}
2
+{#title}Commits at {ref} โ {repo.name}{/title}
3
+<h1><a href="/repos/{repo.owner.username}/{repo.name}">{repo.owner.username}/{repo.name}</a></h1>
4
+<h2>Commits on <code>{ref}</code></h2>
5
+<table>
6
+ <tr><th>Commit</th><th>Message</th><th>Author</th><th>Date</th></tr>
7
+ {#for commit in commits}
8
+ <tr>
9
+ <td><code>{commit.shortId}</code></td>
10
+ <td>{commit.message}</td>
11
+ <td>{commit.author}</td>
12
+ <td>{commit.date}</td>
13
+ </tr>
14
+ {/for}
15
+</table>
16
+<p>
17
+ {#if page > 0}
18
+ <a href="/repos/{repo.owner.username}/{repo.name}/commits/{ref}?page={prevPage}&size={size}">Newer</a>
19
+ {/if}
20
+ {#if hasNext}
21
+ <a href="/repos/{repo.owner.username}/{repo.name}/commits/{ref}?page={nextPage}&size={size}">Older</a>
22
+ {/if}
23
+</p>
24
+{/include}
ADD
src/main/resources/templates/RepositoryResource/overview.html
+39 -0
@@ -0,0 +1,39 @@
1
+{#include layout}
2
+{#title}{repo.owner.username}/{repo.name} โ git-shark{/title}
3
+<h1>{repo.owner.username}/{repo.name} <small>({repo.visibility})</small></h1>
4
+{#if repo.description}
5
+<p>{repo.description}</p>
6
+{/if}
7
+<p>
8
+ Clone: <code>{httpUrl}</code> · <code>{sshUrl}</code>
9
+</p>
10
+{#if empty}
11
+<h2>Quick start</h2>
12
+<p>This repository is empty. Push an existing repository:</p>
13
+<pre>git remote add origin {httpUrl}
14
+git push -u origin main</pre>
15
+{#else}
16
+<p>
17
+ <a href="/repos/{repo.owner.username}/{repo.name}/commits/{defaultBranch}">Commits</a> ·
18
+ <a href="/repos/{repo.owner.username}/{repo.name}/branches">Branches & tags</a>
19
+</p>
20
+<table>
21
+ <tr><th>Name</th></tr>
22
+ {#for entry in entries}
23
+ <tr>
24
+ <td>
25
+ {#if entry.directory}📁{/if}
26
+ <a href="/repos/{repo.owner.username}/{repo.name}/tree/{defaultBranch}/{entry.path}">{entry.name}</a>
27
+ </td>
28
+ </tr>
29
+ {/for}
30
+</table>
31
+{/if}
32
+{#if owner}
33
+<h2>Danger zone</h2>
34
+<form method="post" action="/repos/{repo.owner.username}/{repo.name}/delete">
35
+ <p><label>Type the repository name to confirm deletion <input name="confirm"></label></p>
36
+ <button>Delete repository</button>
37
+</form>
38
+{/if}
39
+{/include}
ADD
src/main/resources/templates/RepositoryResource/tree.html
+16 -0
@@ -0,0 +1,16 @@
1
+{#include layout}
2
+{#title}{path ?: '/'} at {ref} โ {repo.name}{/title}
3
+<h1><a href="/repos/{repo.owner.username}/{repo.name}">{repo.owner.username}/{repo.name}</a></h1>
4
+<p><code>{ref}</code> · /{path}</p>
5
+<table>
6
+ <tr><th>Name</th></tr>
7
+ {#for entry in entries}
8
+ <tr>
9
+ <td>
10
+ {#if entry.directory}📁{/if}
11
+ <a href="/repos/{repo.owner.username}/{repo.name}/tree/{ref}/{entry.path}">{entry.name}</a>
12
+ </td>
13
+ </tr>
14
+ {/for}
15
+</table>
16
+{/include}
ADD
src/main/resources/templates/SettingsResource/keys.html
+27 -0
@@ -0,0 +1,27 @@
1
+{#include layout}
2
+{#title}SSH keys โ git-shark{/title}
3
+<h1>SSH keys</h1>
4
+{#if error}
5
+<p class="error">{error}</p>
6
+{/if}
7
+<table>
8
+ <tr><th>Title</th><th>Fingerprint</th><th></th></tr>
9
+ {#for key in keys}
10
+ <tr>
11
+ <td>{key.title}</td>
12
+ <td><code>{key.fingerprint}</code></td>
13
+ <td>
14
+ <form class="inline" method="post" action="/settings/keys/{key.id}/delete">
15
+ <button>Remove</button>
16
+ </form>
17
+ </td>
18
+ </tr>
19
+ {/for}
20
+</table>
21
+<h2>Add key</h2>
22
+<form method="post" action="/settings/keys">
23
+ <p><label>Title <input name="title" required></label></p>
24
+ <p><label>Public key (OpenSSH format)<br><textarea name="key" required></textarea></label></p>
25
+ <button>Add key</button>
26
+</form>
27
+{/include}
ADD
src/main/resources/templates/SettingsResource/tokenCreated.html
+8 -0
@@ -0,0 +1,8 @@
1
+{#include layout}
2
+{#title}Token created โ git-shark{/title}
3
+<h1>Token created</h1>
4
+<p>Copy your new access token now โ it will not be shown again:</p>
5
+<p><code>{plaintext}</code></p>
6
+<p>Use it as the password for Git over HTTPS.</p>
7
+<p><a href="/settings/tokens">Back to tokens</a></p>
8
+{/include}
ADD
src/main/resources/templates/SettingsResource/tokens.html
+24 -0
@@ -0,0 +1,24 @@
1
+{#include layout}
2
+{#title}Access tokens โ git-shark{/title}
3
+<h1>Access tokens</h1>
4
+<table>
5
+ <tr><th>Label</th><th>Created</th><th>Last used</th><th></th></tr>
6
+ {#for token in tokens}
7
+ <tr>
8
+ <td>{token.label}</td>
9
+ <td>{token.createdAt}</td>
10
+ <td>{token.lastUsed ?: 'never'}</td>
11
+ <td>
12
+ <form class="inline" method="post" action="/settings/tokens/{token.id}/revoke">
13
+ <button>Revoke</button>
14
+ </form>
15
+ </td>
16
+ </tr>
17
+ {/for}
18
+</table>
19
+<h2>Create token</h2>
20
+<form method="post" action="/settings/tokens">
21
+ <p><label>Label <input name="label" required></label></p>
22
+ <button>Create token</button>
23
+</form>
24
+{/include}
ADD
src/main/resources/templates/layout.html
+35 -0
@@ -0,0 +1,35 @@
1
+<!DOCTYPE html>
2
+<html lang="en">
3
+<head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>{#insert title}git-shark{/}</title>
7
+ <style>
8
+ body { font-family: system-ui, sans-serif; margin: 0; color: #1f2328; }
9
+ header { display: flex; gap: 1.5rem; align-items: center; padding: 0.75rem 1.5rem; background: #24292f; }
10
+ header a { color: #fff; text-decoration: none; font-weight: 600; }
11
+ header nav { display: flex; gap: 1rem; margin-left: auto; }
12
+ header nav a { font-weight: 400; }
13
+ main { max-width: 960px; margin: 2rem auto; padding: 0 1.5rem; }
14
+ table { border-collapse: collapse; width: 100%; }
15
+ td, th { border: 1px solid #d0d7de; padding: 0.4rem 0.6rem; text-align: left; }
16
+ code { background: #f6f8fa; padding: 0.1rem 0.3rem; border-radius: 4px; }
17
+ .error { color: #cf222e; }
18
+ form.inline { display: inline; }
19
+ textarea { width: 100%; min-height: 6rem; }
20
+ </style>
21
+</head>
22
+<body>
23
+<header>
24
+ <a href="/">🦈 git-shark</a>
25
+ <nav>
26
+ <a href="/settings/keys">SSH keys</a>
27
+ <a href="/settings/tokens">Access tokens</a>
28
+ <a href="/logout">Logout</a>
29
+ </nav>
30
+</header>
31
+<main>
32
+ {#insert /}
33
+</main>
34
+</body>
35
+</html>
ADD
src/test/java/de/workaround/SmokeIT.java
+40 -0
@@ -0,0 +1,40 @@
1
+package de.workaround;
2
+
3
+import io.quarkus.test.junit.QuarkusIntegrationTest;
4
+import org.junit.jupiter.api.Test;
5
+
6
+import static io.restassured.RestAssured.given;
7
+import static org.hamcrest.CoreMatchers.is;
8
+
9
+/**
10
+ * Boots the packaged application (JVM jar or native binary) and verifies the service is alive:
11
+ * HTTP health endpoint up and the embedded SSH server accepting connections.
12
+ */
13
+@QuarkusIntegrationTest
14
+class SmokeIT
15
+{
16
+ @Test
17
+ void healthEndpointIsUp()
18
+ {
19
+ given()
20
+ .when().get("/q/health")
21
+ .then()
22
+ .statusCode(200)
23
+ .body("status", is("UP"));
24
+ }
25
+
26
+ @Test
27
+ void sshPortPresentsSshBanner() throws Exception
28
+ {
29
+ int port = Integer.getInteger("gitshark.ssh.port", 2222);
30
+ try (java.net.Socket socket = new java.net.Socket("localhost", port))
31
+ {
32
+ byte[] banner = new byte[7];
33
+ int read = socket.getInputStream().readNBytes(banner, 0, banner.length);
34
+ org.junit.jupiter.api.Assertions.assertEquals(7, read);
35
+ org.junit.jupiter.api.Assertions.assertEquals("SSH-2.0",
36
+ new String(banner, java.nio.charset.StandardCharsets.US_ASCII));
37
+ }
38
+ }
39
+
40
+}
ADD
src/test/java/de/workaround/account/SettingsPagesTest.java
+90 -0
@@ -0,0 +1,90 @@
1
+package de.workaround.account;
2
+
3
+import org.junit.jupiter.api.Test;
4
+
5
+import io.quarkus.test.junit.QuarkusTest;
6
+import io.quarkus.test.security.TestSecurity;
7
+
8
+import static io.restassured.RestAssured.given;
9
+import static org.hamcrest.CoreMatchers.containsString;
10
+import static org.hamcrest.CoreMatchers.not;
11
+import static org.hamcrest.Matchers.anyOf;
12
+import static org.hamcrest.Matchers.is;
13
+
14
+@QuarkusTest
15
+class SettingsPagesTest
16
+{
17
+ private static final String VALID_KEY = de.workaround.ssh.TestKeys.validOpenSshKey();
18
+
19
+ @Test
20
+ @TestSecurity(user = "settings-tester")
21
+ void sshKeyPageListsAndAddsKeys()
22
+ {
23
+ given()
24
+ .when().get("/settings/keys")
25
+ .then()
26
+ .statusCode(200)
27
+ .body(containsString("SSH keys"));
28
+
29
+ given()
30
+ .redirects().follow(false)
31
+ .formParam("title", "my laptop")
32
+ .formParam("key", VALID_KEY)
33
+ .when().post("/settings/keys")
34
+ .then()
35
+ .statusCode(anyOf(is(302), is(303)));
36
+
37
+ given()
38
+ .when().get("/settings/keys")
39
+ .then()
40
+ .statusCode(200)
41
+ .body(containsString("my laptop"));
42
+ }
43
+
44
+ @Test
45
+ @TestSecurity(user = "settings-tester")
46
+ void rejectsInvalidSshKeyWithMessage()
47
+ {
48
+ given()
49
+ .formParam("title", "broken")
50
+ .formParam("key", "garbage")
51
+ .when().post("/settings/keys")
52
+ .then()
53
+ .statusCode(400)
54
+ .body(containsString("Not a valid OpenSSH public key"));
55
+ }
56
+
57
+ @Test
58
+ @TestSecurity(user = "token-tester")
59
+ void tokenCreatedPageShowsPlaintextExactlyOnce()
60
+ {
61
+ String body = given()
62
+ .formParam("label", "ci-token")
63
+ .when().post("/settings/tokens")
64
+ .then()
65
+ .statusCode(200)
66
+ .body(containsString("gs_"))
67
+ .extract().body().asString();
68
+
69
+ String plaintext = body.substring(body.indexOf("gs_"));
70
+ plaintext = plaintext.substring(0, plaintext.indexOf('<'));
71
+
72
+ given()
73
+ .when().get("/settings/tokens")
74
+ .then()
75
+ .statusCode(200)
76
+ .body(containsString("ci-token"))
77
+ .body(not(containsString(plaintext)));
78
+ }
79
+
80
+ @Test
81
+ void anonymousIsRedirectedToLogin()
82
+ {
83
+ given()
84
+ .redirects().follow(false)
85
+ .when().get("/settings/keys")
86
+ .then()
87
+ .statusCode(302);
88
+ }
89
+
90
+}
ADD
src/test/java/de/workaround/account/SshKeyServiceTest.java
+111 -0
@@ -0,0 +1,111 @@
1
+package de.workaround.account;
2
+
3
+import java.security.KeyPairGenerator;
4
+import java.util.UUID;
5
+
6
+import org.apache.sshd.common.config.keys.PublicKeyEntry;
7
+import org.junit.jupiter.api.Test;
8
+
9
+import de.workaround.model.SshKey;
10
+import de.workaround.model.User;
11
+import io.quarkus.test.TestTransaction;
12
+import io.quarkus.test.junit.QuarkusTest;
13
+import jakarta.inject.Inject;
14
+
15
+import static org.junit.jupiter.api.Assertions.assertEquals;
16
+import static org.junit.jupiter.api.Assertions.assertThrows;
17
+import static org.junit.jupiter.api.Assertions.assertTrue;
18
+
19
+@QuarkusTest
20
+class SshKeyServiceTest
21
+{
22
+ @Inject
23
+ SshKeyService service;
24
+
25
+ @Inject
26
+ SshKey.Repo sshKeys;
27
+
28
+ @Test
29
+ @TestTransaction
30
+ void addsValidOpenSshKeyAndComputesFingerprint()
31
+ {
32
+ User user = persistUser();
33
+
34
+ SshKey key = service.add(user, "laptop", validOpenSshKey());
35
+
36
+ assertTrue(key.fingerprint.startsWith("SHA256:"), "fingerprint computed in OpenSSH format");
37
+ assertEquals(1, service.list(user).size());
38
+ }
39
+
40
+ @Test
41
+ @TestTransaction
42
+ void rejectsMalformedKeyMaterial()
43
+ {
44
+ User user = persistUser();
45
+
46
+ assertThrows(InvalidSshKeyException.class, () -> service.add(user, "bad", "not-a-key AAAA"));
47
+ assertThrows(InvalidSshKeyException.class, () -> service.add(user, "empty", ""));
48
+ }
49
+
50
+ @Test
51
+ @TestTransaction
52
+ void rejectsKeyAlreadyRegisteredToAnyAccount()
53
+ {
54
+ User first = persistUser();
55
+ User second = persistUser();
56
+ String key = validOpenSshKey();
57
+ service.add(first, "original", key);
58
+
59
+ assertThrows(DuplicateSshKeyException.class, () -> service.add(second, "copy", key));
60
+ }
61
+
62
+ @Test
63
+ @TestTransaction
64
+ void removedKeyIsGoneFromLookup()
65
+ {
66
+ User user = persistUser();
67
+ SshKey key = service.add(user, "transient", validOpenSshKey());
68
+ String fingerprint = key.fingerprint;
69
+
70
+ service.remove(user, key.id);
71
+
72
+ assertTrue(sshKeys.findByFingerprint(fingerprint).isEmpty(),
73
+ "SSH authenticator lookup must no longer find the key");
74
+ }
75
+
76
+ @Test
77
+ @TestTransaction
78
+ void removeByNonOwnerForbidden()
79
+ {
80
+ User owner = persistUser();
81
+ User stranger = persistUser();
82
+ SshKey key = service.add(owner, "mine", validOpenSshKey());
83
+
84
+ assertThrows(de.workaround.git.ForbiddenOperationException.class, () -> service.remove(stranger, key.id));
85
+ }
86
+
87
+ private static String validOpenSshKey()
88
+ {
89
+ try
90
+ {
91
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
92
+ generator.initialize(2048);
93
+ return PublicKeyEntry.toString(generator.generateKeyPair().getPublic()) + " test@example.com";
94
+ }
95
+ catch (Exception e)
96
+ {
97
+ throw new IllegalStateException(e);
98
+ }
99
+ }
100
+
101
+ private static User persistUser()
102
+ {
103
+ String name = "key-" + UUID.randomUUID();
104
+ User user = new User();
105
+ user.oidcSub = "sub-" + name;
106
+ user.username = name;
107
+ user.persist();
108
+ return user;
109
+ }
110
+
111
+}
ADD
src/test/java/de/workaround/account/UserProvisioningServiceTest.java
+54 -0
@@ -0,0 +1,54 @@
1
+package de.workaround.account;
2
+
3
+import java.util.UUID;
4
+
5
+import org.junit.jupiter.api.Test;
6
+
7
+import de.workaround.model.User;
8
+import io.quarkus.test.TestTransaction;
9
+import io.quarkus.test.junit.QuarkusTest;
10
+import jakarta.inject.Inject;
11
+
12
+import static org.junit.jupiter.api.Assertions.assertEquals;
13
+import static org.junit.jupiter.api.Assertions.assertNotNull;
14
+
15
+@QuarkusTest
16
+class UserProvisioningServiceTest
17
+{
18
+ @Inject
19
+ UserProvisioningService service;
20
+
21
+ @Inject
22
+ User.Repo users;
23
+
24
+ @Test
25
+ @TestTransaction
26
+ void firstLoginCreatesUserKeyedByOidcSub()
27
+ {
28
+ String sub = "sub-" + UUID.randomUUID();
29
+
30
+ User user = service.provision(sub, "alice", "Alice A.", "alice@example.com");
31
+
32
+ assertNotNull(user.id);
33
+ User stored = users.findByOidcSub(sub);
34
+ assertEquals("alice", stored.username);
35
+ assertEquals("Alice A.", stored.displayName);
36
+ assertEquals("alice@example.com", stored.email);
37
+ }
38
+
39
+ @Test
40
+ @TestTransaction
41
+ void subsequentLoginUpdatesProfileWithoutDuplicate()
42
+ {
43
+ String sub = "sub-" + UUID.randomUUID();
44
+ User first = service.provision(sub, "bob", "Bob", "bob@example.com");
45
+
46
+ User second = service.provision(sub, "bob", "Bobby", "bobby@example.com");
47
+
48
+ assertEquals(first.id, second.id, "no duplicate user");
49
+ User stored = users.findByOidcSub(sub);
50
+ assertEquals("Bobby", stored.displayName);
51
+ assertEquals("bobby@example.com", stored.email);
52
+ }
53
+
54
+}
ADD
src/test/java/de/workaround/git/AccessPolicyTest.java
+68 -0
@@ -0,0 +1,68 @@
1
+package de.workaround.git;
2
+
3
+import java.util.UUID;
4
+
5
+import org.junit.jupiter.api.Test;
6
+
7
+import de.workaround.model.Repository;
8
+import de.workaround.model.User;
9
+
10
+import static org.junit.jupiter.api.Assertions.assertFalse;
11
+import static org.junit.jupiter.api.Assertions.assertTrue;
12
+
13
+class AccessPolicyTest
14
+{
15
+ private final AccessPolicy policy = new AccessPolicy();
16
+
17
+ private final User owner = user();
18
+
19
+ private final User stranger = user();
20
+
21
+ @Test
22
+ void ownerReadsAndWritesOwnRepository()
23
+ {
24
+ Repository repo = repo(owner, Repository.Visibility.PRIVATE);
25
+
26
+ assertTrue(policy.canRead(owner, repo));
27
+ assertTrue(policy.canWrite(owner, repo));
28
+ }
29
+
30
+ @Test
31
+ void publicRepositoryIsWorldReadableButNotWritable()
32
+ {
33
+ Repository repo = repo(owner, Repository.Visibility.PUBLIC);
34
+
35
+ assertTrue(policy.canRead(stranger, repo));
36
+ assertTrue(policy.canRead(null, repo), "anonymous read on public repo");
37
+ assertFalse(policy.canWrite(stranger, repo));
38
+ assertFalse(policy.canWrite(null, repo), "anonymous never writes");
39
+ }
40
+
41
+ @Test
42
+ void privateRepositoryIsOwnerOnly()
43
+ {
44
+ Repository repo = repo(owner, Repository.Visibility.PRIVATE);
45
+
46
+ assertFalse(policy.canRead(stranger, repo));
47
+ assertFalse(policy.canRead(null, repo));
48
+ assertFalse(policy.canWrite(stranger, repo));
49
+ }
50
+
51
+ private static User user()
52
+ {
53
+ User user = new User();
54
+ user.id = UUID.randomUUID();
55
+ user.username = "u-" + user.id;
56
+ return user;
57
+ }
58
+
59
+ private static Repository repo(User owner, Repository.Visibility visibility)
60
+ {
61
+ Repository repo = new Repository();
62
+ repo.id = UUID.randomUUID();
63
+ repo.owner = owner;
64
+ repo.visibility = visibility;
65
+ return repo;
66
+ }
67
+
68
+}
ADD
src/test/java/de/workaround/git/GitRepositoryServiceTest.java
+153 -0
@@ -0,0 +1,153 @@
1
+package de.workaround.git;
2
+
3
+import java.nio.file.Files;
4
+import java.nio.file.Path;
5
+import java.util.List;
6
+import java.util.UUID;
7
+
8
+import org.junit.jupiter.api.Test;
9
+
10
+import de.workaround.model.Repository;
11
+import de.workaround.model.User;
12
+import io.quarkus.test.TestTransaction;
13
+import io.quarkus.test.junit.QuarkusTest;
14
+import jakarta.inject.Inject;
15
+
16
+import static org.junit.jupiter.api.Assertions.assertEquals;
17
+import static org.junit.jupiter.api.Assertions.assertFalse;
18
+import static org.junit.jupiter.api.Assertions.assertNotNull;
19
+import static org.junit.jupiter.api.Assertions.assertThrows;
20
+import static org.junit.jupiter.api.Assertions.assertTrue;
21
+
22
+@QuarkusTest
23
+class GitRepositoryServiceTest
24
+{
25
+ @Inject
26
+ GitRepositoryService service;
27
+
28
+ @Test
29
+ @TestTransaction
30
+ void createInitializesBareRepoAndPersistsMetadata()
31
+ {
32
+ User owner = persistUser();
33
+
34
+ Repository repo = service.create(owner, "project", Repository.Visibility.PUBLIC, "demo");
35
+
36
+ assertNotNull(repo.id);
37
+ Path path = service.repositoryPath(repo);
38
+ assertTrue(Files.isDirectory(path), "bare repository directory must exist");
39
+ assertTrue(Files.exists(path.resolve("HEAD")), "bare repository must have a HEAD file");
40
+ assertTrue(path.getFileName().toString().endsWith(".git"));
41
+ }
42
+
43
+ @Test
44
+ @TestTransaction
45
+ void createRejectsDuplicateNamePerOwner()
46
+ {
47
+ User owner = persistUser();
48
+ service.create(owner, "twice", Repository.Visibility.PRIVATE, null);
49
+
50
+ assertThrows(RepositoryAlreadyExistsException.class,
51
+ () -> service.create(owner, "twice", Repository.Visibility.PRIVATE, null));
52
+ }
53
+
54
+ @Test
55
+ @TestTransaction
56
+ void createRejectsInvalidNames()
57
+ {
58
+ User owner = persistUser();
59
+
60
+ for (String invalid : new String[] { "..", ".", "a/b", "evil name", "", "tilde~" })
61
+ {
62
+ assertThrows(InvalidRepositoryNameException.class,
63
+ () -> service.create(owner, invalid, Repository.Visibility.PUBLIC, null), "should reject: " + invalid);
64
+ }
65
+ }
66
+
67
+ @Test
68
+ @TestTransaction
69
+ void resolvesExistingRepositoryByOwnerAndName()
70
+ {
71
+ User owner = persistUser();
72
+ Repository created = service.create(owner, "resolvable", Repository.Visibility.PUBLIC, null);
73
+
74
+ Repository found = service.find(owner.username, "resolvable").orElseThrow();
75
+ assertEquals(created.id, found.id);
76
+ assertTrue(Files.isDirectory(service.repositoryPath(found)));
77
+ }
78
+
79
+ @Test
80
+ @TestTransaction
81
+ void resolveUnknownRepositoryIsEmpty()
82
+ {
83
+ assertTrue(service.find("nobody", "nothing").isEmpty());
84
+ }
85
+
86
+ @Test
87
+ @TestTransaction
88
+ void deleteRemovesDatabaseRecordAndDisk()
89
+ {
90
+ User owner = persistUser();
91
+ Repository repo = service.create(owner, "doomed", Repository.Visibility.PUBLIC, null);
92
+ Path path = service.repositoryPath(repo);
93
+
94
+ service.delete(owner, repo);
95
+
96
+ assertFalse(Files.exists(path), "repository directory must be removed");
97
+ assertTrue(service.find(owner.username, "doomed").isEmpty());
98
+ }
99
+
100
+ @Test
101
+ @TestTransaction
102
+ void deleteByNonOwnerIsForbidden()
103
+ {
104
+ User owner = persistUser();
105
+ User stranger = persistUser();
106
+ Repository repo = service.create(owner, "guarded", Repository.Visibility.PUBLIC, null);
107
+
108
+ assertThrows(ForbiddenOperationException.class, () -> service.delete(stranger, repo));
109
+ assertTrue(service.find(owner.username, "guarded").isPresent());
110
+ }
111
+
112
+ @Test
113
+ @TestTransaction
114
+ void listsOwnAndPublicRepositoriesForUser()
115
+ {
116
+ User alice = persistUser();
117
+ User bob = persistUser();
118
+ Repository alicePrivate = service.create(alice, "alice-private", Repository.Visibility.PRIVATE, null);
119
+ Repository bobPublic = service.create(bob, "bob-public", Repository.Visibility.PUBLIC, null);
120
+ Repository bobPrivate = service.create(bob, "bob-private", Repository.Visibility.PRIVATE, null);
121
+
122
+ List<Repository> visible = service.listVisibleTo(alice);
123
+
124
+ assertTrue(visible.stream().anyMatch(r -> r.id.equals(alicePrivate.id)), "own private visible");
125
+ assertTrue(visible.stream().anyMatch(r -> r.id.equals(bobPublic.id)), "foreign public visible");
126
+ assertFalse(visible.stream().anyMatch(r -> r.id.equals(bobPrivate.id)), "foreign private hidden");
127
+ }
128
+
129
+ @Test
130
+ @TestTransaction
131
+ void anonymousSeesOnlyPublicRepositories()
132
+ {
133
+ User owner = persistUser();
134
+ Repository pub = service.create(owner, "pub", Repository.Visibility.PUBLIC, null);
135
+ Repository priv = service.create(owner, "priv", Repository.Visibility.PRIVATE, null);
136
+
137
+ List<Repository> visible = service.listVisibleTo(null);
138
+
139
+ assertTrue(visible.stream().anyMatch(r -> r.id.equals(pub.id)));
140
+ assertFalse(visible.stream().anyMatch(r -> r.id.equals(priv.id)));
141
+ }
142
+
143
+ private static User persistUser()
144
+ {
145
+ String name = "user-" + UUID.randomUUID();
146
+ User user = new User();
147
+ user.oidcSub = "sub-" + name;
148
+ user.username = name;
149
+ user.persist();
150
+ return user;
151
+ }
152
+
153
+}
ADD
src/test/java/de/workaround/git/GitTestSeeder.java
+51 -0
@@ -0,0 +1,51 @@
1
+package de.workaround.git;
2
+
3
+import java.nio.file.Files;
4
+import java.nio.file.Path;
5
+import java.util.Map;
6
+
7
+import org.eclipse.jgit.api.Git;
8
+import org.eclipse.jgit.transport.RefSpec;
9
+
10
+/** Seeds bare test repositories through a temporary working clone over file://. */
11
+public final class GitTestSeeder
12
+{
13
+ private GitTestSeeder()
14
+ {
15
+ }
16
+
17
+ public static void seed(Path barePath, Map<String, byte[]> files) throws Exception
18
+ {
19
+ seed(barePath, files, 1);
20
+ }
21
+
22
+ public static void seed(Path barePath, Map<String, byte[]> files, int commits) throws Exception
23
+ {
24
+ Path work = Files.createTempDirectory("seed");
25
+ try (Git git = Git.cloneRepository().setURI(barePath.toUri().toString()).setDirectory(work.toFile()).call())
26
+ {
27
+ for (Map.Entry<String, byte[]> file : files.entrySet())
28
+ {
29
+ Path target = work.resolve(file.getKey());
30
+ Files.createDirectories(target.getParent() == null ? work : target.getParent());
31
+ Files.write(target, file.getValue());
32
+ }
33
+ git.add().addFilepattern(".").call();
34
+ commit(git, "commit 1");
35
+ for (int i = 2; i <= commits; i++)
36
+ {
37
+ Files.writeString(work.resolve("counter.txt"), "revision " + i + "\n");
38
+ git.add().addFilepattern(".").call();
39
+ commit(git, "commit " + i);
40
+ }
41
+ git.push().setRefSpecs(new RefSpec("HEAD:refs/heads/main")).call();
42
+ }
43
+ }
44
+
45
+ private static void commit(Git git, String message) throws Exception
46
+ {
47
+ git.commit().setMessage(message).setSign(false)
48
+ .setAuthor("seed", "seed@example.com").setCommitter("seed", "seed@example.com").call();
49
+ }
50
+
51
+}
ADD
src/test/java/de/workaround/http/AccessTokenServiceTest.java
+81 -0
@@ -0,0 +1,81 @@
1
+package de.workaround.http;
2
+
3
+import java.util.UUID;
4
+
5
+import org.junit.jupiter.api.Test;
6
+
7
+import de.workaround.model.AccessToken;
8
+import de.workaround.model.User;
9
+import io.quarkus.test.TestTransaction;
10
+import io.quarkus.test.junit.QuarkusTest;
11
+import jakarta.inject.Inject;
12
+
13
+import static org.junit.jupiter.api.Assertions.assertEquals;
14
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
15
+import static org.junit.jupiter.api.Assertions.assertTrue;
16
+
17
+@QuarkusTest
18
+class AccessTokenServiceTest
19
+{
20
+ @Inject
21
+ AccessTokenService service;
22
+
23
+ @Inject
24
+ AccessToken.Repo tokens;
25
+
26
+ @Test
27
+ @TestTransaction
28
+ void createReturnsPlaintextOnceAndStoresOnlyHash()
29
+ {
30
+ User user = persistUser();
31
+
32
+ AccessTokenService.CreatedToken created = service.create(user, "ci");
33
+
34
+ assertTrue(created.plaintext().startsWith("gs_"), "token has recognizable prefix");
35
+ AccessToken stored = tokens.findByTokenHash(service.hash(created.plaintext())).orElseThrow();
36
+ assertEquals("ci", stored.label);
37
+ assertNotEquals(created.plaintext(), stored.tokenHash, "plaintext must never be persisted");
38
+ }
39
+
40
+ @Test
41
+ @TestTransaction
42
+ void authenticatesValidTokenToItsUser()
43
+ {
44
+ User user = persistUser();
45
+ AccessTokenService.CreatedToken created = service.create(user, "ci");
46
+
47
+ User authenticated = service.authenticate(created.plaintext()).orElseThrow();
48
+
49
+ assertEquals(user.id, authenticated.id);
50
+ }
51
+
52
+ @Test
53
+ @TestTransaction
54
+ void rejectsUnknownToken()
55
+ {
56
+ assertTrue(service.authenticate("gs_does-not-exist").isEmpty());
57
+ }
58
+
59
+ @Test
60
+ @TestTransaction
61
+ void revokedTokenNoLongerAuthenticates()
62
+ {
63
+ User user = persistUser();
64
+ AccessTokenService.CreatedToken created = service.create(user, "ci");
65
+
66
+ service.revoke(user, created.token().id);
67
+
68
+ assertTrue(service.authenticate(created.plaintext()).isEmpty());
69
+ }
70
+
71
+ private static User persistUser()
72
+ {
73
+ String name = "token-" + UUID.randomUUID();
74
+ User user = new User();
75
+ user.oidcSub = "sub-" + name;
76
+ user.username = name;
77
+ user.persist();
78
+ return user;
79
+ }
80
+
81
+}
ADD
src/test/java/de/workaround/http/GitSmartHttpTest.java
+237 -0
@@ -0,0 +1,237 @@
1
+package de.workaround.http;
2
+
3
+import java.net.URL;
4
+import java.nio.file.Files;
5
+import java.nio.file.Path;
6
+import java.util.UUID;
7
+
8
+import org.eclipse.jgit.api.Git;
9
+import org.eclipse.jgit.api.errors.TransportException;
10
+import org.eclipse.jgit.lib.ObjectId;
11
+import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
12
+import org.eclipse.jgit.transport.RefSpec;
13
+import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider;
14
+import org.junit.jupiter.api.Test;
15
+
16
+import de.workaround.git.GitRepositoryService;
17
+import de.workaround.model.Repository;
18
+import de.workaround.model.User;
19
+import io.quarkus.test.common.http.TestHTTPResource;
20
+import io.quarkus.test.junit.QuarkusTest;
21
+import jakarta.inject.Inject;
22
+import jakarta.transaction.Transactional;
23
+
24
+import static org.junit.jupiter.api.Assertions.assertEquals;
25
+import static org.junit.jupiter.api.Assertions.assertThrows;
26
+import static org.junit.jupiter.api.Assertions.assertTrue;
27
+
28
+@QuarkusTest
29
+public class GitSmartHttpTest
30
+{
31
+ @Inject
32
+ GitRepositoryService service;
33
+
34
+ @TestHTTPResource("/git")
35
+ URL gitBase;
36
+
37
+ @Test
38
+ void clonesPublicRepositoryAnonymously() throws Exception
39
+ {
40
+ User owner = persistUser();
41
+ Repository repo = createRepo(owner, "public-clone", Repository.Visibility.PUBLIC);
42
+ seedCommit(service.repositoryPath(repo));
43
+
44
+ Path target = Files.createTempDirectory("clone-target");
45
+ try (Git clone = Git.cloneRepository()
46
+ .setURI(gitBase + "/" + owner.username + "/public-clone.git")
47
+ .setDirectory(target.toFile())
48
+ .call())
49
+ {
50
+ assertTrue(Files.exists(target.resolve("README.md")), "cloned working tree must contain seeded file");
51
+ }
52
+ }
53
+
54
+ @Inject
55
+ AccessTokenService tokenService;
56
+
57
+ @Test
58
+ void anonymousPushIsRejectedAndRefsUnchanged() throws Exception
59
+ {
60
+ User owner = persistUser();
61
+ Repository repo = createRepo(owner, "anon-push", Repository.Visibility.PUBLIC);
62
+ seedCommit(service.repositoryPath(repo));
63
+ ObjectId before = mainRef(service.repositoryPath(repo));
64
+
65
+ Path work = Files.createTempDirectory("anon-push");
66
+ try (Git git = cloneOver(httpUrl(owner, "anon-push"), work, null))
67
+ {
68
+ commitFile(git, work, "evil.txt");
69
+ assertThrows(TransportException.class,
70
+ () -> git.push().setRefSpecs(new RefSpec("HEAD:refs/heads/main")).call());
71
+ }
72
+
73
+ assertEquals(before, mainRef(service.repositoryPath(repo)), "refs must not change on rejected push");
74
+ }
75
+
76
+ @Test
77
+ void ownerPushesWithAccessToken() throws Exception
78
+ {
79
+ User owner = persistUser();
80
+ Repository repo = createRepo(owner, "token-push", Repository.Visibility.PUBLIC);
81
+ seedCommit(service.repositoryPath(repo));
82
+ String token = createToken(owner);
83
+
84
+ Path work = Files.createTempDirectory("token-push");
85
+ try (Git git = cloneOver(httpUrl(owner, "token-push"), work, null))
86
+ {
87
+ commitFile(git, work, "feature.txt");
88
+ git.push()
89
+ .setCredentialsProvider(new UsernamePasswordCredentialsProvider(owner.username, token))
90
+ .setRefSpecs(new RefSpec("HEAD:refs/heads/main"))
91
+ .call();
92
+ }
93
+
94
+ ObjectId after = mainRef(service.repositoryPath(repo));
95
+ assertTrue(after != null, "main must exist after push");
96
+ }
97
+
98
+ @Test
99
+ void pushWithoutWritePermissionIsRejected() throws Exception
100
+ {
101
+ User owner = persistUser();
102
+ User stranger = persistUser();
103
+ Repository repo = createRepo(owner, "guarded-push", Repository.Visibility.PUBLIC);
104
+ seedCommit(service.repositoryPath(repo));
105
+ ObjectId before = mainRef(service.repositoryPath(repo));
106
+ String strangerToken = createToken(stranger);
107
+
108
+ Path work = Files.createTempDirectory("stranger-push");
109
+ try (Git git = cloneOver(httpUrl(owner, "guarded-push"), work, null))
110
+ {
111
+ commitFile(git, work, "intrusion.txt");
112
+ assertThrows(TransportException.class, () -> git.push()
113
+ .setCredentialsProvider(new UsernamePasswordCredentialsProvider(stranger.username, strangerToken))
114
+ .setRefSpecs(new RefSpec("HEAD:refs/heads/main"))
115
+ .call());
116
+ }
117
+
118
+ assertEquals(before, mainRef(service.repositoryPath(repo)), "refs must not change on forbidden push");
119
+ }
120
+
121
+ @Test
122
+ void privateRepositoryRequiresAuthenticationForClone() throws Exception
123
+ {
124
+ User owner = persistUser();
125
+ Repository repo = createRepo(owner, "private-clone", Repository.Visibility.PRIVATE);
126
+ seedCommit(service.repositoryPath(repo));
127
+
128
+ Path anonTarget = Files.createTempDirectory("private-anon");
129
+ assertThrows(TransportException.class,
130
+ () -> Git.cloneRepository().setURI(httpUrl(owner, "private-clone")).setDirectory(anonTarget.toFile()).call());
131
+
132
+ String token = createToken(owner);
133
+ Path authTarget = Files.createTempDirectory("private-auth");
134
+ try (Git clone = cloneOver(httpUrl(owner, "private-clone"), authTarget,
135
+ new UsernamePasswordCredentialsProvider(owner.username, token)))
136
+ {
137
+ assertTrue(Files.exists(authTarget.resolve("README.md")));
138
+ }
139
+ }
140
+
141
+ @Test
142
+ void revokedTokenIsRejected() throws Exception
143
+ {
144
+ User owner = persistUser();
145
+ Repository repo = createRepo(owner, "revoked-token", Repository.Visibility.PRIVATE);
146
+ seedCommit(service.repositoryPath(repo));
147
+ AccessTokenService.CreatedToken created = createTokenEntity(owner);
148
+ revokeToken(owner, created);
149
+
150
+ Path target = Files.createTempDirectory("revoked");
151
+ assertThrows(TransportException.class, () -> Git.cloneRepository()
152
+ .setURI(httpUrl(owner, "revoked-token"))
153
+ .setDirectory(target.toFile())
154
+ .setCredentialsProvider(new UsernamePasswordCredentialsProvider(owner.username, created.plaintext()))
155
+ .call());
156
+ }
157
+
158
+ private String httpUrl(User owner, String repoName)
159
+ {
160
+ return gitBase + "/" + owner.username + "/" + repoName + ".git";
161
+ }
162
+
163
+ @Transactional
164
+ String createToken(User user)
165
+ {
166
+ return tokenService.create(user, "test").plaintext();
167
+ }
168
+
169
+ @Transactional
170
+ AccessTokenService.CreatedToken createTokenEntity(User user)
171
+ {
172
+ return tokenService.create(user, "test");
173
+ }
174
+
175
+ @Transactional
176
+ void revokeToken(User user, AccessTokenService.CreatedToken created)
177
+ {
178
+ tokenService.revoke(user, created.token().id);
179
+ }
180
+
181
+ private static Git cloneOver(String url, Path target, UsernamePasswordCredentialsProvider credentials)
182
+ throws Exception
183
+ {
184
+ var cmd = Git.cloneRepository().setURI(url).setDirectory(target.toFile());
185
+ if (credentials != null)
186
+ {
187
+ cmd.setCredentialsProvider(credentials);
188
+ }
189
+ return cmd.call();
190
+ }
191
+
192
+ private static void commitFile(Git git, Path work, String fileName) throws Exception
193
+ {
194
+ Files.writeString(work.resolve(fileName), "content\n");
195
+ git.add().addFilepattern(".").call();
196
+ git.commit().setMessage("add " + fileName).setSign(false)
197
+ .setAuthor("t", "t@example.com").setCommitter("t", "t@example.com").call();
198
+ }
199
+
200
+ private static ObjectId mainRef(Path barePath) throws Exception
201
+ {
202
+ try (var repo = new FileRepositoryBuilder().setGitDir(barePath.toFile()).setMustExist(true).build())
203
+ {
204
+ return repo.resolve("refs/heads/main");
205
+ }
206
+ }
207
+
208
+ @Transactional
209
+ User persistUser()
210
+ {
211
+ String name = "http-" + UUID.randomUUID();
212
+ User user = new User();
213
+ user.oidcSub = "sub-" + name;
214
+ user.username = name;
215
+ user.persist();
216
+ return user;
217
+ }
218
+
219
+ Repository createRepo(User owner, String name, Repository.Visibility visibility)
220
+ {
221
+ return service.create(owner, name, visibility, null);
222
+ }
223
+
224
+ public static void seedCommit(Path barePath) throws Exception
225
+ {
226
+ Path work = Files.createTempDirectory("seed");
227
+ try (Git git = Git.cloneRepository().setURI(barePath.toUri().toString()).setDirectory(work.toFile()).call())
228
+ {
229
+ Files.writeString(work.resolve("README.md"), "hello git-shark\n");
230
+ git.add().addFilepattern(".").call();
231
+ git.commit().setMessage("init").setSign(false)
232
+ .setAuthor("seed", "seed@example.com").setCommitter("seed", "seed@example.com").call();
233
+ git.push().setRefSpecs(new RefSpec("HEAD:refs/heads/main")).call();
234
+ }
235
+ }
236
+
237
+}
ADD
src/test/java/de/workaround/model/EntityPersistenceTest.java
+111 -0
@@ -0,0 +1,111 @@
1
+package de.workaround.model;
2
+
3
+import io.quarkus.test.TestTransaction;
4
+import io.quarkus.test.junit.QuarkusTest;
5
+import jakarta.inject.Inject;
6
+import org.junit.jupiter.api.Test;
7
+
8
+import static org.junit.jupiter.api.Assertions.assertEquals;
9
+import static org.junit.jupiter.api.Assertions.assertNotNull;
10
+
11
+@QuarkusTest
12
+class EntityPersistenceTest
13
+{
14
+ @Inject
15
+ User.Repo users;
16
+
17
+ @Inject
18
+ Repository.Repo repositories;
19
+
20
+ @Inject
21
+ SshKey.Repo sshKeys;
22
+
23
+ @Inject
24
+ AccessToken.Repo accessTokens;
25
+
26
+ @Test
27
+ @TestTransaction
28
+ void persistsUserWithGeneratedUuid()
29
+ {
30
+ User user = newUser("alice");
31
+
32
+ user.persist();
33
+
34
+ assertNotNull(user.id);
35
+ User found = users.findByOidcSub("oidc-sub-alice");
36
+ assertEquals("alice", found.username);
37
+ assertEquals("Alice", found.displayName);
38
+ assertEquals("alice@example.com", found.email);
39
+ }
40
+
41
+ @Test
42
+ @TestTransaction
43
+ void persistsRepositoryOwnedByUser()
44
+ {
45
+ User owner = newUser("bob");
46
+ owner.persist();
47
+
48
+ Repository repo = new Repository();
49
+ repo.name = "project";
50
+ repo.owner = owner;
51
+ repo.visibility = Repository.Visibility.PUBLIC;
52
+ repo.description = "demo";
53
+ repo.persist();
54
+
55
+ assertNotNull(repo.id);
56
+ Repository found = repositories.findByOwnerAndName(owner, "project").orElseThrow();
57
+ assertEquals("project", found.name);
58
+ assertEquals(owner.id, found.owner.id);
59
+ assertEquals(Repository.Visibility.PUBLIC, found.visibility);
60
+ }
61
+
62
+ @Test
63
+ @TestTransaction
64
+ void persistsSshKeyWithFingerprint()
65
+ {
66
+ User user = newUser("carol");
67
+ user.persist();
68
+
69
+ SshKey key = new SshKey();
70
+ key.user = user;
71
+ key.title = "laptop";
72
+ key.publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPlaceholder carol@laptop";
73
+ key.fingerprint = "SHA256:fingerprint-carol";
74
+ key.persist();
75
+
76
+ assertNotNull(key.id);
77
+ SshKey found = sshKeys.findByFingerprint("SHA256:fingerprint-carol").orElseThrow();
78
+ assertEquals(user.id, found.user.id);
79
+ assertEquals("laptop", found.title);
80
+ }
81
+
82
+ @Test
83
+ @TestTransaction
84
+ void persistsAccessTokenHash()
85
+ {
86
+ User user = newUser("dave");
87
+ user.persist();
88
+
89
+ AccessToken token = new AccessToken();
90
+ token.user = user;
91
+ token.tokenHash = "hash-dave";
92
+ token.label = "ci";
93
+ token.persist();
94
+
95
+ assertNotNull(token.id);
96
+ AccessToken found = accessTokens.findByTokenHash("hash-dave").orElseThrow();
97
+ assertEquals(user.id, found.user.id);
98
+ assertEquals("ci", found.label);
99
+ }
100
+
101
+ private static User newUser(String name)
102
+ {
103
+ User user = new User();
104
+ user.oidcSub = "oidc-sub-" + name;
105
+ user.username = name;
106
+ user.displayName = name.substring(0, 1).toUpperCase() + name.substring(1);
107
+ user.email = name + "@example.com";
108
+ return user;
109
+ }
110
+
111
+}
ADD
src/test/java/de/workaround/ssh/SshGitAccessTest.java
+271 -0
@@ -0,0 +1,271 @@
1
+package de.workaround.ssh;
2
+
3
+import java.nio.file.Files;
4
+import java.nio.file.Path;
5
+import java.security.KeyPair;
6
+import java.security.KeyPairGenerator;
7
+import java.util.List;
8
+import java.util.UUID;
9
+
10
+import org.apache.sshd.common.config.keys.KeyUtils;
11
+import org.apache.sshd.common.config.keys.PublicKeyEntry;
12
+import org.apache.sshd.common.digest.BuiltinDigests;
13
+import org.apache.sshd.common.keyprovider.KeyPairProvider;
14
+import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
15
+import org.eclipse.jgit.api.Git;
16
+import org.eclipse.jgit.api.errors.TransportException;
17
+import org.eclipse.jgit.lib.ObjectId;
18
+import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
19
+import org.eclipse.jgit.transport.RefSpec;
20
+import org.eclipse.jgit.transport.SshTransport;
21
+import org.eclipse.jgit.transport.sshd.JGitKeyCache;
22
+import org.eclipse.jgit.transport.sshd.ServerKeyDatabase;
23
+import org.eclipse.jgit.transport.sshd.SshdSessionFactory;
24
+import org.eclipse.jgit.transport.sshd.SshdSessionFactoryBuilder;
25
+import org.junit.jupiter.api.AfterAll;
26
+import org.junit.jupiter.api.Test;
27
+
28
+import de.workaround.git.GitRepositoryService;
29
+import de.workaround.http.GitSmartHttpTest;
30
+import de.workaround.model.Repository;
31
+import de.workaround.model.SshKey;
32
+import de.workaround.model.User;
33
+import io.quarkus.test.junit.QuarkusTest;
34
+import jakarta.inject.Inject;
35
+import jakarta.transaction.Transactional;
36
+import org.eclipse.microprofile.config.inject.ConfigProperty;
37
+
38
+import static org.junit.jupiter.api.Assertions.assertEquals;
39
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
40
+import static org.junit.jupiter.api.Assertions.assertNotNull;
41
+import static org.junit.jupiter.api.Assertions.assertThrows;
42
+import static org.junit.jupiter.api.Assertions.assertTrue;
43
+
44
+@QuarkusTest
45
+class SshGitAccessTest
46
+{
47
+ private static SshdSessionFactory factory;
48
+
49
+ @Inject
50
+ GitRepositoryService service;
51
+
52
+ @Inject
53
+ GitSshServer sshServer;
54
+
55
+ @ConfigProperty(name = "gitshark.ssh.host-key-path")
56
+ Path hostKeyPath;
57
+
58
+ @AfterAll
59
+ static void closeFactory()
60
+ {
61
+ if (factory != null)
62
+ {
63
+ factory.close();
64
+ }
65
+ }
66
+
67
+ @Test
68
+ void clonesOverSshWithRegisteredKey() throws Exception
69
+ {
70
+ KeyPair key = generateKeyPair();
71
+ User owner = persistUserWithKey(key);
72
+ Repository repo = service.create(owner, "ssh-clone", Repository.Visibility.PUBLIC, null);
73
+ GitSmartHttpTest.seedCommit(service.repositoryPath(repo));
74
+
75
+ Path target = Files.createTempDirectory("ssh-clone");
76
+ try (Git clone = cloneOverSsh(sshUrl(owner, "ssh-clone"), target, key))
77
+ {
78
+ assertTrue(Files.exists(target.resolve("README.md")));
79
+ }
80
+ }
81
+
82
+ @Test
83
+ void rejectsUnknownKey() throws Exception
84
+ {
85
+ KeyPair registered = generateKeyPair();
86
+ User owner = persistUserWithKey(registered);
87
+ Repository repo = service.create(owner, "ssh-unknown-key", Repository.Visibility.PUBLIC, null);
88
+ GitSmartHttpTest.seedCommit(service.repositoryPath(repo));
89
+
90
+ KeyPair unregistered = generateKeyPair();
91
+ Path target = Files.createTempDirectory("ssh-unknown");
92
+ assertThrows(TransportException.class,
93
+ () -> cloneOverSsh(sshUrl(owner, "ssh-unknown-key"), target, unregistered));
94
+ }
95
+
96
+ @Test
97
+ void pushesOverSshAsOwner() throws Exception
98
+ {
99
+ KeyPair key = generateKeyPair();
100
+ User owner = persistUserWithKey(key);
101
+ Repository repo = service.create(owner, "ssh-push", Repository.Visibility.PUBLIC, null);
102
+ GitSmartHttpTest.seedCommit(service.repositoryPath(repo));
103
+ ObjectId before = mainRef(service.repositoryPath(repo));
104
+
105
+ Path work = Files.createTempDirectory("ssh-push");
106
+ try (Git git = cloneOverSsh(sshUrl(owner, "ssh-push"), work, key))
107
+ {
108
+ Files.writeString(work.resolve("new.txt"), "via ssh\n");
109
+ git.add().addFilepattern(".").call();
110
+ git.commit().setMessage("ssh push").setSign(false)
111
+ .setAuthor("t", "t@example.com").setCommitter("t", "t@example.com").call();
112
+ git.push()
113
+ .setTransportConfigCallback(SshGitAccessTest::configureTransport)
114
+ .setRefSpecs(new RefSpec("HEAD:refs/heads/main"))
115
+ .call();
116
+ }
117
+
118
+ assertNotEquals(before, mainRef(service.repositoryPath(repo)), "push must advance main");
119
+ }
120
+
121
+ @Test
122
+ void deniesPrivateRepositoryReadForNonOwner() throws Exception
123
+ {
124
+ KeyPair ownerKey = generateKeyPair();
125
+ User owner = persistUserWithKey(ownerKey);
126
+ KeyPair strangerKey = generateKeyPair();
127
+ persistUserWithKey(strangerKey);
128
+ Repository repo = service.create(owner, "ssh-private", Repository.Visibility.PRIVATE, null);
129
+ GitSmartHttpTest.seedCommit(service.repositoryPath(repo));
130
+
131
+ Path target = Files.createTempDirectory("ssh-private");
132
+ // JGit wraps the server-side denial in InvalidRemoteException (repo "not found")
133
+ assertThrows(org.eclipse.jgit.api.errors.GitAPIException.class,
134
+ () -> cloneOverSsh(sshUrl(owner, "ssh-private"), target, strangerKey));
135
+ }
136
+
137
+ @Test
138
+ void deniesReceivePackWithoutWritePermission() throws Exception
139
+ {
140
+ KeyPair ownerKey = generateKeyPair();
141
+ User owner = persistUserWithKey(ownerKey);
142
+ KeyPair strangerKey = generateKeyPair();
143
+ persistUserWithKey(strangerKey);
144
+ Repository repo = service.create(owner, "ssh-guarded", Repository.Visibility.PUBLIC, null);
145
+ GitSmartHttpTest.seedCommit(service.repositoryPath(repo));
146
+ ObjectId before = mainRef(service.repositoryPath(repo));
147
+
148
+ Path work = Files.createTempDirectory("ssh-guarded");
149
+ try (Git git = cloneOverSsh(sshUrl(owner, "ssh-guarded"), work, strangerKey))
150
+ {
151
+ Files.writeString(work.resolve("intrusion.txt"), "nope\n");
152
+ git.add().addFilepattern(".").call();
153
+ git.commit().setMessage("intrusion").setSign(false)
154
+ .setAuthor("t", "t@example.com").setCommitter("t", "t@example.com").call();
155
+ assertThrows(TransportException.class, () -> git.push()
156
+ .setTransportConfigCallback(SshGitAccessTest::configureTransport)
157
+ .setRefSpecs(new RefSpec("HEAD:refs/heads/main"))
158
+ .call());
159
+ }
160
+
161
+ assertEquals(before, mainRef(service.repositoryPath(repo)), "refs must not change on denied push");
162
+ }
163
+
164
+ @Test
165
+ void hostKeyIsPersistedAndStable() throws Exception
166
+ {
167
+ assertTrue(Files.exists(hostKeyPath), "host key file must exist after server start");
168
+
169
+ SimpleGeneratorHostKeyProvider reloaded = new SimpleGeneratorHostKeyProvider(hostKeyPath);
170
+ List<KeyPair> first = (List<KeyPair>) loadKeys(reloaded);
171
+ SimpleGeneratorHostKeyProvider again = new SimpleGeneratorHostKeyProvider(hostKeyPath);
172
+ List<KeyPair> second = (List<KeyPair>) loadKeys(again);
173
+
174
+ assertEquals(KeyUtils.getFingerPrint(BuiltinDigests.sha256, first.get(0).getPublic()),
175
+ KeyUtils.getFingerPrint(BuiltinDigests.sha256, second.get(0).getPublic()),
176
+ "host key fingerprint must be stable across provider reloads");
177
+ }
178
+
179
+ private static Iterable<KeyPair> loadKeys(KeyPairProvider provider) throws Exception
180
+ {
181
+ java.util.ArrayList<KeyPair> keys = new java.util.ArrayList<>();
182
+ provider.loadKeys(null).forEach(keys::add);
183
+ return keys;
184
+ }
185
+
186
+ private String sshUrl(User owner, String repoName)
187
+ {
188
+ return "ssh://git@localhost:" + sshServer.actualPort() + "/" + owner.username + "/" + repoName + ".git";
189
+ }
190
+
191
+ private static Git cloneOverSsh(String url, Path target, KeyPair key) throws Exception
192
+ {
193
+ installFactory(key);
194
+ return Git.cloneRepository()
195
+ .setURI(url)
196
+ .setDirectory(target.toFile())
197
+ .setTransportConfigCallback(SshGitAccessTest::configureTransport)
198
+ .call();
199
+ }
200
+
201
+ private static void configureTransport(org.eclipse.jgit.transport.Transport transport)
202
+ {
203
+ ((SshTransport) transport).setSshSessionFactory(factory);
204
+ }
205
+
206
+ private static synchronized void installFactory(KeyPair key) throws Exception
207
+ {
208
+ if (factory != null)
209
+ {
210
+ factory.close();
211
+ }
212
+ Path home = Files.createTempDirectory("ssh-home");
213
+ Files.createDirectories(home.resolve(".ssh"));
214
+ factory = new SshdSessionFactoryBuilder()
215
+ .setPreferredAuthentications("publickey")
216
+ .setHomeDirectory(home.toFile())
217
+ .setSshDirectory(home.resolve(".ssh").toFile())
218
+ .setDefaultKeysProvider(dir -> List.of(key))
219
+ .setServerKeyDatabase((ignoredHome, ignoredSsh) -> new ServerKeyDatabase()
220
+ {
221
+ @Override
222
+ public List<java.security.PublicKey> lookup(String connectAddress,
223
+ java.net.InetSocketAddress remoteAddress, Configuration config)
224
+ {
225
+ return List.of();
226
+ }
227
+
228
+ @Override
229
+ public boolean accept(String connectAddress, java.net.InetSocketAddress remoteAddress,
230
+ java.security.PublicKey serverKey, Configuration config, org.eclipse.jgit.transport.CredentialsProvider provider)
231
+ {
232
+ return true;
233
+ }
234
+ })
235
+ .build(new JGitKeyCache());
236
+ }
237
+
238
+ private static KeyPair generateKeyPair() throws Exception
239
+ {
240
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
241
+ generator.initialize(2048);
242
+ return generator.generateKeyPair();
243
+ }
244
+
245
+ @Transactional
246
+ User persistUserWithKey(KeyPair key) throws Exception
247
+ {
248
+ String name = "ssh-" + UUID.randomUUID();
249
+ User user = new User();
250
+ user.oidcSub = "sub-" + name;
251
+ user.username = name;
252
+ user.persist();
253
+
254
+ SshKey sshKey = new SshKey();
255
+ sshKey.user = user;
256
+ sshKey.title = "test-key";
257
+ sshKey.publicKey = PublicKeyEntry.toString(key.getPublic());
258
+ sshKey.fingerprint = KeyUtils.getFingerPrint(BuiltinDigests.sha256, key.getPublic());
259
+ sshKey.persist();
260
+ return user;
261
+ }
262
+
263
+ private static ObjectId mainRef(Path barePath) throws Exception
264
+ {
265
+ try (var repo = new FileRepositoryBuilder().setGitDir(barePath.toFile()).setMustExist(true).build())
266
+ {
267
+ return repo.resolve("refs/heads/main");
268
+ }
269
+ }
270
+
271
+}
ADD
src/test/java/de/workaround/ssh/TestKeys.java
+27 -0
@@ -0,0 +1,27 @@
1
+package de.workaround.ssh;
2
+
3
+import java.security.KeyPairGenerator;
4
+
5
+import org.apache.sshd.common.config.keys.PublicKeyEntry;
6
+
7
+public final class TestKeys
8
+{
9
+ private TestKeys()
10
+ {
11
+ }
12
+
13
+ public static String validOpenSshKey()
14
+ {
15
+ try
16
+ {
17
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
18
+ generator.initialize(2048);
19
+ return PublicKeyEntry.toString(generator.generateKeyPair().getPublic()) + " test@example.com";
20
+ }
21
+ catch (Exception e)
22
+ {
23
+ throw new IllegalStateException(e);
24
+ }
25
+ }
26
+
27
+}
ADD
src/test/java/de/workaround/web/WebUiTest.java
+191 -0
@@ -0,0 +1,191 @@
1
+package de.workaround.web;
2
+
3
+import java.nio.charset.StandardCharsets;
4
+import java.util.Map;
5
+import java.util.UUID;
6
+
7
+import org.junit.jupiter.api.Test;
8
+
9
+import de.workaround.git.GitRepositoryService;
10
+import de.workaround.git.GitTestSeeder;
11
+import de.workaround.model.Repository;
12
+import de.workaround.model.User;
13
+import io.quarkus.test.junit.QuarkusTest;
14
+import io.quarkus.test.security.TestSecurity;
15
+import jakarta.inject.Inject;
16
+import jakarta.transaction.Transactional;
17
+
18
+import static io.restassured.RestAssured.given;
19
+import static org.hamcrest.CoreMatchers.containsString;
20
+import static org.hamcrest.CoreMatchers.not;
21
+
22
+@QuarkusTest
23
+class WebUiTest
24
+{
25
+ @Inject
26
+ GitRepositoryService service;
27
+
28
+ @Test
29
+ void anonymousSeesOnlyPublicRepositoriesOnHome() throws Exception
30
+ {
31
+ User owner = persistUser("ui-bob-" + unique());
32
+ service.create(owner, "ui-pub", Repository.Visibility.PUBLIC, "public demo");
33
+ service.create(owner, "ui-priv", Repository.Visibility.PRIVATE, null);
34
+
35
+ given()
36
+ .when().get("/")
37
+ .then()
38
+ .statusCode(200)
39
+ .body(containsString("ui-pub"))
40
+ .body(not(containsString("ui-priv")));
41
+ }
42
+
43
+ @Test
44
+ @TestSecurity(user = "ui-alice")
45
+ void ownerSeesOwnPrivateRepositoryOnHome() throws Exception
46
+ {
47
+ User alice = persistUser("ui-alice");
48
+ service.create(alice, "alice-secret-" + unique(), Repository.Visibility.PRIVATE, null);
49
+
50
+ String repoName = service.listVisibleTo(alice).stream()
51
+ .filter(r -> r.name.startsWith("alice-secret-"))
52
+ .findFirst().orElseThrow().name;
53
+
54
+ given()
55
+ .when().get("/")
56
+ .then()
57
+ .statusCode(200)
58
+ .body(containsString(repoName));
59
+ }
60
+
61
+ @Test
62
+ void fileBrowserListsDirectoriesAndEscapesTextContent() throws Exception
63
+ {
64
+ User owner = persistUser("ui-carol-" + unique());
65
+ Repository repo = service.create(owner, "browse", Repository.Visibility.PUBLIC, null);
66
+ GitTestSeeder.seed(service.repositoryPath(repo), Map.of(
67
+ "README.md", "<b>bold</b> readme\n".getBytes(StandardCharsets.UTF_8),
68
+ "docs/guide.txt", "guide\n".getBytes(StandardCharsets.UTF_8),
69
+ "logo.bin", new byte[] { 0, 1, 2, 3, 0, -1 }));
70
+
71
+ String base = "/repos/" + owner.username + "/browse";
72
+
73
+ given().when().get(base + "/tree/main")
74
+ .then().statusCode(200)
75
+ .body(containsString("README.md"))
76
+ .body(containsString("docs"));
77
+
78
+ given().when().get(base + "/tree/main/README.md")
79
+ .then().statusCode(200)
80
+ .body(containsString("<b>bold</b>"))
81
+ .body(not(containsString("<b>bold</b>")));
82
+
83
+ given().when().get(base + "/tree/main/logo.bin")
84
+ .then().statusCode(200)
85
+ .body(containsString("/raw/main/logo.bin"));
86
+
87
+ byte[] raw = given().when().get(base + "/raw/main/logo.bin")
88
+ .then().statusCode(200)
89
+ .extract().body().asByteArray();
90
+ org.junit.jupiter.api.Assertions.assertArrayEquals(new byte[] { 0, 1, 2, 3, 0, -1 }, raw);
91
+ }
92
+
93
+ @Test
94
+ void emptyRepositoryShowsSetupInstructions() throws Exception
95
+ {
96
+ User owner = persistUser("ui-dora-" + unique());
97
+ service.create(owner, "empty", Repository.Visibility.PUBLIC, null);
98
+
99
+ given().when().get("/repos/" + owner.username + "/empty")
100
+ .then().statusCode(200)
101
+ .body(containsString("/git/" + owner.username + "/empty.git"))
102
+ .body(containsString("git push"));
103
+ }
104
+
105
+ @Test
106
+ void commitLogIsPaginated() throws Exception
107
+ {
108
+ User owner = persistUser("ui-eric-" + unique());
109
+ Repository repo = service.create(owner, "logrepo", Repository.Visibility.PUBLIC, null);
110
+ GitTestSeeder.seed(service.repositoryPath(repo),
111
+ Map.of("a.txt", "a\n".getBytes(StandardCharsets.UTF_8)), 3);
112
+
113
+ String base = "/repos/" + owner.username + "/logrepo/commits/main";
114
+
115
+ given().when().get(base + "?size=2")
116
+ .then().statusCode(200)
117
+ .body(containsString("commit 3"))
118
+ .body(containsString("commit 2"))
119
+ .body(not(containsString("commit 1")))
120
+ .body(containsString("page=1"));
121
+
122
+ given().when().get(base + "?size=2&page=1")
123
+ .then().statusCode(200)
124
+ .body(containsString("commit 1"))
125
+ .body(not(containsString("commit 3")));
126
+ }
127
+
128
+ @Test
129
+ void branchesPageMarksDefaultBranch() throws Exception
130
+ {
131
+ User owner = persistUser("ui-fred-" + unique());
132
+ Repository repo = service.create(owner, "branched", Repository.Visibility.PUBLIC, null);
133
+ GitTestSeeder.seed(service.repositoryPath(repo),
134
+ Map.of("a.txt", "a\n".getBytes(StandardCharsets.UTF_8)));
135
+
136
+ given().when().get("/repos/" + owner.username + "/branched/branches")
137
+ .then().statusCode(200)
138
+ .body(containsString("main"))
139
+ .body(containsString("default"));
140
+ }
141
+
142
+ @Test
143
+ void repositoryPageShowsCloneUrls() throws Exception
144
+ {
145
+ User owner = persistUser("ui-gina-" + unique());
146
+ Repository repo = service.create(owner, "cloneurls", Repository.Visibility.PUBLIC, null);
147
+ GitTestSeeder.seed(service.repositoryPath(repo),
148
+ Map.of("a.txt", "a\n".getBytes(StandardCharsets.UTF_8)));
149
+
150
+ given().when().get("/repos/" + owner.username + "/cloneurls")
151
+ .then().statusCode(200)
152
+ .body(containsString("/git/" + owner.username + "/cloneurls.git"))
153
+ .body(containsString("ssh://git@"));
154
+ }
155
+
156
+ @Test
157
+ @TestSecurity(user = "ui-mallory")
158
+ void privateRepositoryHiddenFromStranger() throws Exception
159
+ {
160
+ persistUser("ui-mallory");
161
+ User owner = persistUser("ui-henry-" + unique());
162
+ service.create(owner, "hidden", Repository.Visibility.PRIVATE, null);
163
+
164
+ given().when().get("/repos/" + owner.username + "/hidden")
165
+ .then().statusCode(404);
166
+ }
167
+
168
+ private static String unique()
169
+ {
170
+ return UUID.randomUUID().toString().substring(0, 8);
171
+ }
172
+
173
+ @Transactional
174
+ User persistUser(String name)
175
+ {
176
+ User existing = userRepo.findByOidcSubOptional(name).orElse(null);
177
+ if (existing != null)
178
+ {
179
+ return existing;
180
+ }
181
+ User user = new User();
182
+ user.oidcSub = name;
183
+ user.username = name;
184
+ user.persist();
185
+ return user;
186
+ }
187
+
188
+ @Inject
189
+ User.Repo userRepo;
190
+
191
+}