feat: add project config files and restic sub-module extraction
This commit is contained in:
83
.claude/skills/gitnexus/gitnexus-cli/SKILL.md
Normal file
83
.claude/skills/gitnexus/gitnexus-cli/SKILL.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
---
|
||||||
|
name: gitnexus-cli
|
||||||
|
description: "Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: \"Index this repo\", \"Reanalyze the codebase\", \"Generate a wiki\""
|
||||||
|
---
|
||||||
|
|
||||||
|
# GitNexus CLI Commands
|
||||||
|
|
||||||
|
All commands work via `npx` — no global install required.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### analyze — Build or refresh the index
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx gitnexus analyze
|
||||||
|
```
|
||||||
|
|
||||||
|
Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files.
|
||||||
|
|
||||||
|
| Flag | Effect |
|
||||||
|
| -------------- | ---------------------------------------------------------------- |
|
||||||
|
| `--force` | Force full re-index even if up to date |
|
||||||
|
| `--embeddings` | Enable embedding generation for semantic search (off by default) |
|
||||||
|
| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. |
|
||||||
|
|
||||||
|
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook detects staleness after `git commit` and `git merge` and notifies the agent to run `analyze` — the hook does not run analyze itself, to avoid blocking the agent for up to 120s and risking KuzuDB corruption on timeout.
|
||||||
|
|
||||||
|
### status — Check index freshness
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx gitnexus status
|
||||||
|
```
|
||||||
|
|
||||||
|
Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed.
|
||||||
|
|
||||||
|
### clean — Delete the index
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx gitnexus clean
|
||||||
|
```
|
||||||
|
|
||||||
|
Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project.
|
||||||
|
|
||||||
|
| Flag | Effect |
|
||||||
|
| --------- | ------------------------------------------------- |
|
||||||
|
| `--force` | Skip confirmation prompt |
|
||||||
|
| `--all` | Clean all indexed repos, not just the current one |
|
||||||
|
|
||||||
|
### wiki — Generate documentation from the graph
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx gitnexus wiki
|
||||||
|
```
|
||||||
|
|
||||||
|
Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use).
|
||||||
|
|
||||||
|
| Flag | Effect |
|
||||||
|
| ------------------- | ----------------------------------------- |
|
||||||
|
| `--force` | Force full regeneration |
|
||||||
|
| `--model <model>` | LLM model (default: minimax/minimax-m2.5) |
|
||||||
|
| `--base-url <url>` | LLM API base URL |
|
||||||
|
| `--api-key <key>` | LLM API key |
|
||||||
|
| `--concurrency <n>` | Parallel LLM calls (default: 3) |
|
||||||
|
| `--gist` | Publish wiki as a public GitHub Gist |
|
||||||
|
|
||||||
|
### list — Show all indexed repos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx gitnexus list
|
||||||
|
```
|
||||||
|
|
||||||
|
Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information.
|
||||||
|
|
||||||
|
## After Indexing
|
||||||
|
|
||||||
|
1. **Read `gitnexus://repo/{name}/context`** to verify the index loaded
|
||||||
|
2. Use the other GitNexus skills (`exploring`, `debugging`, `impact-analysis`, `refactoring`) for your task
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **"Not inside a git repository"**: Run from a directory inside a git repo
|
||||||
|
- **Index is stale after re-analyzing**: Restart Claude Code to reload the MCP server
|
||||||
|
- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding
|
||||||
89
.claude/skills/gitnexus/gitnexus-debugging/SKILL.md
Normal file
89
.claude/skills/gitnexus/gitnexus-debugging/SKILL.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
---
|
||||||
|
name: gitnexus-debugging
|
||||||
|
description: "Use when the user is debugging a bug, tracing an error, or asking why something fails. Examples: \"Why is X failing?\", \"Where does this error come from?\", \"Trace this bug\""
|
||||||
|
---
|
||||||
|
|
||||||
|
# Debugging with GitNexus
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- "Why is this function failing?"
|
||||||
|
- "Trace where this error comes from"
|
||||||
|
- "Who calls this method?"
|
||||||
|
- "This endpoint returns 500"
|
||||||
|
- Investigating bugs, errors, or unexpected behavior
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. gitnexus_query({query: "<error or symptom>"}) → Find related execution flows
|
||||||
|
2. gitnexus_context({name: "<suspect>"}) → See callers/callees/processes
|
||||||
|
3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow
|
||||||
|
4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed
|
||||||
|
```
|
||||||
|
|
||||||
|
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
```
|
||||||
|
- [ ] Understand the symptom (error message, unexpected behavior)
|
||||||
|
- [ ] gitnexus_query for error text or related code
|
||||||
|
- [ ] Identify the suspect function from returned processes
|
||||||
|
- [ ] gitnexus_context to see callers and callees
|
||||||
|
- [ ] Trace execution flow via process resource if applicable
|
||||||
|
- [ ] gitnexus_cypher for custom call chain traces if needed
|
||||||
|
- [ ] Read source files to confirm root cause
|
||||||
|
```
|
||||||
|
|
||||||
|
## Debugging Patterns
|
||||||
|
|
||||||
|
| Symptom | GitNexus Approach |
|
||||||
|
| -------------------- | ---------------------------------------------------------- |
|
||||||
|
| Error message | `gitnexus_query` for error text → `context` on throw sites |
|
||||||
|
| Wrong return value | `context` on the function → trace callees for data flow |
|
||||||
|
| Intermittent failure | `context` → look for external calls, async deps |
|
||||||
|
| Performance issue | `context` → find symbols with many callers (hot paths) |
|
||||||
|
| Recent regression | `detect_changes` to see what your changes affect |
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
**gitnexus_query** — find code related to error:
|
||||||
|
|
||||||
|
```
|
||||||
|
gitnexus_query({query: "payment validation error"})
|
||||||
|
→ Processes: CheckoutFlow, ErrorHandling
|
||||||
|
→ Symbols: validatePayment, handlePaymentError, PaymentException
|
||||||
|
```
|
||||||
|
|
||||||
|
**gitnexus_context** — full context for a suspect:
|
||||||
|
|
||||||
|
```
|
||||||
|
gitnexus_context({name: "validatePayment"})
|
||||||
|
→ Incoming calls: processCheckout, webhookHandler
|
||||||
|
→ Outgoing calls: verifyCard, fetchRates (external API!)
|
||||||
|
→ Processes: CheckoutFlow (step 3/7)
|
||||||
|
```
|
||||||
|
|
||||||
|
**gitnexus_cypher** — custom call chain traces:
|
||||||
|
|
||||||
|
```cypher
|
||||||
|
MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"})
|
||||||
|
RETURN [n IN nodes(path) | n.name] AS chain
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example: "Payment endpoint returns 500 intermittently"
|
||||||
|
|
||||||
|
```
|
||||||
|
1. gitnexus_query({query: "payment error handling"})
|
||||||
|
→ Processes: CheckoutFlow, ErrorHandling
|
||||||
|
→ Symbols: validatePayment, handlePaymentError
|
||||||
|
|
||||||
|
2. gitnexus_context({name: "validatePayment"})
|
||||||
|
→ Outgoing calls: verifyCard, fetchRates (external API!)
|
||||||
|
|
||||||
|
3. READ gitnexus://repo/my-app/process/CheckoutFlow
|
||||||
|
→ Step 3: validatePayment → calls fetchRates (external)
|
||||||
|
|
||||||
|
4. Root cause: fetchRates calls external API without proper timeout
|
||||||
|
```
|
||||||
78
.claude/skills/gitnexus/gitnexus-exploring/SKILL.md
Normal file
78
.claude/skills/gitnexus/gitnexus-exploring/SKILL.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
name: gitnexus-exploring
|
||||||
|
description: "Use when the user asks how code works, wants to understand architecture, trace execution flows, or explore unfamiliar parts of the codebase. Examples: \"How does X work?\", \"What calls this function?\", \"Show me the auth flow\""
|
||||||
|
---
|
||||||
|
|
||||||
|
# Exploring Codebases with GitNexus
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- "How does authentication work?"
|
||||||
|
- "What's the project structure?"
|
||||||
|
- "Show me the main components"
|
||||||
|
- "Where is the database logic?"
|
||||||
|
- Understanding code you haven't seen before
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. READ gitnexus://repos → Discover indexed repos
|
||||||
|
2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness
|
||||||
|
3. gitnexus_query({query: "<what you want to understand>"}) → Find related execution flows
|
||||||
|
4. gitnexus_context({name: "<symbol>"}) → Deep dive on specific symbol
|
||||||
|
5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
|
||||||
|
```
|
||||||
|
|
||||||
|
> If step 2 says "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
```
|
||||||
|
- [ ] READ gitnexus://repo/{name}/context
|
||||||
|
- [ ] gitnexus_query for the concept you want to understand
|
||||||
|
- [ ] Review returned processes (execution flows)
|
||||||
|
- [ ] gitnexus_context on key symbols for callers/callees
|
||||||
|
- [ ] READ process resource for full execution traces
|
||||||
|
- [ ] Read source files for implementation details
|
||||||
|
```
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
| Resource | What you get |
|
||||||
|
| --------------------------------------- | ------------------------------------------------------- |
|
||||||
|
| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) |
|
||||||
|
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) |
|
||||||
|
| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) |
|
||||||
|
| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) |
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
**gitnexus_query** — find execution flows related to a concept:
|
||||||
|
|
||||||
|
```
|
||||||
|
gitnexus_query({query: "payment processing"})
|
||||||
|
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
|
||||||
|
→ Symbols grouped by flow with file locations
|
||||||
|
```
|
||||||
|
|
||||||
|
**gitnexus_context** — 360-degree view of a symbol:
|
||||||
|
|
||||||
|
```
|
||||||
|
gitnexus_context({name: "validateUser"})
|
||||||
|
→ Incoming calls: loginHandler, apiMiddleware
|
||||||
|
→ Outgoing calls: checkToken, getUserById
|
||||||
|
→ Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example: "How does payment processing work?"
|
||||||
|
|
||||||
|
```
|
||||||
|
1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes
|
||||||
|
2. gitnexus_query({query: "payment processing"})
|
||||||
|
→ CheckoutFlow: processPayment → validateCard → chargeStripe
|
||||||
|
→ RefundFlow: initiateRefund → calculateRefund → processRefund
|
||||||
|
3. gitnexus_context({name: "processPayment"})
|
||||||
|
→ Incoming: checkoutHandler, webhookHandler
|
||||||
|
→ Outgoing: validateCard, chargeStripe, saveTransaction
|
||||||
|
4. Read src/payments/processor.ts for implementation details
|
||||||
|
```
|
||||||
64
.claude/skills/gitnexus/gitnexus-guide/SKILL.md
Normal file
64
.claude/skills/gitnexus/gitnexus-guide/SKILL.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
name: gitnexus-guide
|
||||||
|
description: "Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: \"What GitNexus tools are available?\", \"How do I use GitNexus?\""
|
||||||
|
---
|
||||||
|
|
||||||
|
# GitNexus Guide
|
||||||
|
|
||||||
|
Quick reference for all GitNexus MCP tools, resources, and the knowledge graph schema.
|
||||||
|
|
||||||
|
## Always Start Here
|
||||||
|
|
||||||
|
For any task involving code understanding, debugging, impact analysis, or refactoring:
|
||||||
|
|
||||||
|
1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness
|
||||||
|
2. **Match your task to a skill below** and **read that skill file**
|
||||||
|
3. **Follow the skill's workflow and checklist**
|
||||||
|
|
||||||
|
> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first.
|
||||||
|
|
||||||
|
## Skills
|
||||||
|
|
||||||
|
| Task | Skill to read |
|
||||||
|
| -------------------------------------------- | ------------------- |
|
||||||
|
| Understand architecture / "How does X work?" | `gitnexus-exploring` |
|
||||||
|
| Blast radius / "What breaks if I change X?" | `gitnexus-impact-analysis` |
|
||||||
|
| Trace bugs / "Why is X failing?" | `gitnexus-debugging` |
|
||||||
|
| Rename / extract / split / refactor | `gitnexus-refactoring` |
|
||||||
|
| Tools, resources, schema reference | `gitnexus-guide` (this file) |
|
||||||
|
| Index, status, clean, wiki CLI commands | `gitnexus-cli` |
|
||||||
|
|
||||||
|
## Tools Reference
|
||||||
|
|
||||||
|
| Tool | What it gives you |
|
||||||
|
| ---------------- | ------------------------------------------------------------------------ |
|
||||||
|
| `query` | Process-grouped code intelligence — execution flows related to a concept |
|
||||||
|
| `context` | 360-degree symbol view — categorized refs, processes it participates in |
|
||||||
|
| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence |
|
||||||
|
| `detect_changes` | Git-diff impact — what do your current changes affect |
|
||||||
|
| `rename` | Multi-file coordinated rename with confidence-tagged edits |
|
||||||
|
| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
|
||||||
|
| `list_repos` | Discover indexed repos |
|
||||||
|
|
||||||
|
## Resources Reference
|
||||||
|
|
||||||
|
Lightweight reads (~100-500 tokens) for navigation:
|
||||||
|
|
||||||
|
| Resource | Content |
|
||||||
|
| ---------------------------------------------- | ----------------------------------------- |
|
||||||
|
| `gitnexus://repo/{name}/context` | Stats, staleness check |
|
||||||
|
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores |
|
||||||
|
| `gitnexus://repo/{name}/cluster/{clusterName}` | Area members |
|
||||||
|
| `gitnexus://repo/{name}/processes` | All execution flows |
|
||||||
|
| `gitnexus://repo/{name}/process/{processName}` | Step-by-step trace |
|
||||||
|
| `gitnexus://repo/{name}/schema` | Graph schema for Cypher |
|
||||||
|
|
||||||
|
## Graph Schema
|
||||||
|
|
||||||
|
**Nodes:** File, Function, Class, Interface, Method, Community, Process
|
||||||
|
**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS
|
||||||
|
|
||||||
|
```cypher
|
||||||
|
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"})
|
||||||
|
RETURN caller.name, caller.filePath
|
||||||
|
```
|
||||||
97
.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md
Normal file
97
.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
---
|
||||||
|
name: gitnexus-impact-analysis
|
||||||
|
description: "Use when the user wants to know what will break if they change something, or needs safety analysis before editing code. Examples: \"Is it safe to change X?\", \"What depends on this?\", \"What will break?\""
|
||||||
|
---
|
||||||
|
|
||||||
|
# Impact Analysis with GitNexus
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- "Is it safe to change this function?"
|
||||||
|
- "What will break if I modify X?"
|
||||||
|
- "Show me the blast radius"
|
||||||
|
- "Who uses this code?"
|
||||||
|
- Before making non-trivial code changes
|
||||||
|
- Before committing — to understand what your changes affect
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. gitnexus_impact({target: "X", direction: "upstream"}) → What depends on this
|
||||||
|
2. READ gitnexus://repo/{name}/processes → Check affected execution flows
|
||||||
|
3. gitnexus_detect_changes() → Map current git changes to affected flows
|
||||||
|
4. Assess risk and report to user
|
||||||
|
```
|
||||||
|
|
||||||
|
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
```
|
||||||
|
- [ ] gitnexus_impact({target, direction: "upstream"}) to find dependents
|
||||||
|
- [ ] Review d=1 items first (these WILL BREAK)
|
||||||
|
- [ ] Check high-confidence (>0.8) dependencies
|
||||||
|
- [ ] READ processes to check affected execution flows
|
||||||
|
- [ ] gitnexus_detect_changes() for pre-commit check
|
||||||
|
- [ ] Assess risk level and report to user
|
||||||
|
```
|
||||||
|
|
||||||
|
## Understanding Output
|
||||||
|
|
||||||
|
| Depth | Risk Level | Meaning |
|
||||||
|
| ----- | ---------------- | ------------------------ |
|
||||||
|
| d=1 | **WILL BREAK** | Direct callers/importers |
|
||||||
|
| d=2 | LIKELY AFFECTED | Indirect dependencies |
|
||||||
|
| d=3 | MAY NEED TESTING | Transitive effects |
|
||||||
|
|
||||||
|
## Risk Assessment
|
||||||
|
|
||||||
|
| Affected | Risk |
|
||||||
|
| ------------------------------ | -------- |
|
||||||
|
| <5 symbols, few processes | LOW |
|
||||||
|
| 5-15 symbols, 2-5 processes | MEDIUM |
|
||||||
|
| >15 symbols or many processes | HIGH |
|
||||||
|
| Critical path (auth, payments) | CRITICAL |
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
**gitnexus_impact** — the primary tool for symbol blast radius:
|
||||||
|
|
||||||
|
```
|
||||||
|
gitnexus_impact({
|
||||||
|
target: "validateUser",
|
||||||
|
direction: "upstream",
|
||||||
|
minConfidence: 0.8,
|
||||||
|
maxDepth: 3
|
||||||
|
})
|
||||||
|
|
||||||
|
→ d=1 (WILL BREAK):
|
||||||
|
- loginHandler (src/auth/login.ts:42) [CALLS, 100%]
|
||||||
|
- apiMiddleware (src/api/middleware.ts:15) [CALLS, 100%]
|
||||||
|
|
||||||
|
→ d=2 (LIKELY AFFECTED):
|
||||||
|
- authRouter (src/routes/auth.ts:22) [CALLS, 95%]
|
||||||
|
```
|
||||||
|
|
||||||
|
**gitnexus_detect_changes** — git-diff based impact analysis:
|
||||||
|
|
||||||
|
```
|
||||||
|
gitnexus_detect_changes({scope: "staged"})
|
||||||
|
|
||||||
|
→ Changed: 5 symbols in 3 files
|
||||||
|
→ Affected: LoginFlow, TokenRefresh, APIMiddlewarePipeline
|
||||||
|
→ Risk: MEDIUM
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example: "What breaks if I change validateUser?"
|
||||||
|
|
||||||
|
```
|
||||||
|
1. gitnexus_impact({target: "validateUser", direction: "upstream"})
|
||||||
|
→ d=1: loginHandler, apiMiddleware (WILL BREAK)
|
||||||
|
→ d=2: authRouter, sessionManager (LIKELY AFFECTED)
|
||||||
|
|
||||||
|
2. READ gitnexus://repo/my-app/processes
|
||||||
|
→ LoginFlow and TokenRefresh touch validateUser
|
||||||
|
|
||||||
|
3. Risk: 2 direct callers, 2 processes = MEDIUM
|
||||||
|
```
|
||||||
121
.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md
Normal file
121
.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
---
|
||||||
|
name: gitnexus-refactoring
|
||||||
|
description: "Use when the user wants to rename, extract, split, move, or restructure code safely. Examples: \"Rename this function\", \"Extract this into a module\", \"Refactor this class\", \"Move this to a separate file\""
|
||||||
|
---
|
||||||
|
|
||||||
|
# Refactoring with GitNexus
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- "Rename this function safely"
|
||||||
|
- "Extract this into a module"
|
||||||
|
- "Split this service"
|
||||||
|
- "Move this to a new file"
|
||||||
|
- Any task involving renaming, extracting, splitting, or restructuring code
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. gitnexus_impact({target: "X", direction: "upstream"}) → Map all dependents
|
||||||
|
2. gitnexus_query({query: "X"}) → Find execution flows involving X
|
||||||
|
3. gitnexus_context({name: "X"}) → See all incoming/outgoing refs
|
||||||
|
4. Plan update order: interfaces → implementations → callers → tests
|
||||||
|
```
|
||||||
|
|
||||||
|
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||||
|
|
||||||
|
## Checklists
|
||||||
|
|
||||||
|
### Rename Symbol
|
||||||
|
|
||||||
|
```
|
||||||
|
- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
|
||||||
|
- [ ] Review graph edits (high confidence) and ast_search edits (review carefully)
|
||||||
|
- [ ] If satisfied: gitnexus_rename({..., dry_run: false}) — apply edits
|
||||||
|
- [ ] gitnexus_detect_changes() — verify only expected files changed
|
||||||
|
- [ ] Run tests for affected processes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Extract Module
|
||||||
|
|
||||||
|
```
|
||||||
|
- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs
|
||||||
|
- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers
|
||||||
|
- [ ] Define new module interface
|
||||||
|
- [ ] Extract code, update imports
|
||||||
|
- [ ] gitnexus_detect_changes() — verify affected scope
|
||||||
|
- [ ] Run tests for affected processes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Split Function/Service
|
||||||
|
|
||||||
|
```
|
||||||
|
- [ ] gitnexus_context({name: target}) — understand all callees
|
||||||
|
- [ ] Group callees by responsibility
|
||||||
|
- [ ] gitnexus_impact({target, direction: "upstream"}) — map callers to update
|
||||||
|
- [ ] Create new functions/services
|
||||||
|
- [ ] Update callers
|
||||||
|
- [ ] gitnexus_detect_changes() — verify affected scope
|
||||||
|
- [ ] Run tests for affected processes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
**gitnexus_rename** — automated multi-file rename:
|
||||||
|
|
||||||
|
```
|
||||||
|
gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||||
|
→ 12 edits across 8 files
|
||||||
|
→ 10 graph edits (high confidence), 2 ast_search edits (review)
|
||||||
|
→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}]
|
||||||
|
```
|
||||||
|
|
||||||
|
**gitnexus_impact** — map all dependents first:
|
||||||
|
|
||||||
|
```
|
||||||
|
gitnexus_impact({target: "validateUser", direction: "upstream"})
|
||||||
|
→ d=1: loginHandler, apiMiddleware, testUtils
|
||||||
|
→ Affected Processes: LoginFlow, TokenRefresh
|
||||||
|
```
|
||||||
|
|
||||||
|
**gitnexus_detect_changes** — verify your changes after refactoring:
|
||||||
|
|
||||||
|
```
|
||||||
|
gitnexus_detect_changes({scope: "all"})
|
||||||
|
→ Changed: 8 files, 12 symbols
|
||||||
|
→ Affected processes: LoginFlow, TokenRefresh
|
||||||
|
→ Risk: MEDIUM
|
||||||
|
```
|
||||||
|
|
||||||
|
**gitnexus_cypher** — custom reference queries:
|
||||||
|
|
||||||
|
```cypher
|
||||||
|
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"})
|
||||||
|
RETURN caller.name, caller.filePath ORDER BY caller.filePath
|
||||||
|
```
|
||||||
|
|
||||||
|
## Risk Rules
|
||||||
|
|
||||||
|
| Risk Factor | Mitigation |
|
||||||
|
| ------------------- | ----------------------------------------- |
|
||||||
|
| Many callers (>5) | Use gitnexus_rename for automated updates |
|
||||||
|
| Cross-area refs | Use detect_changes after to verify scope |
|
||||||
|
| String/dynamic refs | gitnexus_query to find them |
|
||||||
|
| External/public API | Version and deprecate properly |
|
||||||
|
|
||||||
|
## Example: Rename `validateUser` to `authenticateUser`
|
||||||
|
|
||||||
|
```
|
||||||
|
1. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||||
|
→ 12 edits: 10 graph (safe), 2 ast_search (review)
|
||||||
|
→ Files: validator.ts, login.ts, middleware.ts, config.json...
|
||||||
|
|
||||||
|
2. Review ast_search edits (config.json: dynamic reference!)
|
||||||
|
|
||||||
|
3. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
|
||||||
|
→ Applied 12 edits across 8 files
|
||||||
|
|
||||||
|
4. gitnexus_detect_changes({scope: "all"})
|
||||||
|
→ Affected: LoginFlow, TokenRefresh
|
||||||
|
→ Risk: MEDIUM — run tests for these flows
|
||||||
|
```
|
||||||
16
.codegraph/.gitignore
vendored
Normal file
16
.codegraph/.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# CodeGraph data files
|
||||||
|
# These are local to each machine and should not be committed
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
|
||||||
|
# Cache
|
||||||
|
cache/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Hook markers
|
||||||
|
.dirty
|
||||||
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Android / Gradle
|
||||||
|
.gradle/
|
||||||
|
app/build/
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Keystore (regenerate if needed)
|
||||||
|
debug.keystore
|
||||||
43
AGENTS.md
Normal file
43
AGENTS.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<!-- gitnexus:start -->
|
||||||
|
# GitNexus — Code Intelligence
|
||||||
|
|
||||||
|
This project is indexed by GitNexus as **android-backup-gui** (773 symbols, 2066 relationships, 66 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||||
|
|
||||||
|
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||||
|
|
||||||
|
## Always Do
|
||||||
|
|
||||||
|
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||||
|
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||||
|
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||||
|
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||||
|
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||||
|
|
||||||
|
## Never Do
|
||||||
|
|
||||||
|
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||||
|
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||||
|
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||||
|
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
| Resource | Use for |
|
||||||
|
|----------|---------|
|
||||||
|
| `gitnexus://repo/android-backup-gui/context` | Codebase overview, check index freshness |
|
||||||
|
| `gitnexus://repo/android-backup-gui/clusters` | All functional areas |
|
||||||
|
| `gitnexus://repo/android-backup-gui/processes` | All execution flows |
|
||||||
|
| `gitnexus://repo/android-backup-gui/process/{name}` | Step-by-step execution trace |
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
| Task | Read this skill file |
|
||||||
|
|------|---------------------|
|
||||||
|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||||
|
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||||
|
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||||
|
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||||
|
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||||
|
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||||
|
|
||||||
|
<!-- gitnexus:end -->
|
||||||
43
CLAUDE.md
Normal file
43
CLAUDE.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<!-- gitnexus:start -->
|
||||||
|
# GitNexus — Code Intelligence
|
||||||
|
|
||||||
|
This project is indexed by GitNexus as **android-backup-gui** (773 symbols, 2066 relationships, 66 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||||
|
|
||||||
|
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||||
|
|
||||||
|
## Always Do
|
||||||
|
|
||||||
|
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||||
|
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||||
|
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||||
|
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||||
|
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||||
|
|
||||||
|
## Never Do
|
||||||
|
|
||||||
|
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||||
|
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||||
|
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||||
|
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
| Resource | Use for |
|
||||||
|
|----------|---------|
|
||||||
|
| `gitnexus://repo/android-backup-gui/context` | Codebase overview, check index freshness |
|
||||||
|
| `gitnexus://repo/android-backup-gui/clusters` | All functional areas |
|
||||||
|
| `gitnexus://repo/android-backup-gui/processes` | All execution flows |
|
||||||
|
| `gitnexus://repo/android-backup-gui/process/{name}` | Step-by-step execution trace |
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
| Task | Read this skill file |
|
||||||
|
|------|---------------------|
|
||||||
|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||||
|
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||||
|
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||||
|
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||||
|
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||||
|
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||||
|
|
||||||
|
<!-- gitnexus:end -->
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package com.example.androidbackupgui.backup
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages remote transport lifecycle (SMB/WebDAV) and local temp repo sync.
|
||||||
|
*
|
||||||
|
* For SMB/WebDAV backends, restic runs against a local temp directory;
|
||||||
|
* [RemoteTransport] syncs files to/from the remote backend.
|
||||||
|
*
|
||||||
|
* All sync operations are serialized via [repoSyncMutex] so concurrent
|
||||||
|
* operations don't corrupt the local temp repo.
|
||||||
|
*/
|
||||||
|
class RemoteSyncManager {
|
||||||
|
|
||||||
|
private val TAG = "ResticWrapper"
|
||||||
|
|
||||||
|
/** Local temp directory used as restic repo for SMB/WebDAV backends. */
|
||||||
|
@Volatile
|
||||||
|
var tempRepoDir: String = ""
|
||||||
|
|
||||||
|
/** Domain for SMB NTLM authentication. */
|
||||||
|
@Volatile
|
||||||
|
var backendDomain: String = ""
|
||||||
|
|
||||||
|
// ── Transport cache ──────────────────────────────────
|
||||||
|
@Volatile private var transport: RemoteTransport? = null
|
||||||
|
private var transportConfigKey: String = ""
|
||||||
|
private val transportLock = Any()
|
||||||
|
|
||||||
|
/** Serializes access to tempRepoDir so concurrent operations don't corrupt each other. */
|
||||||
|
private val repoSyncMutex = Mutex()
|
||||||
|
|
||||||
|
// ── Transport lifecycle ──────────────────────────────
|
||||||
|
|
||||||
|
private fun ensureTransport(
|
||||||
|
backend: String, url: String, user: String, pass: String, share: String, repoPath: String
|
||||||
|
): RemoteTransport? = synchronized(transportLock) {
|
||||||
|
val key = "$backend|$url|$user|$pass|$share|$backendDomain|$repoPath"
|
||||||
|
if (key != transportConfigKey || transport == null) {
|
||||||
|
transport?.let { Log.i(TAG, "transport config changed ($transportConfigKey -> $key), recreating") }
|
||||||
|
// Clear local temp repo when backend config changes so
|
||||||
|
// syncFromRemote downloads fresh data from the new backend
|
||||||
|
if (transportConfigKey.isNotEmpty() && tempRepoDir.isNotEmpty()) {
|
||||||
|
val dir = File(tempRepoDir)
|
||||||
|
val deleted = dir.deleteRecursively()
|
||||||
|
Log.i(TAG, "cleared local temp repo: $tempRepoDir (deleted=$deleted)")
|
||||||
|
dir.mkdirs()
|
||||||
|
}
|
||||||
|
transport = RemoteTransport.create(backend, url, user, pass, share, backendDomain)
|
||||||
|
if (transport != null) {
|
||||||
|
transportConfigKey = key
|
||||||
|
Log.i(TAG, "transport created: $backend @ $url repo=$repoPath domain=$backendDomain")
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "transport creation failed for backend=$backend url=$url")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return transport
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Temp dir lifecycle ───────────────────────────────
|
||||||
|
|
||||||
|
/** Clean up local temp repo and cache directories. */
|
||||||
|
private fun cleanupTempDirs() {
|
||||||
|
if (tempRepoDir.isEmpty()) return
|
||||||
|
try {
|
||||||
|
val repoDir = File(tempRepoDir)
|
||||||
|
if (repoDir.exists()) {
|
||||||
|
val deleted = repoDir.deleteRecursively()
|
||||||
|
Log.i(TAG, "cleanupTempDirs: deleted $tempRepoDir ($deleted)")
|
||||||
|
}
|
||||||
|
val cacheDir = File(tempRepoDir.substringBeforeLast("/") + "/restic_cache")
|
||||||
|
if (cacheDir.exists()) {
|
||||||
|
val deleted = cacheDir.deleteRecursively()
|
||||||
|
Log.i(TAG, "cleanupTempDirs: deleted cache $cacheDir ($deleted)")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "cleanupTempDirs failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if [tempRepoDir] already contains an initialized restic repository (has a config file). */
|
||||||
|
private fun isLocalRepoPopulated(): Boolean {
|
||||||
|
if (tempRepoDir.isEmpty()) return false
|
||||||
|
return File(tempRepoDir, "config").isFile
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sync engine ──────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute [action] with remote repo synced before/after as needed.
|
||||||
|
* For local/rest-server backends, executes [action] directly without sync.
|
||||||
|
* Protected by [repoSyncMutex] so concurrent operations don't corrupt tempRepoDir.
|
||||||
|
*
|
||||||
|
* Cleanup strategy:
|
||||||
|
* - Write ops (needsUpload=true): cleanup only on successful sync to remote.
|
||||||
|
* On syncToRemote failure the local repo is preserved so the next
|
||||||
|
* operation can retry — destroying it would lose the just-created snapshot.
|
||||||
|
* - Read-only ops (needsUpload=false): keep local cache for subsequent operations.
|
||||||
|
* - Read-only ops skip download entirely if local repo is already populated.
|
||||||
|
*/
|
||||||
|
suspend fun <T> withRemoteSync(
|
||||||
|
backend: String,
|
||||||
|
backendUrl: String,
|
||||||
|
backendUser: String,
|
||||||
|
backendPass: String,
|
||||||
|
backendShare: String,
|
||||||
|
repoPath: String,
|
||||||
|
needsDownload: Boolean,
|
||||||
|
needsUpload: Boolean,
|
||||||
|
onProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||||
|
onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||||
|
action: suspend () -> Result<T>
|
||||||
|
): Result<T> {
|
||||||
|
if (backend != "smb" && backend != "webdav") return action()
|
||||||
|
|
||||||
|
return repoSyncMutex.withLock {
|
||||||
|
var shouldCleanup = false
|
||||||
|
try {
|
||||||
|
val t = ensureTransport(backend, backendUrl, backendUser, backendPass, backendShare, repoPath)
|
||||||
|
?: return@withLock Result.failure(Exception("Failed to create transport for backend: $backend"))
|
||||||
|
|
||||||
|
val localDir = File(tempRepoDir)
|
||||||
|
|
||||||
|
val emitProgress: suspend (RemoteTransport.TransferProgress) -> Unit = { p ->
|
||||||
|
withContext(Dispatchers.Main) { onProgress(p) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write ops always download to avoid overwriting remote changes.
|
||||||
|
// Read-only ops skip download if local repo is already present.
|
||||||
|
val actualDownload = needsDownload && (needsUpload || !isLocalRepoPopulated())
|
||||||
|
if (actualDownload) {
|
||||||
|
Log.i(TAG, "syncFromRemote start: $repoPath -> $tempRepoDir")
|
||||||
|
val syncResult = RemoteTransport.syncFromRemote(t, localDir, repoPath, emitProgress, onByteProgress)
|
||||||
|
if (syncResult.isFailure) {
|
||||||
|
shouldCleanup = true
|
||||||
|
Log.e(TAG, "syncFromRemote FAILED: ${syncResult.exceptionOrNull()?.message}")
|
||||||
|
return@withLock Result.failure(
|
||||||
|
Exception("syncFromRemote failed: ${syncResult.exceptionOrNull()?.message}")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Log.i(TAG, "syncFromRemote complete")
|
||||||
|
} else if (needsDownload) {
|
||||||
|
Log.i(TAG, "syncFromRemote skipped: local repo already populated")
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = action()
|
||||||
|
|
||||||
|
if (needsUpload && result.isSuccess) {
|
||||||
|
Log.i(TAG, "syncToRemote start: $tempRepoDir -> $repoPath")
|
||||||
|
val uploadResult = RemoteTransport.syncToRemote(t, localDir, repoPath, emitProgress, onByteProgress)
|
||||||
|
if (uploadResult.isFailure) {
|
||||||
|
shouldCleanup = false // PRESERVE local repo — snapshot would be lost
|
||||||
|
Log.e(TAG, "syncToRemote FAILED: ${uploadResult.exceptionOrNull()?.message} — local repo preserved for retry")
|
||||||
|
return@withLock Result.failure(
|
||||||
|
Exception("syncToRemote failed: ${uploadResult.exceptionOrNull()?.message}")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Log.i(TAG, "syncToRemote complete")
|
||||||
|
shouldCleanup = true
|
||||||
|
} else if (result.isFailure) {
|
||||||
|
shouldCleanup = true
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
shouldCleanup = true
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
shouldCleanup = true
|
||||||
|
Result.failure(e)
|
||||||
|
} finally {
|
||||||
|
if (shouldCleanup) {
|
||||||
|
Log.i(TAG, "withRemoteSync: cleaning up temp dirs")
|
||||||
|
cleanupTempDirs()
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "withRemoteSync: keeping local repo for subsequent ops")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public safety-net cleanup called by fragment lifecycle.
|
||||||
|
* Waits for any in-progress operation to finish, then deletes temp dirs.
|
||||||
|
*/
|
||||||
|
suspend fun cleanup() {
|
||||||
|
repoSyncMutex.withLock { cleanupTempDirs() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package com.example.androidbackupgui.backup
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlin.coroutines.coroutineContext
|
||||||
|
|
||||||
|
/** Shared Json instance configured for restic's snake_case output via @SerialName. */
|
||||||
|
private val resticJson = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backup operations: running restic backup and parsing its summary output.
|
||||||
|
*
|
||||||
|
* Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and
|
||||||
|
* [RemoteSyncManager] which are shared across sub-modules.
|
||||||
|
*/
|
||||||
|
class ResticBackup(
|
||||||
|
private val runner: ResticCommandRunner,
|
||||||
|
private val envResolver: ResticEnvResolver,
|
||||||
|
private val syncManager: RemoteSyncManager
|
||||||
|
) {
|
||||||
|
private val TAG = "ResticWrapper"
|
||||||
|
|
||||||
|
// ── Backup ─────────────────────────────────────────
|
||||||
|
|
||||||
|
suspend fun backup(
|
||||||
|
repoPath: String,
|
||||||
|
password: String,
|
||||||
|
paths: List<String>,
|
||||||
|
tags: List<String> = emptyList(),
|
||||||
|
hostname: String? = null,
|
||||||
|
backend: String = "local",
|
||||||
|
backendUrl: String = "",
|
||||||
|
backendUser: String = "",
|
||||||
|
backendPass: String = "",
|
||||||
|
backendShare: String = "",
|
||||||
|
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||||
|
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||||
|
onProgress: suspend (ResticWrapper.ResticProgress) -> Unit = {}
|
||||||
|
): Result<ResticWrapper.BackupSummary> = withContext(Dispatchers.IO) {
|
||||||
|
val emit: suspend (ResticWrapper.ResticProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
|
||||||
|
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
||||||
|
needsDownload = true, needsUpload = true,
|
||||||
|
onProgress = onSyncProgress,
|
||||||
|
onByteProgress = onByteSyncProgress,
|
||||||
|
) {
|
||||||
|
val args = mutableListOf("backup", "--json")
|
||||||
|
for (path in paths) args.add(path)
|
||||||
|
for (tag in tags) { args.add("--tag"); args.add(tag) }
|
||||||
|
if (hostname != null) { args.add("--host"); args.add(hostname) }
|
||||||
|
|
||||||
|
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
||||||
|
val result = runner.runResticStreaming(env, args) { line ->
|
||||||
|
if (!coroutineContext.isActive) return@runResticStreaming
|
||||||
|
try {
|
||||||
|
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
|
||||||
|
if (progress.messageType == "status") emit(progress)
|
||||||
|
} catch (_: Exception) { /* ignore non-JSON lines */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.exitCode != 0) {
|
||||||
|
return@withRemoteSync Result.failure(Exception("restic backup failed: ${result.stderr}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
parseBackupSummary(result.stdout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal helpers ───────────────────────────────
|
||||||
|
|
||||||
|
/** Parse the JSON summary from the end of restic backup output. */
|
||||||
|
private fun parseBackupSummary(stdout: String): Result<ResticWrapper.BackupSummary> {
|
||||||
|
val lines = stdout.lines()
|
||||||
|
for (i in lines.indices.reversed()) {
|
||||||
|
val line = lines[i].trim()
|
||||||
|
if (!line.startsWith("{")) continue
|
||||||
|
try {
|
||||||
|
val summary = resticJson.decodeFromString<ResticWrapper.BackupSummary>(line)
|
||||||
|
if (summary.snapshotId.isNotEmpty()) return Result.success(summary)
|
||||||
|
} catch (_: Exception) { /* keep looking */ }
|
||||||
|
}
|
||||||
|
return Result.failure(Exception("No summary found in restic output"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package com.example.androidbackupgui.backup
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import kotlin.coroutines.coroutineContext
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages restic binary process execution.
|
||||||
|
* Holds the binary path and provides blocking and streaming execution.
|
||||||
|
*/
|
||||||
|
class ResticCommandRunner {
|
||||||
|
|
||||||
|
private val TAG = "ResticWrapper"
|
||||||
|
|
||||||
|
/** Path to the restic binary. Default assumes it's on PATH (e.g. Termux). */
|
||||||
|
var binaryPath: String = "restic"
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CommandResult(
|
||||||
|
val stdout: String,
|
||||||
|
val stderr: String,
|
||||||
|
val exitCode: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Build the full command list to run restic. */
|
||||||
|
fun buildCommandArgs(args: List<String>): List<String> {
|
||||||
|
val cmd = listOf(binaryPath) + args
|
||||||
|
Log.d(TAG, "buildCommandArgs: binaryPath=$binaryPath args=$args → cmd=$cmd")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run restic (non-streaming). */
|
||||||
|
fun runRestic(env: Map<String, String>, args: List<String>): CommandResult {
|
||||||
|
val cmdArgs = buildCommandArgs(args)
|
||||||
|
Log.i(TAG, "runRestic cmd=${cmdArgs.joinToString(" ")}")
|
||||||
|
Log.d(TAG, "runRestic REPOSITORY=${env["RESTIC_REPOSITORY"]}")
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val pb = ProcessBuilder(cmdArgs)
|
||||||
|
pb.environment().putAll(env)
|
||||||
|
pb.redirectErrorStream(false)
|
||||||
|
val process = pb.start()
|
||||||
|
|
||||||
|
val stderrText = StringBuilder()
|
||||||
|
val stderrThread = Thread({
|
||||||
|
try {
|
||||||
|
process.errorStream.bufferedReader().use { reader ->
|
||||||
|
var line: String?
|
||||||
|
while (reader.readLine().also { line = it } != null) {
|
||||||
|
Log.d(TAG, "restic stderr: $line")
|
||||||
|
stderrText.appendLine(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}, "restic-stderr").apply { isDaemon = true; start() }
|
||||||
|
|
||||||
|
val stdout = process.inputStream.bufferedReader().use(BufferedReader::readText)
|
||||||
|
val exitCode = process.waitFor()
|
||||||
|
stderrThread.join(5000)
|
||||||
|
Log.i(TAG, "runRestic exitCode=$exitCode stdout_len=${stdout.length}")
|
||||||
|
if (stderrText.isNotEmpty()) Log.w(TAG, "runRestic stderr: ${stderrText}")
|
||||||
|
CommandResult(stdout.trim(), stderrText.toString().trim(), exitCode)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "runRestic exception", e)
|
||||||
|
CommandResult("", e.message ?: "Unknown error", -1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run restic with single-string args. */
|
||||||
|
fun runRestic(env: Map<String, String>, vararg args: String): CommandResult {
|
||||||
|
return runRestic(env, args.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run restic, calling onLine for each stdout line (for streaming progress). */
|
||||||
|
suspend fun runResticStreaming(
|
||||||
|
env: Map<String, String>,
|
||||||
|
args: List<String>,
|
||||||
|
onLine: suspend (String) -> Unit
|
||||||
|
): CommandResult = withContext(Dispatchers.IO) {
|
||||||
|
val cmdArgs = buildCommandArgs(args)
|
||||||
|
Log.i(TAG, "runResticStreaming cmd=${cmdArgs.joinToString(" ")}")
|
||||||
|
Log.d(TAG, "runResticStreaming REPOSITORY=${env["RESTIC_REPOSITORY"]}")
|
||||||
|
|
||||||
|
var process: Process? = null
|
||||||
|
try {
|
||||||
|
val pb = ProcessBuilder(cmdArgs)
|
||||||
|
pb.environment().putAll(env)
|
||||||
|
pb.redirectErrorStream(false)
|
||||||
|
process = pb.start()
|
||||||
|
|
||||||
|
val stdoutText = StringBuilder()
|
||||||
|
val reader = process.inputStream.bufferedReader()
|
||||||
|
val stderrReader = process.errorStream.bufferedReader()
|
||||||
|
|
||||||
|
val stderrText = StringBuilder()
|
||||||
|
val stderrThread = Thread({
|
||||||
|
try { stderrReader.use { stderrText.append(it.readText()) } } catch (_: Exception) {}
|
||||||
|
}, "restic-stderr").apply { isDaemon = true; start() }
|
||||||
|
|
||||||
|
try {
|
||||||
|
var line: String?
|
||||||
|
while (reader.readLine().also { line = it } != null) {
|
||||||
|
if (!coroutineContext.isActive) {
|
||||||
|
process.destroy()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
val l = line!!
|
||||||
|
stdoutText.appendLine(l)
|
||||||
|
onLine(l)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try { reader.close() } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
stderrThread.join(5000)
|
||||||
|
val exitCode = try { process.waitFor() } catch (_: Exception) { -1 }
|
||||||
|
|
||||||
|
Log.i(TAG, "runResticStreaming exitCode=$exitCode stdout_len=${stdoutText.length}")
|
||||||
|
if (stderrText.isNotEmpty()) Log.w(TAG, "runResticStreaming stderr: ${stderrText}")
|
||||||
|
CommandResult(stdoutText.toString().trim(), stderrText.toString().trim(), exitCode)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "runResticStreaming exception", e)
|
||||||
|
try { process?.destroy() } catch (_: Exception) {}
|
||||||
|
CommandResult("", e.message ?: "Unknown error", -1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.example.androidbackupgui.backup
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stateless helper for constructing restic environment variables and repo URLs.
|
||||||
|
*/
|
||||||
|
class ResticEnvResolver {
|
||||||
|
|
||||||
|
/** Build environment for restic. For SMB/WebDAV backends, uses local temp dir as repo. */
|
||||||
|
fun buildFullEnv(
|
||||||
|
repoPath: String,
|
||||||
|
password: String,
|
||||||
|
backend: String = "local",
|
||||||
|
backendUrl: String = "",
|
||||||
|
backendUser: String = "",
|
||||||
|
backendPass: String = "",
|
||||||
|
backendShare: String = "",
|
||||||
|
tempRepoDir: String = ""
|
||||||
|
): Map<String, String> {
|
||||||
|
val env = HashMap(System.getenv() ?: emptyMap())
|
||||||
|
env["RESTIC_REPOSITORY"] = if (backend == "smb" || backend == "webdav") {
|
||||||
|
tempRepoDir
|
||||||
|
} else {
|
||||||
|
buildRepoUrl(backend, repoPath, backendUrl)
|
||||||
|
}
|
||||||
|
env["RESTIC_PASSWORD"] = password
|
||||||
|
// Provide a cache directory on Android (no $HOME by default)
|
||||||
|
if (tempRepoDir.isNotEmpty()) {
|
||||||
|
val cacheDir = tempRepoDir.substringBeforeLast("/") + "/restic_cache"
|
||||||
|
env["HOME"] = cacheDir
|
||||||
|
env["XDG_CACHE_HOME"] = cacheDir
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a display-friendly repository URL for UI. */
|
||||||
|
fun buildRepoUrl(backend: String, repoPath: String, backendUrl: String): String {
|
||||||
|
return when (backend) {
|
||||||
|
"local" -> repoPath
|
||||||
|
"rest-server" -> "rest:${backendUrl.trimEnd('/')}/$repoPath"
|
||||||
|
"webdav" -> "${backendUrl.trimEnd('/')}/$repoPath"
|
||||||
|
"smb" -> "smb:${backendUrl.trimEnd('/')}/$repoPath"
|
||||||
|
else -> repoPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package com.example.androidbackupgui.backup
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository maintenance operations: prune, check, stats.
|
||||||
|
*
|
||||||
|
* [prune] requires both download and upload (it removes pack files from the remote).
|
||||||
|
* [check] and [stats] are download-only read operations.
|
||||||
|
* Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and
|
||||||
|
* [RemoteSyncManager] which are shared across sub-modules.
|
||||||
|
*/
|
||||||
|
class ResticMaintenance(
|
||||||
|
private val runner: ResticCommandRunner,
|
||||||
|
private val envResolver: ResticEnvResolver,
|
||||||
|
private val syncManager: RemoteSyncManager
|
||||||
|
) {
|
||||||
|
// ── Prune ──────────────────────────────────────────
|
||||||
|
|
||||||
|
suspend fun prune(
|
||||||
|
repoPath: String,
|
||||||
|
password: String,
|
||||||
|
backend: String = "local",
|
||||||
|
backendUrl: String = "",
|
||||||
|
backendUser: String = "",
|
||||||
|
backendPass: String = "",
|
||||||
|
backendShare: String = "",
|
||||||
|
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||||
|
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||||
|
): Result<String> =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
||||||
|
needsDownload = true, needsUpload = true,
|
||||||
|
onProgress = onSyncProgress,
|
||||||
|
onByteProgress = onByteSyncProgress,
|
||||||
|
) {
|
||||||
|
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
||||||
|
val result = runner.runRestic(env, "prune")
|
||||||
|
if (result.exitCode == 0) Result.success(result.stdout)
|
||||||
|
else Result.failure(Exception("restic prune failed: ${result.stderr}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Check ──────────────────────────────────────────
|
||||||
|
|
||||||
|
suspend fun check(
|
||||||
|
repoPath: String,
|
||||||
|
password: String,
|
||||||
|
backend: String = "local",
|
||||||
|
backendUrl: String = "",
|
||||||
|
backendUser: String = "",
|
||||||
|
backendPass: String = "",
|
||||||
|
backendShare: String = "",
|
||||||
|
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||||
|
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||||
|
): Result<String> =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
||||||
|
needsDownload = true, needsUpload = false,
|
||||||
|
onProgress = onSyncProgress,
|
||||||
|
onByteProgress = onByteSyncProgress,
|
||||||
|
) {
|
||||||
|
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
||||||
|
val result = runner.runRestic(env, "check")
|
||||||
|
if (result.exitCode == 0) Result.success(result.stdout)
|
||||||
|
else Result.failure(Exception("restic check failed: ${result.stderr}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stats ──────────────────────────────────────────
|
||||||
|
|
||||||
|
suspend fun stats(
|
||||||
|
repoPath: String,
|
||||||
|
password: String,
|
||||||
|
backend: String = "local",
|
||||||
|
backendUrl: String = "",
|
||||||
|
backendUser: String = "",
|
||||||
|
backendPass: String = "",
|
||||||
|
backendShare: String = "",
|
||||||
|
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||||
|
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||||
|
): Result<String> =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
||||||
|
needsDownload = true, needsUpload = false,
|
||||||
|
onProgress = onSyncProgress,
|
||||||
|
onByteProgress = onByteSyncProgress,
|
||||||
|
) {
|
||||||
|
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
||||||
|
val result = runner.runRestic(env, "stats")
|
||||||
|
if (result.exitCode == 0) Result.success(result.stdout)
|
||||||
|
else Result.failure(Exception("restic stats failed: ${result.stderr}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.example.androidbackupgui.backup
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository lifecycle operations: init and repo URL construction.
|
||||||
|
*
|
||||||
|
* Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and
|
||||||
|
* [RemoteSyncManager] which are shared across sub-modules.
|
||||||
|
*/
|
||||||
|
class ResticRepoInit(
|
||||||
|
private val runner: ResticCommandRunner,
|
||||||
|
private val envResolver: ResticEnvResolver,
|
||||||
|
private val syncManager: RemoteSyncManager
|
||||||
|
) {
|
||||||
|
private val TAG = "ResticWrapper"
|
||||||
|
|
||||||
|
// ── Repository initialization ──────────────────────
|
||||||
|
|
||||||
|
suspend fun init(
|
||||||
|
repoPath: String,
|
||||||
|
password: String,
|
||||||
|
backend: String = "local",
|
||||||
|
backendUrl: String = "",
|
||||||
|
backendUser: String = "",
|
||||||
|
backendPass: String = "",
|
||||||
|
backendShare: String = "",
|
||||||
|
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||||
|
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||||
|
): Result<Unit> =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
||||||
|
needsDownload = true, needsUpload = true,
|
||||||
|
onProgress = onSyncProgress,
|
||||||
|
onByteProgress = onByteSyncProgress,
|
||||||
|
) {
|
||||||
|
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
||||||
|
val result = runner.runRestic(env, "init")
|
||||||
|
// exitCode 0 = brand new repo created, needs upload
|
||||||
|
if (result.exitCode == 0) {
|
||||||
|
return@withRemoteSync Result.success(Unit)
|
||||||
|
}
|
||||||
|
// exitCode 1 = config already exists; verify the repo is actually usable
|
||||||
|
if (result.exitCode == 1) {
|
||||||
|
val verify = runner.runRestic(env, "snapshots", "--json")
|
||||||
|
if (verify.exitCode == 0) {
|
||||||
|
// Repo is healthy — already initialized with matching password
|
||||||
|
Log.i(TAG, "init: repo already initialized and verified")
|
||||||
|
return@withRemoteSync Result.success(Unit)
|
||||||
|
}
|
||||||
|
// Config exists but repo is corrupted (wrong password, missing keys, etc.)
|
||||||
|
return@withRemoteSync Result.failure(
|
||||||
|
Exception("仓库已存在但无法验证: ${verify.stderr.ifEmpty { "密码错误或密钥缺失" }}。请删除远端仓库后重试。")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Result.failure(Exception("restic init failed: ${result.stderr}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public URL helper ──────────────────────────────
|
||||||
|
|
||||||
|
/** Build a display-friendly repository URL for UI. */
|
||||||
|
fun buildRepoUrl(backend: String, repoPath: String, backendUrl: String): String {
|
||||||
|
return envResolver.buildRepoUrl(backend, repoPath, backendUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package com.example.androidbackupgui.backup
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlin.coroutines.coroutineContext
|
||||||
|
|
||||||
|
/** Shared Json instance configured for restic's snake_case output via @SerialName. */
|
||||||
|
private val resticJson = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore operations: full directory restore and single-file dump.
|
||||||
|
*
|
||||||
|
* Both are download-only operations (no upload to remote needed).
|
||||||
|
* Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and
|
||||||
|
* [RemoteSyncManager] which are shared across sub-modules.
|
||||||
|
*/
|
||||||
|
class ResticRestore(
|
||||||
|
private val runner: ResticCommandRunner,
|
||||||
|
private val envResolver: ResticEnvResolver,
|
||||||
|
private val syncManager: RemoteSyncManager
|
||||||
|
) {
|
||||||
|
// ── Restore ────────────────────────────────────────
|
||||||
|
|
||||||
|
suspend fun restore(
|
||||||
|
repoPath: String,
|
||||||
|
password: String,
|
||||||
|
snapshotId: String,
|
||||||
|
targetPath: String,
|
||||||
|
include: String? = null,
|
||||||
|
backend: String = "local",
|
||||||
|
backendUrl: String = "",
|
||||||
|
backendUser: String = "",
|
||||||
|
backendPass: String = "",
|
||||||
|
backendShare: String = "",
|
||||||
|
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||||
|
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||||
|
onProgress: suspend (String) -> Unit = {}
|
||||||
|
): Result<Unit> = withContext(Dispatchers.IO) {
|
||||||
|
val emit: suspend (String) -> Unit = { s -> withContext(Dispatchers.Main) { onProgress(s) } }
|
||||||
|
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
||||||
|
needsDownload = true, needsUpload = false,
|
||||||
|
onProgress = onSyncProgress,
|
||||||
|
onByteProgress = onByteSyncProgress,
|
||||||
|
) {
|
||||||
|
File(targetPath).mkdirs()
|
||||||
|
|
||||||
|
val args = mutableListOf("restore", snapshotId, "--target", targetPath, "--json")
|
||||||
|
if (include != null) { args.add("--include"); args.add(include) }
|
||||||
|
|
||||||
|
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
||||||
|
val result = runner.runResticStreaming(env, args) { line ->
|
||||||
|
if (!coroutineContext.isActive) return@runResticStreaming
|
||||||
|
try {
|
||||||
|
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
|
||||||
|
when (progress.messageType) {
|
||||||
|
"status" -> {
|
||||||
|
val percent = "%.1f".format(progress.percentDone * 100)
|
||||||
|
emit("恢复进度: $percent%")
|
||||||
|
}
|
||||||
|
"summary" -> {
|
||||||
|
emit("恢复完成: ${progress.totalFiles} 个文件")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { emit(line) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.exitCode == 0) Result.success(Unit)
|
||||||
|
else Result.failure(Exception("restic restore failed: ${result.stderr}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── File dump ──────────────────────────────────────
|
||||||
|
|
||||||
|
suspend fun dump(
|
||||||
|
repoPath: String,
|
||||||
|
password: String,
|
||||||
|
snapshotId: String,
|
||||||
|
filePath: String,
|
||||||
|
backend: String = "local",
|
||||||
|
backendUrl: String = "",
|
||||||
|
backendUser: String = "",
|
||||||
|
backendPass: String = "",
|
||||||
|
backendShare: String = "",
|
||||||
|
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||||
|
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||||
|
): Result<String> = withContext(Dispatchers.IO) {
|
||||||
|
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
||||||
|
needsDownload = true, needsUpload = false,
|
||||||
|
onProgress = onSyncProgress,
|
||||||
|
onByteProgress = onByteSyncProgress,
|
||||||
|
) {
|
||||||
|
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
||||||
|
val result = runner.runRestic(env, "dump", snapshotId, filePath)
|
||||||
|
if (result.exitCode == 0) Result.success(result.stdout)
|
||||||
|
else Result.failure(Exception(result.stderr.ifEmpty { "restic dump failed with exit code ${result.exitCode}" }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package com.example.androidbackupgui.backup
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
|
||||||
|
/** Shared Json instance configured for restic's snake_case output via @SerialName. */
|
||||||
|
private val resticJson = Json { ignoreUnknownKeys = true }
|
||||||
|
/**
|
||||||
|
* Snapshot listing and retention policy operations.
|
||||||
|
*
|
||||||
|
* [listSnapshots] is download-only; [forget] requires both download and upload
|
||||||
|
* (forget removes snapshots from the remote).
|
||||||
|
* Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and
|
||||||
|
* [RemoteSyncManager] which are shared across sub-modules.
|
||||||
|
*/
|
||||||
|
class ResticSnapshotOps(
|
||||||
|
private val runner: ResticCommandRunner,
|
||||||
|
private val envResolver: ResticEnvResolver,
|
||||||
|
private val syncManager: RemoteSyncManager
|
||||||
|
) {
|
||||||
|
// ── List snapshots ─────────────────────────────────
|
||||||
|
|
||||||
|
suspend fun listSnapshots(
|
||||||
|
repoPath: String,
|
||||||
|
password: String,
|
||||||
|
tag: String? = null,
|
||||||
|
backend: String = "local",
|
||||||
|
backendUrl: String = "",
|
||||||
|
backendUser: String = "",
|
||||||
|
backendPass: String = "",
|
||||||
|
backendShare: String = "",
|
||||||
|
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||||
|
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||||
|
): Result<List<ResticWrapper.ResticSnapshot>> = withContext(Dispatchers.IO) {
|
||||||
|
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
||||||
|
needsDownload = true, needsUpload = false,
|
||||||
|
onProgress = onSyncProgress,
|
||||||
|
onByteProgress = onByteSyncProgress,
|
||||||
|
) {
|
||||||
|
val args = mutableListOf("snapshots", "--json")
|
||||||
|
if (tag != null) { args.add("--tag"); args.add(tag) }
|
||||||
|
|
||||||
|
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
||||||
|
val result = runner.runRestic(env, args)
|
||||||
|
|
||||||
|
if (result.exitCode != 0) {
|
||||||
|
return@withRemoteSync Result.failure(Exception("restic snapshots failed: ${result.stderr}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val snapshots = resticJson.decodeFromString<List<ResticWrapper.ResticSnapshot>>(
|
||||||
|
result.stdout.ifEmpty { "[]" }
|
||||||
|
)
|
||||||
|
Result.success(snapshots.sortedByDescending { it.time })
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(Exception("Failed to parse snapshot JSON: ${e.message}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Forget (retention policy) ──────────────────────
|
||||||
|
|
||||||
|
suspend fun forget(
|
||||||
|
repoPath: String,
|
||||||
|
password: String,
|
||||||
|
keepDaily: Int = 7,
|
||||||
|
keepWeekly: Int = 4,
|
||||||
|
keepMonthly: Int = 3,
|
||||||
|
dryRun: Boolean = false,
|
||||||
|
backend: String = "local",
|
||||||
|
backendUrl: String = "",
|
||||||
|
backendUser: String = "",
|
||||||
|
backendPass: String = "",
|
||||||
|
backendShare: String = "",
|
||||||
|
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||||
|
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||||
|
): Result<String> = withContext(Dispatchers.IO) {
|
||||||
|
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
||||||
|
needsDownload = true, needsUpload = true,
|
||||||
|
onProgress = onSyncProgress,
|
||||||
|
onByteProgress = onByteSyncProgress,
|
||||||
|
) {
|
||||||
|
val args = mutableListOf(
|
||||||
|
"forget",
|
||||||
|
"--keep-daily", keepDaily.toString(),
|
||||||
|
"--keep-weekly", keepWeekly.toString(),
|
||||||
|
"--keep-monthly", keepMonthly.toString()
|
||||||
|
)
|
||||||
|
if (dryRun) args.add("--dry-run")
|
||||||
|
|
||||||
|
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
||||||
|
val result = runner.runRestic(env, args)
|
||||||
|
|
||||||
|
if (result.exitCode == 0) Result.success(result.stdout)
|
||||||
|
else Result.failure(Exception("restic forget failed: ${result.stderr}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user