Compare commits
47
Commits
bc2cc0b531
...
v0.1.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f908bffc41 | ||
|
|
a1638bd944 | ||
|
|
93d830d500 | ||
|
|
89b7eb78a7 | ||
|
|
b1dd808d1e | ||
|
|
d247095e87 | ||
|
|
66579d068d | ||
|
|
4c14ef2cee | ||
|
|
0f94e50746 | ||
|
|
c9aa028b3c | ||
|
|
bca125cb92 | ||
|
|
7d73901631 | ||
|
|
6663cb25f8 | ||
|
|
8a28364839 | ||
|
|
894f97820e | ||
|
|
fb646a94be | ||
|
|
4bcae1358d | ||
|
|
2428a33046 | ||
|
|
a48fe55d4b | ||
|
|
0df346dbf9 | ||
|
|
d7f4c6826b | ||
|
|
79be211df3 | ||
|
|
ba87d6184b | ||
|
|
180c3f0eba | ||
|
|
be0c761cb3 | ||
|
|
4ebf3bd60f | ||
|
|
63547e74c2 | ||
|
|
79b630b622 | ||
|
|
4520ad3dfd | ||
|
|
4072ca4a45 | ||
|
|
ba81eecea8 | ||
|
|
287d60e978 | ||
|
|
e33682098a | ||
|
|
cbf00f3e33 | ||
|
|
ab670b3ecd | ||
|
|
8bfd6b22c5 | ||
|
|
adc276c28a | ||
|
|
73c2009139 | ||
|
|
71961f68da | ||
|
|
3ea14b91c0 | ||
|
|
a2afce3f8d | ||
|
|
2bbbf4e81e | ||
|
|
1f5243d953 | ||
|
|
95b5a7b70c | ||
|
|
7f05519f6f | ||
|
|
72bee49d3f | ||
|
|
1e913c784e |
@@ -1,3 +1,4 @@
|
||||
bin/
|
||||
.env
|
||||
vendor/
|
||||
.dirmd
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Generate markdown documentation from directory structure. Optimized for LLM ingestion.
|
||||
|
||||
<video src="./docs/videos/dirmd.webm" autoplay loop muted playsinline></video>
|
||||
|
||||
## Features
|
||||
|
||||
- Recursive directory traversal
|
||||
@@ -13,6 +15,37 @@ Generate markdown documentation from directory structure. Optimized for LLM inge
|
||||
- Force overwrite of existing output files
|
||||
- Configurable depth, file size limits, ignore patterns
|
||||
- Optional upload to Proton Drive after generating output
|
||||
- Skip frontend files (`.html`, `.css`, `.js`, etc.)
|
||||
- Frontend only mode (include only frontend files and `README.md`)
|
||||
- Omit test files and inline test blocks (Go, Java, Rust)
|
||||
|
||||
## Installation
|
||||
Only for linux.
|
||||
Download binary from release page or install with go
|
||||
```bash
|
||||
go install git.flo.fo/FLO/dirmd@latest
|
||||
```
|
||||
|
||||
## Use with Git Post Hook
|
||||
combine with git post hook to sync with proton drive on each commit
|
||||
|
||||
add something like this in ./.git/hooks/post-commit
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if ! command -v dirmd &>/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
REPO_NAME=$(basename "$REPO_ROOT")
|
||||
|
||||
dirmd -o "/tmp/${REPO_NAME}" "$REPO_ROOT" -f --vertical-slices --proton-drive
|
||||
```
|
||||
|
||||
|
||||
## Build
|
||||
|
||||
@@ -35,12 +68,60 @@ Generate markdown documentation from directory structure. Optimized for LLM inge
|
||||
# Generate with vertical slices
|
||||
dirmd -o output.md ~/repos/myproject --vertical-slices
|
||||
|
||||
# Skip frontend files (.html, .css, .js, etc.)
|
||||
dirmd -o output.md ~/repos/myproject --skip-frontend
|
||||
|
||||
# Frontend only (include only frontend files and README.md)
|
||||
dirmd -o output.md ~/repos/myproject --frontend-only
|
||||
|
||||
# Omit test files and inline test blocks
|
||||
# Go: excludes *_test.go
|
||||
# Java: excludes *Test.java, *Tests.java, src/test/java/*
|
||||
# Rust: strips #[cfg(test)] mod tests { ... } blocks
|
||||
dirmd -o output.md ~/repos/myproject --omit-test
|
||||
|
||||
# Combine filters
|
||||
dirmd -o output.md ~/repos/myproject --frontend-only --omit-test
|
||||
|
||||
# Generate and upload to Proton Drive (default: /my-files/md/<filename>)
|
||||
dirmd -o output.md ~/repos/myproject --proton-drive
|
||||
|
||||
# Generate and upload to a specific remote path
|
||||
dirmd -o output.md ~/repos/myproject --proton-drive --drive-path /my-files/docs/api.md
|
||||
|
||||
## Test File Omission
|
||||
|
||||
The `--omit-test` flag removes test files and inline test blocks to reduce token overhead in LLM context windows. Behavior varies by language:
|
||||
|
||||
| Language | Exclusion Strategy | What Gets Removed |
|
||||
|----------|-------------------|-------------------|
|
||||
| **Go** | File exclusion | All `*_test.go` files |
|
||||
| **Java** | File exclusion | `*Test.java`, `*Tests.java`, files under `src/test/java/` |
|
||||
| **Rust** | Content stripping | Entire `#[cfg(test)] mod tests { ... }` blocks removed from `.rs` files |
|
||||
|
||||
The Rust stripping works by detecting the `#[cfg(test)]` attribute, then tracking brace depth until the module closes. Nested braces in string literals may occasionally throw off the count, but this is acceptable for RAG use cases.
|
||||
|
||||
## Frontend Filtering
|
||||
|
||||
Two complementary flags control frontend file inclusion:
|
||||
|
||||
| Flag | Effect |
|
||||
|------|--------|
|
||||
| `--skip-frontend` | Exclude frontend files, keep everything else |
|
||||
| `--frontend-only` | Include only frontend files and `README.md`, exclude everything else |
|
||||
|
||||
Frontend extensions covered:
|
||||
|
||||
| Type | Extensions |
|
||||
|------|------------|
|
||||
| Templates | `.html`, `.htm`, `.gohtml`, `.tmpl` |
|
||||
| Stylesheets | `.css`, `.scss`, `.sass`, `.less` |
|
||||
| JavaScript | `.js`, `.jsx`, `.mjs`, `.cjs` |
|
||||
| TypeScript | `.ts`, `.tsx` |
|
||||
| Frameworks | `.vue`, `.svelte` |
|
||||
|
||||
`--skip-frontend` and `--frontend-only` are mutually exclusive. Both can be combined with `--omit-test`.
|
||||
|
||||
## Vertical Slices Mode
|
||||
|
||||
When `--vertical-slices` is passed, dirmd splits output into multiple files instead of one monolithic document:
|
||||
@@ -57,10 +138,32 @@ Example output with `dirmd -o docs.md ~/repos/myproject --vertical-slices`:
|
||||
├── cmd.md ← slice: tree + contents of cmd/
|
||||
└── internal.md ← slice: tree + contents of internal/
|
||||
|
||||
## Instructions File (`.dirmd`)
|
||||
|
||||
If a file named `.dirmd` exists in the root of the input directory, you can include its contents as metadata in the generated output by passing the `--instructions` flag.
|
||||
|
||||
This is useful for injecting specific context, constraints, or instructions for LLMs processing the snapshot.
|
||||
|
||||
- **File Name**: `.dirmd`
|
||||
- **Location**: Root of the input directory.
|
||||
- **Format**: Plain text or Markdown.
|
||||
- **Output**: Rendered as a quoted block (`> Instructions: ...`) immediately after the timestamp and commit info.
|
||||
- **Opt-in**: The file is **ignored** unless `--instructions` is explicitly passed.
|
||||
|
||||
Example `.dirmd`:
|
||||
|
||||
```text
|
||||
Focus on the `internal/` package for business logic.
|
||||
Ignore `cmd/` unless explicitly asked.
|
||||
Prioritize Go files over Python.
|
||||
```
|
||||
|
||||
## LLM-Optimized Output
|
||||
|
||||
Output is designed for RAG ingestion and LLM context windows, not human presentation:
|
||||
|
||||
- **ISO 8601 Timestamp**: Added as a metadata line (`> Generated: ...`) immediately after the H1 header for every new file created.
|
||||
- **Git Commit Info**: If the source directory is a Git repository, a second metadata line (`> Commit: <sha> (<message>)`) is added below the timestamp.
|
||||
- **ASCII tree format** (`+-` instead of Unicode box-drawing) to reduce token overhead
|
||||
- **Explicit file delimiters**: `--- FILE: ./path (bytes, lines) ---` and `--- END FILE ---`
|
||||
- **Document type markers**: `[ROOT]` and `[SLICE]` in H1 headers
|
||||
@@ -82,7 +185,10 @@ Output is designed for RAG ingestion and LLM context windows, not human presenta
|
||||
| `--ignore` | Additional ignore patterns (glob) | |
|
||||
| `--extensions` | Additional file extensions to skip | |
|
||||
| `--proton-drive` | Upload output to Proton Drive after writing locally | false |
|
||||
| `--drive-path` | Full remote path on Proton Drive (e.g. /my-files/md/report.md) | /my-files/md/<filename> |
|
||||
| `--drive-path` | Full remote path on Proton Drive (e.g. /my-files/md/report.md) | /my-files/md/<filename> |
|
||||
| `--skip-frontend` | Omit frontend file types (.html, .css, .js, .ts, .vue, .svelte, etc.) | false |
|
||||
| `--frontend-only` | Include only frontend file types and README.md | false |
|
||||
| `--omit-test` | Exclude test files (Go *_test.go, Java *Test.java) and strip #[cfg(test)] blocks from Rust | false |
|
||||
|
||||
## Proton Drive Integration
|
||||
|
||||
@@ -94,7 +200,7 @@ In vertical slices mode, all generated files (root + slices) are uploaded to `/m
|
||||
|
||||
1. Install the Proton Drive CLI — see the [official guide](https://proton.me/support/drive-cli) for download and installation instructions.
|
||||
2. Authenticate by running `proton-drive auth login`.
|
||||
3. Ensure `proton-drive` is in your `PATH`.
|
||||
3. Ensure `proton-drive` is in your PATH.
|
||||
|
||||
If the CLI is not installed or you are not logged in, dirmd will report the error to stderr and exit with a non-zero code. Local files are always written successfully before the upload is attempted.
|
||||
|
||||
@@ -114,6 +220,12 @@ For full documentation on the Proton Drive CLI, including installation, authenti
|
||||
# Vertical slices with overwrite
|
||||
dirmd -o docs.md ~/repos/project -f --vertical-slices
|
||||
|
||||
# Skip frontend and omit tests
|
||||
dirmd -o docs.md ~/repos/project -f --skip-frontend --omit-test
|
||||
|
||||
# Frontend only with test omission
|
||||
dirmd -o docs.md ~/repos/project -f --frontend-only --omit-test
|
||||
|
||||
# Generate, then upload to default remote location
|
||||
dirmd -o docs.md ~/repos/project --proton-drive
|
||||
|
||||
@@ -128,15 +240,18 @@ For full documentation on the Proton Drive CLI, including installation, authenti
|
||||
Produces markdown with:
|
||||
|
||||
1. Absolute path as H1 header with `[ROOT]` or `[SLICE]` marker
|
||||
2. ASCII directory tree (indented with `+-` and `|` characters)
|
||||
3. Vertical slices index table (sliced mode only)
|
||||
4. All text files with content in fenced code blocks
|
||||
5. Explicit `--- FILE: ---` and `--- END FILE ---` delimiters with byte and line counts
|
||||
6. Language detection from file extensions
|
||||
2. **Timestamp metadata line** (ISO 8601) immediately after the H1 header (only for new files)
|
||||
3. ASCII directory tree (indented with `+-` and `|` characters)
|
||||
4. Vertical slices index table (sliced mode only)
|
||||
5. All text files with content in fenced code blocks
|
||||
6. Explicit `--- FILE: ---` and `--- END FILE ---` delimiters with byte and line counts
|
||||
7. Language detection from file extensions
|
||||
|
||||
Example output structure (non-sliced mode):
|
||||
|
||||
# /home/user/repos/myproject [ROOT]
|
||||
> Generated: 2026-09-02T14:30:00Z
|
||||
> Commit: a1b2c3d (Fix login bug)
|
||||
|
||||
## Directory Tree
|
||||
|
||||
|
||||
+51
-29
@@ -7,10 +7,11 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"dirmd/internal/config"
|
||||
"dirmd/internal/drive"
|
||||
"dirmd/internal/renderer"
|
||||
"dirmd/internal/walker"
|
||||
"git.flo.fo/FLO/dirmd/internal/config"
|
||||
"git.flo.fo/FLO/dirmd/internal/drive"
|
||||
"git.flo.fo/FLO/dirmd/internal/renderer"
|
||||
"git.flo.fo/FLO/dirmd/internal/tui"
|
||||
"git.flo.fo/FLO/dirmd/internal/walker"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -26,26 +27,26 @@ Output is always written to a directory specified by -o.
|
||||
|
||||
Examples:
|
||||
|
||||
# Create new documentation (writes index.md inside ./docs/)
|
||||
# Create new documentation
|
||||
dirmd -o ./docs ~/repos/myproject
|
||||
|
||||
# Overwrite existing output directory
|
||||
# Overwrite existing output directory (deletes all contents)
|
||||
dirmd -o ./docs ~/repos/myproject -f
|
||||
|
||||
# Append a single file
|
||||
dirmd -a -i README.md -o ./docs
|
||||
|
||||
# Vertical slices (root index.md + one markdown per top-level dir)
|
||||
# Vertical slices
|
||||
dirmd -o ./docs ~/repos/myproject --vertical-slices
|
||||
|
||||
# Skip frontend files
|
||||
dirmd -o ./docs ~/repos/myproject --skip-frontend
|
||||
# Include instructions from .dirmd
|
||||
dirmd -o ./docs ~/repos/myproject --instructions
|
||||
|
||||
# Generate and upload to Proton Drive
|
||||
dirmd -o ./docs ~/repos/myproject --proton-drive
|
||||
|
||||
# Generate and upload to a specific remote path
|
||||
dirmd -o ./docs ~/repos/myproject --proton-drive --drive-path /my-files/docs/api`,
|
||||
# Interactive TUI mode
|
||||
dirmd --tui`,
|
||||
RunE: run,
|
||||
}
|
||||
|
||||
@@ -61,6 +62,11 @@ Examples:
|
||||
drivePath string
|
||||
verticalSlices bool
|
||||
skipFrontend bool
|
||||
frontendOnly bool
|
||||
omitTest bool
|
||||
omitMd bool
|
||||
tuiMode bool
|
||||
instructions bool
|
||||
)
|
||||
|
||||
func Execute() {
|
||||
@@ -72,8 +78,9 @@ func Execute() {
|
||||
func init() {
|
||||
rootCmd.PersistentFlags().StringVarP(&outputPath, "output", "o", "", "output directory (required)")
|
||||
rootCmd.Flags().BoolVarP(&appendMode, "append", "a", false, "append to existing index.md")
|
||||
rootCmd.Flags().BoolVarP(&force, "force", "f", false, "overwrite existing output directory")
|
||||
rootCmd.Flags().StringVarP(&singleFile, "input-file", "i", "", "single file input (append mode only)")
|
||||
rootCmd.Flags().BoolVarP(&force, "force", "f", false, "overwrite existing output directory (deletes all contents)")
|
||||
rootCmd.Flags().StringVar(&singleFile, "input-file", "", "single file input (append mode only)")
|
||||
rootCmd.Flags().BoolVarP(&tuiMode, "interactive", "i", false, "launch interactive TUI configuration wizard")
|
||||
rootCmd.Flags().Int64Var(&maxSize, "max-size", config.DefaultMaxSize, "max file size in bytes")
|
||||
rootCmd.Flags().IntVar(&maxDepth, "max-depth", config.DefaultMaxDepth, "max directory recursion depth")
|
||||
rootCmd.Flags().StringArrayVar(&ignorePatterns, "ignore", nil, "additional ignore patterns (glob)")
|
||||
@@ -82,11 +89,21 @@ func init() {
|
||||
rootCmd.Flags().StringVar(&drivePath, "drive-path", "", "full remote directory on Proton Drive (e.g. /my-files/docs/api). Requires --proton-drive")
|
||||
rootCmd.Flags().BoolVar(&verticalSlices, "vertical-slices", false, "split output into root index.md + one markdown per top-level directory")
|
||||
rootCmd.Flags().BoolVar(&skipFrontend, "skip-frontend", false, "omit frontend file types (.html, .css, .js, .ts, .vue, .svelte, etc.)")
|
||||
|
||||
rootCmd.MarkPersistentFlagRequired("output")
|
||||
rootCmd.Flags().BoolVar(&frontendOnly, "frontend-only", false, "include only frontend file types and README.md")
|
||||
rootCmd.Flags().BoolVar(&omitTest, "omit-test", false, "exclude test files (Go *_test.go, Java *Test.java) and strip #[cfg(test)] blocks from Rust")
|
||||
rootCmd.Flags().BoolVar(&omitMd, "omit-md", false, "omit all .md files except README.md")
|
||||
rootCmd.Flags().BoolVar(&instructions, "instructions", false, "include .dirmd instructions file if present in root")
|
||||
}
|
||||
|
||||
func run(cmd *cobra.Command, args []string) error {
|
||||
if tuiMode {
|
||||
return tui.Run()
|
||||
}
|
||||
|
||||
if outputPath == "" {
|
||||
return fmt.Errorf("-o is required (or use --tui for interactive mode)")
|
||||
}
|
||||
|
||||
if drivePath != "" && !protonDrive {
|
||||
return fmt.Errorf("--drive-path requires --proton-drive")
|
||||
}
|
||||
@@ -99,6 +116,10 @@ func run(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("--vertical-slices cannot be used with --input-file")
|
||||
}
|
||||
|
||||
if skipFrontend && frontendOnly {
|
||||
return fmt.Errorf("--skip-frontend and --frontend-only are mutually exclusive")
|
||||
}
|
||||
|
||||
excludedExts := append(config.DefaultExts, extensions...)
|
||||
if skipFrontend {
|
||||
excludedExts = append(excludedExts, config.FrontendExts...)
|
||||
@@ -116,6 +137,10 @@ func run(cmd *cobra.Command, args []string) error {
|
||||
DrivePath: drivePath,
|
||||
VerticalSlices: verticalSlices,
|
||||
SkipFrontend: skipFrontend,
|
||||
FrontendOnly: frontendOnly,
|
||||
OmitTest: omitTest,
|
||||
OmitMd: omitMd,
|
||||
IncludeInstructions: instructions,
|
||||
}
|
||||
|
||||
if singleFile != "" {
|
||||
@@ -142,18 +167,22 @@ func run(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("failed to resolve output path: %w", err)
|
||||
}
|
||||
|
||||
absOutput = filepath.Join(filepath.Dir(absOutput), filepath.Base(absOutput)+"_dirmd")
|
||||
|
||||
if !appendMode {
|
||||
if _, err := os.Stat(absOutput); err == nil && !force {
|
||||
if _, err := os.Stat(absOutput); err == nil {
|
||||
if !force {
|
||||
return fmt.Errorf("output directory %s already exists (use -f to overwrite)", absOutput)
|
||||
}
|
||||
if err := os.RemoveAll(absOutput); err != nil {
|
||||
return fmt.Errorf("failed to remove existing output directory: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(absOutput, 0755); err != nil {
|
||||
return fmt.Errorf("cannot create output directory: %w", err)
|
||||
}
|
||||
|
||||
repoName := filepath.Base(cfg.AbsRoot)
|
||||
fmt.Printf("%s\n", absOutput)
|
||||
|
||||
result, err := walker.Run(cfg)
|
||||
@@ -165,12 +194,13 @@ func run(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("no readable text files found in %s", cfg.AbsRoot)
|
||||
}
|
||||
|
||||
indexBasename := "index_" + repoName + ".md"
|
||||
|
||||
if verticalSlices {
|
||||
generated, err := renderer.WriteSlices(result, absOutput)
|
||||
generated, err := renderer.WriteSlices(result, absOutput, repoName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "generated %d files in %s\n", len(generated), absOutput)
|
||||
|
||||
if protonDrive {
|
||||
@@ -178,27 +208,22 @@ func run(cmd *cobra.Command, args []string) error {
|
||||
if remoteDir == "" {
|
||||
remoteDir = "/my-files/md/" + filepath.Base(absOutput)
|
||||
}
|
||||
|
||||
if err := drive.CheckAuth(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
fmt.Fprintln(os.Stderr, "local files were written successfully, but upload was skipped")
|
||||
return err
|
||||
}
|
||||
|
||||
if err := drive.UploadDir(absOutput, remoteDir); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
fmt.Fprintln(os.Stderr, "local files were written successfully, but upload failed")
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "uploaded %s to %s on Proton Drive\n", absOutput, remoteDir)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
indexPath := filepath.Join(absOutput, "index.md")
|
||||
|
||||
indexPath := filepath.Join(absOutput, indexBasename)
|
||||
if err := renderer.Write(result, indexPath, appendMode, cfg.SingleFile); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -210,19 +235,16 @@ func run(cmd *cobra.Command, args []string) error {
|
||||
if remoteDir == "" {
|
||||
remoteDir = "/my-files/md/" + filepath.Base(absOutput)
|
||||
}
|
||||
|
||||
if err := drive.CheckAuth(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
fmt.Fprintln(os.Stderr, "local file was written successfully, but upload was skipped")
|
||||
return err
|
||||
}
|
||||
|
||||
if err := drive.UploadDir(absOutput, remoteDir); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
fmt.Fprintln(os.Stderr, "local file was written successfully, but upload failed")
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "uploaded %s to %s on Proton Drive\n", absOutput, remoteDir)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# Automating sending files to proton drive on commits
|
||||
|
||||
If you add this to any repos, and have dirmd in path and logged in on proton drive cli, then after each commit the newest code is pushed to proton drive, as vertical slices. If you do not want vertical slices of the code omit --vertical-slices. Also check out the other flags of dirmd if required.
|
||||
|
||||
## repos-root/.git/hooks/post-commit
|
||||
|
||||
create this file
|
||||
|
||||
```sh
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if ! command -v dirmd &>/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
REPO_NAME=$(basename "$REPO_ROOT")
|
||||
|
||||
dirmd -o "/tmp/${REPO_NAME}_new" "$REPO_ROOT" -f --vertical-slices --proton-drive
|
||||
```
|
||||
|
||||
and then make it executable with something like `chmod +x .git/hooks/post-commit`
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,10 +1,37 @@
|
||||
module dirmd
|
||||
module git.flo.fo/FLO/dirmd
|
||||
|
||||
go 1.26.4
|
||||
|
||||
require github.com/spf13/cobra v1.10.2
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.2.1
|
||||
charm.land/huh/v2 v2.0.3
|
||||
github.com/spf13/cobra v1.10.2
|
||||
)
|
||||
|
||||
require (
|
||||
charm.land/bubbletea/v2 v2.0.9 // indirect
|
||||
charm.land/lipgloss/v2 v2.0.6 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/catppuccin/go v0.2.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.8 // indirect
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.27 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,10 +1,76 @@
|
||||
charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
|
||||
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
|
||||
charm.land/bubbletea/v2 v2.0.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8=
|
||||
charm.land/bubbletea/v2 v2.0.9/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
|
||||
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
|
||||
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
|
||||
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
|
||||
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
|
||||
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro=
|
||||
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
|
||||
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
|
||||
github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
|
||||
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||
github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw=
|
||||
github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0=
|
||||
github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
+28
-27
@@ -15,16 +15,9 @@ const (
|
||||
)
|
||||
|
||||
var DefaultIgnores = []string{
|
||||
".git",
|
||||
"node_modules",
|
||||
"vendor",
|
||||
"bin",
|
||||
"target",
|
||||
"build",
|
||||
"dist",
|
||||
".idea",
|
||||
".vscode",
|
||||
"dependency-reduced-pom.xml",
|
||||
".git", "node_modules", "vendor", "bin", "go.mod", "go.sum",
|
||||
"target", "build", "dist", "Cargo.lock", ".idea", ".vscode",
|
||||
"dependency-reduced-pom.xml", "__pycache__", "venv", "env", "uv.lock",
|
||||
}
|
||||
|
||||
var DefaultExts = []string{
|
||||
@@ -36,10 +29,8 @@ var DefaultExts = []string{
|
||||
".pdf", ".doc", ".docx", ".xls", ".xlsx",
|
||||
".pem", ".key", ".cert",
|
||||
".db", ".sqlite", ".sqlite3",
|
||||
".class",
|
||||
".jar",
|
||||
".war",
|
||||
".ear",
|
||||
".class", ".jar", ".war", ".ear",
|
||||
".pyc", ".pyo",
|
||||
}
|
||||
|
||||
var FrontendExts = []string{
|
||||
@@ -52,14 +43,8 @@ var FrontendExts = []string{
|
||||
|
||||
type stringSlice []string
|
||||
|
||||
func (s *stringSlice) String() string {
|
||||
return strings.Join(*s, ", ")
|
||||
}
|
||||
|
||||
func (s *stringSlice) Set(v string) error {
|
||||
*s = append(*s, v)
|
||||
return nil
|
||||
}
|
||||
func (s *stringSlice) String() string { return strings.Join(*s, ", ") }
|
||||
func (s *stringSlice) Set(v string) error { *s = append(*s, v); return nil }
|
||||
|
||||
type Config struct {
|
||||
InputPath string
|
||||
@@ -76,6 +61,10 @@ type Config struct {
|
||||
DrivePath string
|
||||
VerticalSlices bool
|
||||
SkipFrontend bool
|
||||
FrontendOnly bool
|
||||
OmitTest bool
|
||||
OmitMd bool
|
||||
IncludeInstructions bool
|
||||
}
|
||||
|
||||
func PrintUsage() {
|
||||
@@ -101,6 +90,10 @@ func Parse() (*Config, error) {
|
||||
drivePath string
|
||||
verticalSlices bool
|
||||
skipFrontend bool
|
||||
frontendOnly bool
|
||||
omitTest bool
|
||||
omitMd bool
|
||||
includeInstructions bool
|
||||
)
|
||||
|
||||
flag.StringVar(&input, "i", "", "single file input (append mode only)")
|
||||
@@ -113,9 +106,13 @@ func Parse() (*Config, error) {
|
||||
flag.Var(&exts, "extensions", "additional file extensions to skip (repeatable)")
|
||||
flag.BoolVar(&showVer, "version", false, "print version and exit")
|
||||
flag.BoolVar(&protonDrive, "proton-drive", false, "upload output to Proton Drive after writing locally")
|
||||
flag.StringVar(&drivePath, "drive-path", "", "full remote path on Proton Drive (requires --proton-drive)")
|
||||
flag.StringVar(&drivePath, "drive-path", "", "full remote path on Proton Drive (requires --proton-drive")
|
||||
flag.BoolVar(&verticalSlices, "vertical-slices", false, "split output into root file + one markdown per top-level directory")
|
||||
flag.BoolVar(&skipFrontend, "skip-frontend", false, "omit frontend file types (.html, .css, .js, .ts, .vue, .svelte, etc.)")
|
||||
flag.BoolVar(&skipFrontend, "skip-frontend", false, "omit frontend file types")
|
||||
flag.BoolVar(&frontendOnly, "frontend-only", false, "include only frontend file types and README.md")
|
||||
flag.BoolVar(&omitTest, "omit-test", false, "exclude test files and strip #[cfg(test)] blocks")
|
||||
flag.BoolVar(&omitMd, "omit-md", false, "omit all .md files except README.md")
|
||||
flag.BoolVar(&includeInstructions, "instructions", false, "include .dirmd instructions file if present in root")
|
||||
|
||||
flag.Usage = PrintUsage
|
||||
flag.Parse()
|
||||
@@ -128,18 +125,18 @@ func Parse() (*Config, error) {
|
||||
if output == "" {
|
||||
return nil, fmt.Errorf("-o is required")
|
||||
}
|
||||
|
||||
if drivePath != "" && !protonDrive {
|
||||
return nil, fmt.Errorf("--drive-path requires --proton-drive")
|
||||
}
|
||||
|
||||
if verticalSlices && appendMd {
|
||||
return nil, fmt.Errorf("--vertical-slices cannot be used with --append")
|
||||
}
|
||||
|
||||
if verticalSlices && input != "" {
|
||||
return nil, fmt.Errorf("--vertical-slices cannot be used with --input-file")
|
||||
}
|
||||
if skipFrontend && frontendOnly {
|
||||
return nil, fmt.Errorf("--skip-frontend and --frontend-only are mutually exclusive")
|
||||
}
|
||||
|
||||
excludedExts := append(DefaultExts, exts...)
|
||||
if skipFrontend {
|
||||
@@ -158,6 +155,10 @@ func Parse() (*Config, error) {
|
||||
DrivePath: drivePath,
|
||||
VerticalSlices: verticalSlices,
|
||||
SkipFrontend: skipFrontend,
|
||||
FrontendOnly: frontendOnly,
|
||||
OmitTest: omitTest,
|
||||
OmitMd: omitMd,
|
||||
IncludeInstructions: includeInstructions,
|
||||
}
|
||||
|
||||
if input != "" {
|
||||
|
||||
+17
-11
@@ -30,7 +30,6 @@ func CheckAuth() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureRemoteParent creates the parent remote directory if it doesn't exist.
|
||||
func EnsureRemoteParent(remoteDir string) error {
|
||||
binary, err := Lookup()
|
||||
if err != nil {
|
||||
@@ -38,37 +37,45 @@ func EnsureRemoteParent(remoteDir string) error {
|
||||
}
|
||||
|
||||
parentDir := filepath.Dir(remoteDir)
|
||||
|
||||
// Try create-folder on the grandparent for the parent
|
||||
grandparent := filepath.Dir(parentDir)
|
||||
parentName := filepath.Base(parentDir)
|
||||
|
||||
cmd := exec.Command(binary, "filesystem", "create-folder", grandparent, parentName)
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
cmd.Run() // Ignore errors — folder may already exist
|
||||
cmd.Run()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UploadDir uploads an entire local directory to Proton Drive.
|
||||
// The local directory's basename becomes the remote directory name.
|
||||
func CleanRemote(remoteDir string) {
|
||||
binary, err := Lookup()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
cmd := exec.Command(binary, "filesystem", "delete", remoteDir)
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
cmd.Run()
|
||||
}
|
||||
|
||||
func UploadDir(localDir, remoteDir string) error {
|
||||
binary, err := Lookup()
|
||||
if err != nil {
|
||||
return fmt.Errorf("proton-drive not found in PATH: %w", err)
|
||||
}
|
||||
|
||||
// Ensure parent of remote dir exists
|
||||
if err := EnsureRemoteParent(remoteDir); err != nil {
|
||||
return fmt.Errorf("cannot prepare remote directory: %w", err)
|
||||
}
|
||||
|
||||
// Upload to parent — the local dir's basename creates the remote dir
|
||||
CleanRemote(remoteDir)
|
||||
|
||||
remoteParent := filepath.Dir(remoteDir)
|
||||
|
||||
cmd := exec.Command(binary, "filesystem", "upload", localDir, remoteParent,
|
||||
"--conflict-strategy", "replace")
|
||||
"--folder-conflict-strategy", "replace", "--file-conflict-strategy", "replace")
|
||||
|
||||
var stderr strings.Builder
|
||||
cmd.Stderr = &stderr
|
||||
@@ -80,7 +87,6 @@ func UploadDir(localDir, remoteDir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Upload uploads a single local file to a remote path on Proton Drive.
|
||||
func Upload(localPath, remotePath string) error {
|
||||
binary, err := Lookup()
|
||||
if err != nil {
|
||||
@@ -111,7 +117,7 @@ func Upload(localPath, remotePath string) error {
|
||||
|
||||
func runUpload(binary, localPath, remoteDir string) error {
|
||||
cmd := exec.Command(binary, "filesystem", "upload", localPath, remoteDir,
|
||||
"--conflict-strategy", "replace")
|
||||
"--folder-conflict-strategy", "replace", "--file-conflict-strategy", "replace")
|
||||
|
||||
var stderr strings.Builder
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
@@ -4,12 +4,17 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.flo.fo/FLO/dirmd/internal/config"
|
||||
)
|
||||
|
||||
type Filter struct {
|
||||
MaxSize int64
|
||||
Extensions []string
|
||||
Ignores []string
|
||||
OmitTest bool
|
||||
FrontendOnly bool
|
||||
OmitMd bool
|
||||
}
|
||||
|
||||
func (f *Filter) ShouldSkipDir(name string) bool {
|
||||
@@ -37,6 +42,22 @@ func (f *Filter) ShouldSkipFile(path, name string, info os.FileInfo) bool {
|
||||
}
|
||||
}
|
||||
|
||||
if info.Size() > f.MaxSize {
|
||||
return true
|
||||
}
|
||||
|
||||
if f.OmitMd && isNonReadmeMarkdown(path, name) {
|
||||
return true
|
||||
}
|
||||
|
||||
if f.FrontendOnly && !isFrontendOrReadme(path, name) {
|
||||
return true
|
||||
}
|
||||
|
||||
if f.OmitTest && isTestFile(path, name) {
|
||||
return true
|
||||
}
|
||||
|
||||
ext := filepath.Ext(path)
|
||||
for _, bad := range f.Extensions {
|
||||
if strings.EqualFold(ext, bad) {
|
||||
@@ -44,9 +65,51 @@ func (f *Filter) ShouldSkipFile(path, name string, info os.FileInfo) bool {
|
||||
}
|
||||
}
|
||||
|
||||
if info.Size() > f.MaxSize {
|
||||
return false
|
||||
}
|
||||
|
||||
func isNonReadmeMarkdown(path, name string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if ext != ".md" && ext != ".markdown" {
|
||||
return false
|
||||
}
|
||||
return !strings.EqualFold(name, "README.md")
|
||||
}
|
||||
|
||||
func isFrontendOrReadme(path, name string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
for _, fe := range config.FrontendExts {
|
||||
if strings.EqualFold(ext, fe) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return strings.EqualFold(name, "README.md")
|
||||
}
|
||||
|
||||
func isTestFile(path, name string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
|
||||
switch ext {
|
||||
case ".go":
|
||||
return strings.HasSuffix(strings.ToLower(name), "_test.go")
|
||||
case ".java":
|
||||
base := strings.TrimSuffix(name, filepath.Ext(name))
|
||||
if strings.HasSuffix(base, "Test") || strings.HasSuffix(base, "Tests") {
|
||||
return true
|
||||
}
|
||||
slashPath := filepath.ToSlash(path)
|
||||
return strings.Contains(slashPath, "/src/test/")
|
||||
case ".py":
|
||||
lower := strings.ToLower(name)
|
||||
if strings.HasPrefix(lower, "test_") || strings.HasSuffix(lower, "_test.py") {
|
||||
return true
|
||||
}
|
||||
if strings.EqualFold(name, "conftest.py") {
|
||||
return true
|
||||
}
|
||||
slashPath := filepath.ToSlash(path)
|
||||
return strings.Contains(slashPath, "/tests/")
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type CommitInfo struct {
|
||||
ShortSHA string
|
||||
Message string
|
||||
}
|
||||
|
||||
func GetCommitInfo(rootDir string) *CommitInfo {
|
||||
cmd := exec.Command("git", "-C", rootDir, "log", "-1", "--format=%h %s")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
parts := strings.SplitN(strings.TrimSpace(string(output)), " ", 2)
|
||||
if len(parts) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &CommitInfo{
|
||||
ShortSHA: parts[0],
|
||||
Message: parts[1],
|
||||
}
|
||||
}
|
||||
|
||||
func FormatCommitLine(info *CommitInfo) string {
|
||||
if info == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("> Commit: %s (%s)\n", info.ShortSHA, info.Message)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package instructions
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const FileName = ".dirmd"
|
||||
|
||||
func Read(rootDir string) (string, error) {
|
||||
path := filepath.Join(rootDir, FileName)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func Format(content string) string {
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
lines := strings.Split(content, "\n")
|
||||
var sb strings.Builder
|
||||
sb.WriteString("> Instructions:\n")
|
||||
for _, line := range lines {
|
||||
sb.WriteString("> ")
|
||||
sb.WriteString(line)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
return sb.String()
|
||||
}
|
||||
+116
-57
@@ -6,15 +6,17 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"dirmd/internal/tree"
|
||||
"dirmd/internal/walker"
|
||||
"git.flo.fo/FLO/dirmd/internal/git"
|
||||
"git.flo.fo/FLO/dirmd/internal/instructions"
|
||||
"git.flo.fo/FLO/dirmd/internal/tree"
|
||||
"git.flo.fo/FLO/dirmd/internal/walker"
|
||||
)
|
||||
|
||||
const fenceChars = "~~~"
|
||||
|
||||
// Write writes a single markdown file with tree and file contents.
|
||||
func Write(result *walker.Result, outputPath string, appendMode bool, singleFile bool) error {
|
||||
flags := os.O_CREATE | os.O_WRONLY
|
||||
if appendMode {
|
||||
@@ -36,7 +38,18 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile
|
||||
h1Path = filepath.Join(result.AbsRoot, result.Entries[0].RelPath)
|
||||
}
|
||||
|
||||
fmt.Fprintf(&sb, "# %s [ROOT]\n\n", h1Path)
|
||||
fmt.Fprintf(&sb, "# %s [ROOT]\n", h1Path)
|
||||
|
||||
if !appendMode {
|
||||
fmt.Fprintf(&sb, "> Generated: %s\n", time.Now().Format(time.RFC3339))
|
||||
sb.WriteString(git.FormatCommitLine(result.Commit))
|
||||
|
||||
if result.Instructions != "" {
|
||||
sb.WriteString(instructions.Format(result.Instructions))
|
||||
} else {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
if !singleFile {
|
||||
root := tree.Build(result.Entries)
|
||||
@@ -54,8 +67,7 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile
|
||||
|
||||
for _, entry := range result.Entries {
|
||||
fullPath := filepath.Join(result.AbsRoot, entry.RelPath)
|
||||
|
||||
content, err := readFile(fullPath)
|
||||
content, err := readFileForOutput(fullPath, result.OmitTest)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err)
|
||||
continue
|
||||
@@ -82,9 +94,7 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile
|
||||
return err
|
||||
}
|
||||
|
||||
// WriteSlices generates a root markdown file plus one markdown per top-level directory.
|
||||
// Returns the list of generated file paths.
|
||||
func WriteSlices(result *walker.Result, outputDir string) ([]string, error) {
|
||||
func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, error) {
|
||||
entries := result.Entries
|
||||
if len(entries) == 0 {
|
||||
return nil, fmt.Errorf("no entries to write")
|
||||
@@ -108,16 +118,14 @@ func WriteSlices(result *walker.Result, outputDir string) ([]string, error) {
|
||||
}
|
||||
|
||||
var generated []string
|
||||
|
||||
// Write root file as index.md
|
||||
rootPath := filepath.Join(outputDir, "index.md")
|
||||
indexBasename := "index_" + repoName + ".md"
|
||||
rootPath := filepath.Join(outputDir, indexBasename)
|
||||
generated = append(generated, rootPath)
|
||||
|
||||
if err := writeRootSlice(rootPath, absRoot, rootName, rootFiles, topLevelDirs); err != nil {
|
||||
if err := writeRootSlice(rootPath, absRoot, rootName, repoName, rootFiles, topLevelDirs, result.OmitTest, result.Commit, result.Instructions); err != nil {
|
||||
return nil, fmt.Errorf("failed to write root slice: %w", err)
|
||||
}
|
||||
|
||||
// Write each top-level directory slice into a mirroring subdirectory
|
||||
var dirNames []string
|
||||
for k := range topLevelDirs {
|
||||
dirNames = append(dirNames, k)
|
||||
@@ -125,7 +133,7 @@ func WriteSlices(result *walker.Result, outputDir string) ([]string, error) {
|
||||
sort.Strings(dirNames)
|
||||
|
||||
for _, dirName := range dirNames {
|
||||
sliceName := strings.TrimSuffix(dirName, "/") + ".md"
|
||||
sliceName := strings.TrimSuffix(dirName, "/") + "_" + repoName + ".md"
|
||||
sliceDir := filepath.Join(outputDir, dirName)
|
||||
if err := os.MkdirAll(sliceDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("cannot create slice directory %s: %w", sliceDir, err)
|
||||
@@ -136,21 +144,17 @@ func WriteSlices(result *walker.Result, outputDir string) ([]string, error) {
|
||||
absDir := filepath.Join(absRoot, dirName)
|
||||
dirEntries := topLevelDirs[dirName]
|
||||
|
||||
// Strip dirName prefix for tree building only
|
||||
treeEntries := make([]walker.Entry, len(dirEntries))
|
||||
for i, e := range dirEntries {
|
||||
sepIdx := strings.Index(e.RelPath, string(filepath.Separator))
|
||||
if sepIdx >= 0 {
|
||||
treeEntries[i] = walker.Entry{
|
||||
RelPath: e.RelPath[sepIdx+1:],
|
||||
Size: e.Size,
|
||||
}
|
||||
treeEntries[i] = walker.Entry{RelPath: e.RelPath[sepIdx+1:], Size: e.Size}
|
||||
} else {
|
||||
treeEntries[i] = e
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeDirSlice(slicePath, absDir, absRoot, dirName, treeEntries, dirEntries, "index.md"); err != nil {
|
||||
if err := writeDirSlice(slicePath, absDir, absRoot, dirName, treeEntries, dirEntries, indexBasename, result.OmitTest, result.Commit, result.Instructions); err != nil {
|
||||
return nil, fmt.Errorf("failed to write slice %s: %w", sliceName, err)
|
||||
}
|
||||
}
|
||||
@@ -158,7 +162,7 @@ func WriteSlices(result *walker.Result, outputDir string) ([]string, error) {
|
||||
return generated, nil
|
||||
}
|
||||
|
||||
func writeRootSlice(path, absRoot, rootName string, rootFiles []walker.Entry, topLevelDirs map[string][]walker.Entry) error {
|
||||
func writeRootSlice(path, absRoot, rootName, repoName string, rootFiles []walker.Entry, topLevelDirs map[string][]walker.Entry, omitTest bool, commit *git.CommitInfo, instContent string) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create root file: %w", err)
|
||||
@@ -166,17 +170,21 @@ func writeRootSlice(path, absRoot, rootName string, rootFiles []walker.Entry, to
|
||||
defer f.Close()
|
||||
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "# %s [ROOT]\n", absRoot)
|
||||
fmt.Fprintf(&sb, "> Generated: %s\n", time.Now().Format(time.RFC3339))
|
||||
sb.WriteString(git.FormatCommitLine(commit))
|
||||
if instContent != "" {
|
||||
sb.WriteString(instructions.Format(instContent))
|
||||
} else {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
fmt.Fprintf(&sb, "# %s [ROOT]\n\n", absRoot)
|
||||
|
||||
// Build combined tree with references
|
||||
treeLines := buildTreeWithRefs(rootName, topLevelDirs, rootFiles)
|
||||
treeLines := buildTreeWithRefs(rootName, repoName, topLevelDirs, rootFiles)
|
||||
sb.WriteString("## Directory Tree\n\n")
|
||||
sb.WriteString(fenceChars + "\n")
|
||||
sb.WriteString(treeLines)
|
||||
sb.WriteString(fenceChars + "\n\n")
|
||||
|
||||
// Vertical slices index for LLM navigation
|
||||
sb.WriteString("## Vertical Slices Index\n\n")
|
||||
sb.WriteString("These files contain the full contents of each top-level directory.\n")
|
||||
sb.WriteString("Read the relevant slice file for detailed source code.\n\n")
|
||||
@@ -196,36 +204,29 @@ func writeRootSlice(path, absRoot, rootName string, rootFiles []walker.Entry, to
|
||||
for _, e := range entries {
|
||||
totalBytes += e.Size
|
||||
}
|
||||
sliceFile := filepath.Join(dirName, strings.TrimSuffix(dirName, "/")+".md")
|
||||
sliceFile := filepath.Join(dirName, strings.TrimSuffix(dirName, "/")+"_"+repoName+".md")
|
||||
fmt.Fprintf(&sb, "| %s | %s/ | %d | %d |\n", sliceFile, dirName, fileCount, totalBytes)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
if len(rootFiles) > 0 {
|
||||
sb.WriteString("## Root Contents\n\n")
|
||||
|
||||
sort.Slice(rootFiles, func(i, j int) bool {
|
||||
return rootFiles[i].RelPath < rootFiles[j].RelPath
|
||||
})
|
||||
|
||||
sort.Slice(rootFiles, func(i, j int) bool { return rootFiles[i].RelPath < rootFiles[j].RelPath })
|
||||
for _, entry := range rootFiles {
|
||||
fullPath := filepath.Join(absRoot, entry.RelPath)
|
||||
content, err := readFile(fullPath)
|
||||
content, err := readFileForOutput(fullPath, omitTest)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
language := extToLang(filepath.Ext(entry.RelPath))
|
||||
lines := countLines(content)
|
||||
|
||||
fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", entry.RelPath, entry.Size, lines)
|
||||
fmt.Fprintf(&sb, "%s", fenceChars)
|
||||
if language != "" {
|
||||
fmt.Fprintf(&sb, "%s", language)
|
||||
}
|
||||
fmt.Fprintf(&sb, "\n")
|
||||
fmt.Fprintf(&sb, "%s", content)
|
||||
fmt.Fprintf(&sb, "\n%s", content)
|
||||
if !strings.HasSuffix(content, "\n") {
|
||||
fmt.Fprintf(&sb, "\n")
|
||||
}
|
||||
@@ -237,7 +238,7 @@ func writeRootSlice(path, absRoot, rootName string, rootFiles []walker.Entry, to
|
||||
return err
|
||||
}
|
||||
|
||||
func writeDirSlice(path, absDir, absRoot, dirName string, treeEntries, fileEntries []walker.Entry, rootFileName string) error {
|
||||
func writeDirSlice(path, absDir, absRoot, dirName string, treeEntries, fileEntries []walker.Entry, rootFileName string, omitTest bool, commit *git.CommitInfo, instContent string) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create slice file: %w", err)
|
||||
@@ -245,42 +246,40 @@ func writeDirSlice(path, absDir, absRoot, dirName string, treeEntries, fileEntri
|
||||
defer f.Close()
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
fmt.Fprintf(&sb, "# %s [SLICE]\n\n", absDir)
|
||||
fmt.Fprintf(&sb, "# %s [SLICE]\n", absDir)
|
||||
fmt.Fprintf(&sb, "> Generated: %s\n", time.Now().Format(time.RFC3339))
|
||||
sb.WriteString(git.FormatCommitLine(commit))
|
||||
if instContent != "" {
|
||||
sb.WriteString(instructions.Format(instContent))
|
||||
} else {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
fmt.Fprintf(&sb, "Root document: %s\n\n", rootFileName)
|
||||
|
||||
sb.WriteString("## Directory Tree\n\n")
|
||||
sb.WriteString(fenceChars + "\n")
|
||||
|
||||
sliceTree := buildTreeFromEntries(dirName, treeEntries)
|
||||
sb.WriteString(sliceTree)
|
||||
sb.WriteString(fenceChars + "\n\n")
|
||||
|
||||
if len(fileEntries) > 0 {
|
||||
sb.WriteString("## Contents\n\n")
|
||||
|
||||
sort.Slice(fileEntries, func(i, j int) bool {
|
||||
return fileEntries[i].RelPath < fileEntries[j].RelPath
|
||||
})
|
||||
|
||||
sort.Slice(fileEntries, func(i, j int) bool { return fileEntries[i].RelPath < fileEntries[j].RelPath })
|
||||
for _, entry := range fileEntries {
|
||||
fullPath := filepath.Join(absRoot, entry.RelPath)
|
||||
content, err := readFile(fullPath)
|
||||
content, err := readFileForOutput(fullPath, omitTest)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
language := extToLang(filepath.Ext(entry.RelPath))
|
||||
lines := countLines(content)
|
||||
|
||||
fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", entry.RelPath, entry.Size, lines)
|
||||
fmt.Fprintf(&sb, "%s", fenceChars)
|
||||
if language != "" {
|
||||
fmt.Fprintf(&sb, "%s", language)
|
||||
}
|
||||
fmt.Fprintf(&sb, "\n")
|
||||
fmt.Fprintf(&sb, "%s", content)
|
||||
fmt.Fprintf(&sb, "\n%s", content)
|
||||
if !strings.HasSuffix(content, "\n") {
|
||||
fmt.Fprintf(&sb, "\n")
|
||||
}
|
||||
@@ -292,24 +291,21 @@ func writeDirSlice(path, absDir, absRoot, dirName string, treeEntries, fileEntri
|
||||
return err
|
||||
}
|
||||
|
||||
func buildTreeWithRefs(rootName string, topLevelDirs map[string][]walker.Entry, rootFiles []walker.Entry) string {
|
||||
func buildTreeWithRefs(rootName, repoName string, topLevelDirs map[string][]walker.Entry, rootFiles []walker.Entry) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(rootName)
|
||||
sb.WriteString("/\n")
|
||||
|
||||
// Collect and sort directory names
|
||||
var dirNames []string
|
||||
for k := range topLevelDirs {
|
||||
dirNames = append(dirNames, k)
|
||||
}
|
||||
sort.Strings(dirNames)
|
||||
|
||||
// Sort root files
|
||||
sort.Slice(rootFiles, func(i, j int) bool {
|
||||
return rootFiles[i].RelPath < rootFiles[j].RelPath
|
||||
})
|
||||
|
||||
// Merge directories and files into a single sorted list for display
|
||||
type treeItem struct {
|
||||
name string
|
||||
isDir bool
|
||||
@@ -318,10 +314,11 @@ func buildTreeWithRefs(rootName string, topLevelDirs map[string][]walker.Entry,
|
||||
|
||||
items := make([]treeItem, 0, len(dirNames)+len(rootFiles))
|
||||
for _, d := range dirNames {
|
||||
sliceRef := filepath.Join(d, strings.TrimSuffix(d, "/")+"_"+repoName+".md")
|
||||
items = append(items, treeItem{
|
||||
name: d,
|
||||
isDir: true,
|
||||
sliceRef: filepath.Join(d, strings.TrimSuffix(d, "/")+".md"),
|
||||
sliceRef: sliceRef,
|
||||
})
|
||||
}
|
||||
for _, rf := range rootFiles {
|
||||
@@ -439,6 +436,68 @@ func readFile(path string) (string, error) {
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func readFileForOutput(fullPath string, omitTest bool) (string, error) {
|
||||
content, err := readFile(fullPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if omitTest && strings.EqualFold(filepath.Ext(fullPath), ".rs") {
|
||||
content = stripTestBlocks(content)
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func stripTestBlocks(content string) string {
|
||||
lines := strings.Split(content, "\n")
|
||||
var result []string
|
||||
|
||||
i := 0
|
||||
for i < len(lines) {
|
||||
line := lines[i]
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
if isCfgTestAttr(trimmed) {
|
||||
braceDepth := 0
|
||||
foundBrace := false
|
||||
|
||||
for i < len(lines) {
|
||||
line := lines[i]
|
||||
|
||||
if !foundBrace {
|
||||
if strings.Contains(line, "{") {
|
||||
braceDepth += strings.Count(line, "{") - strings.Count(line, "}")
|
||||
foundBrace = true
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
braceDepth += strings.Count(line, "{") - strings.Count(line, "}")
|
||||
i++
|
||||
|
||||
if braceDepth <= 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
result = append(result, line)
|
||||
i++
|
||||
}
|
||||
|
||||
if len(result) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(result, "\n")
|
||||
}
|
||||
|
||||
func isCfgTestAttr(line string) bool {
|
||||
return strings.Contains(line, "#[cfg(test)]") ||
|
||||
strings.Contains(line, "#[cfg(all(test") ||
|
||||
strings.Contains(line, "#[cfg(any(test")
|
||||
}
|
||||
|
||||
func extToLang(ext string) string {
|
||||
mapping := map[string]string{
|
||||
".go": "go",
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"dirmd/internal/walker"
|
||||
"git.flo.fo/FLO/dirmd/internal/walker"
|
||||
)
|
||||
|
||||
type Node struct {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.flo.fo/FLO/dirmd/internal/config"
|
||||
"git.flo.fo/FLO/dirmd/internal/drive"
|
||||
"git.flo.fo/FLO/dirmd/internal/renderer"
|
||||
"git.flo.fo/FLO/dirmd/internal/walker"
|
||||
)
|
||||
|
||||
type GenerateResult struct {
|
||||
Files []string
|
||||
Uploaded bool
|
||||
Error error
|
||||
}
|
||||
|
||||
func Generate(cfg *config.Config) GenerateResult {
|
||||
result := GenerateResult{}
|
||||
|
||||
abs, err := filepath.Abs(cfg.InputPath)
|
||||
if err != nil {
|
||||
result.Error = fmt.Errorf("resolve input path: %w", err)
|
||||
return result
|
||||
}
|
||||
cfg.AbsRoot = abs
|
||||
|
||||
absOutput, err := filepath.Abs(cfg.OutputPath)
|
||||
if err != nil {
|
||||
result.Error = fmt.Errorf("resolve output path: %w", err)
|
||||
return result
|
||||
}
|
||||
|
||||
if _, err := os.Stat(absOutput); err == nil && !cfg.Force {
|
||||
if info, err2 := os.Stat(absOutput); err2 == nil && !info.IsDir() {
|
||||
result.Error = fmt.Errorf("output %s exists and is not a directory (enable force)", absOutput)
|
||||
return result
|
||||
}
|
||||
result.Error = fmt.Errorf("output %s exists (enable force)", absOutput)
|
||||
return result
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(absOutput, 0755); err != nil {
|
||||
result.Error = fmt.Errorf("create output dir: %w", err)
|
||||
return result
|
||||
}
|
||||
|
||||
cfg.OutputPath = absOutput
|
||||
|
||||
walkResult, err := walker.Run(cfg)
|
||||
if err != nil {
|
||||
result.Error = err
|
||||
return result
|
||||
}
|
||||
|
||||
if len(walkResult.Entries) == 0 {
|
||||
result.Error = fmt.Errorf("no readable files in %s", cfg.AbsRoot)
|
||||
return result
|
||||
}
|
||||
|
||||
repoName := filepath.Base(cfg.AbsRoot)
|
||||
|
||||
if cfg.VerticalSlices {
|
||||
generated, err := renderer.WriteSlices(walkResult, cfg.OutputPath, repoName)
|
||||
if err != nil {
|
||||
result.Error = err
|
||||
return result
|
||||
}
|
||||
result.Files = generated
|
||||
} else {
|
||||
indexPath := filepath.Join(cfg.OutputPath, "index_"+repoName+".md")
|
||||
if err := renderer.Write(walkResult, indexPath, false, false); err != nil {
|
||||
result.Error = err
|
||||
return result
|
||||
}
|
||||
result.Files = []string{indexPath}
|
||||
}
|
||||
|
||||
if cfg.ProtonDrive {
|
||||
remoteDir := cfg.DrivePath
|
||||
if remoteDir == "" {
|
||||
remoteDir = "/my-files/md/" + filepath.Base(cfg.OutputPath)
|
||||
}
|
||||
|
||||
if err := drive.CheckAuth(); err != nil {
|
||||
result.Error = fmt.Errorf("drive auth: %w", err)
|
||||
return result
|
||||
}
|
||||
|
||||
if err := drive.UploadDir(cfg.OutputPath, remoteDir); err != nil {
|
||||
result.Error = fmt.Errorf("drive upload: %w", err)
|
||||
return result
|
||||
}
|
||||
result.Uploaded = true
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
"charm.land/huh/v2"
|
||||
"charm.land/huh/v2/spinner"
|
||||
|
||||
"git.flo.fo/FLO/dirmd/internal/config"
|
||||
)
|
||||
|
||||
func Run() error {
|
||||
clearScreen()
|
||||
|
||||
var (
|
||||
inputDir string
|
||||
outputDir string
|
||||
options []string
|
||||
drivePath string
|
||||
maxDepthStr string
|
||||
maxSizeStr string
|
||||
ignorePatterns string
|
||||
finalConfirm bool
|
||||
)
|
||||
|
||||
inputDir = "."
|
||||
maxDepthStr = "20"
|
||||
maxSizeStr = "512"
|
||||
defOut := defaultOutputDir()
|
||||
outputDir = defOut
|
||||
drivePath = "/my-files/md/"
|
||||
|
||||
km := huh.NewDefaultKeyMap()
|
||||
|
||||
km.Input.Next = key.NewBinding(key.WithKeys("tab", "down"), key.WithHelp("tab/↓", "next"))
|
||||
km.Input.Prev = key.NewBinding(key.WithKeys("shift+tab", "up"), key.WithHelp("shift+tab/↑", "prev"))
|
||||
km.Input.Submit = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit"))
|
||||
|
||||
km.Text.Next = key.NewBinding(key.WithKeys("tab", "down"), key.WithHelp("tab/↓", "next"))
|
||||
km.Text.Prev = key.NewBinding(key.WithKeys("shift+tab", "up"), key.WithHelp("shift+tab/↑", "prev"))
|
||||
km.Text.NewLine = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "new line"))
|
||||
km.Text.Submit = key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("ctrl+s", "submit"))
|
||||
|
||||
km.Confirm.Next = key.NewBinding(key.WithKeys("tab", "down"), key.WithHelp("tab/↓", "next"))
|
||||
km.Confirm.Prev = key.NewBinding(key.WithKeys("shift+tab", "up"), key.WithHelp("shift+tab/↑", "prev"))
|
||||
km.Confirm.Toggle = key.NewBinding(key.WithKeys("left", "right", "h", "l"), key.WithHelp("←/→", "toggle"))
|
||||
km.Confirm.Submit = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm"))
|
||||
km.Confirm.Accept = key.NewBinding(key.WithKeys("y"), key.WithHelp("y", "yes"))
|
||||
km.Confirm.Reject = key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "no"))
|
||||
|
||||
km.MultiSelect.Next = key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next field"))
|
||||
km.MultiSelect.Prev = key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev field"))
|
||||
km.MultiSelect.Up = key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up"))
|
||||
km.MultiSelect.Down = key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down"))
|
||||
km.MultiSelect.Toggle = key.NewBinding(key.WithKeys("enter", " "), key.WithHelp("enter/space", "toggle"))
|
||||
km.MultiSelect.Submit = key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next"))
|
||||
km.MultiSelect.Filter = key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter"))
|
||||
km.MultiSelect.SelectAll = key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "select all"))
|
||||
km.MultiSelect.SelectNone = key.NewBinding(key.WithKeys("A"), key.WithHelp("A", "select none"))
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().Title("Input directory").Value(&inputDir).Placeholder("."),
|
||||
huh.NewInput().Title("Output directory").Value(&outputDir).Placeholder(defOut),
|
||||
huh.NewMultiSelect[string]().
|
||||
Title("Options").
|
||||
Description("↑↓ navigate • enter/space toggle • tab next field").
|
||||
Options(
|
||||
huh.NewOption("Force overwrite", "force"),
|
||||
huh.NewOption("Vertical slices", "vertical-slices"),
|
||||
huh.NewOption("Skip frontend files", "skip-frontend"),
|
||||
huh.NewOption("Frontend only", "frontend-only"),
|
||||
huh.NewOption("Omit test files", "omit-test"),
|
||||
huh.NewOption("Omit non-README markdown", "omit-md"),
|
||||
huh.NewOption("Upload to Proton Drive", "proton-drive"),
|
||||
huh.NewOption("Include .dirmd instructions", "instructions"),
|
||||
).
|
||||
Value(&options).
|
||||
Height(10),
|
||||
huh.NewInput().
|
||||
Title("Proton Drive path").
|
||||
Description("Used if 'Upload to Proton Drive' is toggled").
|
||||
Value(&drivePath).
|
||||
Placeholder("/my-files/md/"),
|
||||
huh.NewInput().Title("Max depth").Value(&maxDepthStr).Placeholder("20"),
|
||||
huh.NewInput().Title("Max size (KB)").Value(&maxSizeStr).Placeholder("512"),
|
||||
huh.NewText().
|
||||
Title("Ignore patterns (one per line)").
|
||||
Description("enter = new line • tab = next field").
|
||||
Value(&ignorePatterns).
|
||||
Placeholder("One pattern per line...").
|
||||
Lines(4),
|
||||
huh.NewConfirm().
|
||||
Title("Confirm and proceed?").
|
||||
Description("←/→ or y/n to choose • enter to confirm").
|
||||
Value(&finalConfirm).
|
||||
Affirmative("Yes").
|
||||
Negative("No"),
|
||||
),
|
||||
).WithWidth(80).WithKeyMap(km)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return fmt.Errorf("form: %w", err)
|
||||
}
|
||||
|
||||
force := containsOption(options, "force")
|
||||
verticalSlices := containsOption(options, "vertical-slices")
|
||||
skipFrontend := containsOption(options, "skip-frontend")
|
||||
frontendOnly := containsOption(options, "frontend-only")
|
||||
omitTest := containsOption(options, "omit-test")
|
||||
omitMd := containsOption(options, "omit-md")
|
||||
protonDrive := containsOption(options, "proton-drive")
|
||||
includeInstructions := containsOption(options, "instructions")
|
||||
|
||||
if skipFrontend && frontendOnly {
|
||||
return fmt.Errorf("--skip-frontend and --frontend-only are mutually exclusive")
|
||||
}
|
||||
|
||||
if !finalConfirm {
|
||||
fmt.Fprintln(os.Stderr, "Cancelled.")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
maxDepth, err := strconv.Atoi(strings.TrimSpace(maxDepthStr))
|
||||
if err != nil || maxDepth <= 0 {
|
||||
maxDepth = 20
|
||||
}
|
||||
|
||||
maxSizeKB, err := strconv.Atoi(strings.TrimSpace(maxSizeStr))
|
||||
if err != nil || maxSizeKB <= 0 {
|
||||
maxSizeKB = 512
|
||||
}
|
||||
|
||||
var ignores []string
|
||||
raw := strings.TrimSpace(ignorePatterns)
|
||||
if raw != "" {
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
ignores = append(ignores, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
excludedExts := append([]string{}, config.DefaultExts...)
|
||||
if skipFrontend {
|
||||
excludedExts = append(excludedExts, config.FrontendExts...)
|
||||
}
|
||||
|
||||
allIgnores := append([]string{}, config.DefaultIgnores...)
|
||||
allIgnores = append(allIgnores, ignores...)
|
||||
|
||||
cfg := &config.Config{
|
||||
InputPath: inputDir,
|
||||
OutputPath: outputDir,
|
||||
Force: force,
|
||||
MaxSize: int64(maxSizeKB) * 1024,
|
||||
MaxDepth: maxDepth,
|
||||
Ignores: allIgnores,
|
||||
Extensions: excludedExts,
|
||||
ProtonDrive: protonDrive,
|
||||
DrivePath: drivePath,
|
||||
VerticalSlices: verticalSlices,
|
||||
SkipFrontend: skipFrontend,
|
||||
FrontendOnly: frontendOnly,
|
||||
OmitTest: omitTest,
|
||||
OmitMd: omitMd,
|
||||
IncludeInstructions: includeInstructions,
|
||||
}
|
||||
|
||||
cmd := buildCommand(cfg)
|
||||
fmt.Fprintf(os.Stderr, "%s\n", cmd)
|
||||
|
||||
var result GenerateResult
|
||||
if err := spinner.New().
|
||||
Title("Generating...").
|
||||
Action(func() {
|
||||
result = Generate(cfg)
|
||||
}).
|
||||
Run(); err != nil {
|
||||
return fmt.Errorf("interrupted: %w", err)
|
||||
}
|
||||
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
|
||||
for _, f := range result.Files {
|
||||
fmt.Fprintf(os.Stdout, " %s\n", f)
|
||||
}
|
||||
if result.Uploaded {
|
||||
fmt.Fprintln(os.Stdout, "Uploaded to Proton Drive")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func clearScreen() {
|
||||
cmd := exec.Command("clear")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Run()
|
||||
}
|
||||
|
||||
func containsOption(opts []string, target string) bool {
|
||||
for _, o := range opts {
|
||||
if o == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func defaultOutputDir() string {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "/tmp/output_dirmd"
|
||||
}
|
||||
base := filepath.Base(cwd)
|
||||
return filepath.Join("/tmp", base+"_dirmd")
|
||||
}
|
||||
|
||||
func buildCommand(cfg *config.Config) string {
|
||||
var parts []string
|
||||
parts = append(parts, "dirmd")
|
||||
parts = append(parts, "-o", cfg.OutputPath)
|
||||
if cfg.Force {
|
||||
parts = append(parts, "-f")
|
||||
}
|
||||
if cfg.VerticalSlices {
|
||||
parts = append(parts, "--vertical-slices")
|
||||
}
|
||||
if cfg.SkipFrontend {
|
||||
parts = append(parts, "--skip-frontend")
|
||||
}
|
||||
if cfg.FrontendOnly {
|
||||
parts = append(parts, "--frontend-only")
|
||||
}
|
||||
if cfg.OmitTest {
|
||||
parts = append(parts, "--omit-test")
|
||||
}
|
||||
if cfg.OmitMd {
|
||||
parts = append(parts, "--omit-md")
|
||||
}
|
||||
if cfg.ProtonDrive {
|
||||
parts = append(parts, "--proton-drive")
|
||||
if cfg.DrivePath != "" {
|
||||
parts = append(parts, "--drive-path", cfg.DrivePath)
|
||||
}
|
||||
}
|
||||
if cfg.MaxDepth != 20 {
|
||||
parts = append(parts, "--max-depth", strconv.Itoa(cfg.MaxDepth))
|
||||
}
|
||||
if cfg.MaxSize != 512*1024 {
|
||||
parts = append(parts, "--max-size", strconv.FormatInt(cfg.MaxSize, 10))
|
||||
}
|
||||
for _, ig := range extraIgnores(cfg) {
|
||||
parts = append(parts, "--ignore", ig)
|
||||
}
|
||||
parts = append(parts, cfg.InputPath)
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func extraIgnores(cfg *config.Config) []string {
|
||||
defaultSet := make(map[string]bool)
|
||||
for _, d := range config.DefaultIgnores {
|
||||
defaultSet[d] = true
|
||||
}
|
||||
var extra []string
|
||||
for _, ig := range cfg.Ignores {
|
||||
if !defaultSet[ig] {
|
||||
extra = append(extra, ig)
|
||||
}
|
||||
}
|
||||
return extra
|
||||
}
|
||||
@@ -7,8 +7,10 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"dirmd/internal/config"
|
||||
"dirmd/internal/filter"
|
||||
"git.flo.fo/FLO/dirmd/internal/config"
|
||||
"git.flo.fo/FLO/dirmd/internal/filter"
|
||||
"git.flo.fo/FLO/dirmd/internal/git"
|
||||
"git.flo.fo/FLO/dirmd/internal/instructions"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
@@ -20,6 +22,9 @@ type Result struct {
|
||||
Entries []Entry
|
||||
AbsRoot string
|
||||
SingleFile bool
|
||||
OmitTest bool
|
||||
Commit *git.CommitInfo
|
||||
Instructions string
|
||||
}
|
||||
|
||||
func Run(cfg *config.Config) (*Result, error) {
|
||||
@@ -43,18 +48,29 @@ func processSingleFile(cfg *config.Config) (*Result, error) {
|
||||
MaxSize: cfg.MaxSize,
|
||||
Extensions: cfg.Extensions,
|
||||
Ignores: cfg.Ignores,
|
||||
OmitTest: cfg.OmitTest,
|
||||
FrontendOnly: cfg.FrontendOnly,
|
||||
OmitMd: cfg.OmitMd,
|
||||
}
|
||||
|
||||
if flt.ShouldSkipFile(cfg.AbsRoot, filepath.Base(cfg.AbsRoot), info) {
|
||||
return nil, fmt.Errorf("file %s filtered out", cfg.AbsRoot)
|
||||
}
|
||||
|
||||
commitInfo := git.GetCommitInfo(filepath.Dir(cfg.AbsRoot))
|
||||
var instContent string
|
||||
if cfg.IncludeInstructions {
|
||||
instContent, _ = instructions.Read(filepath.Dir(cfg.AbsRoot))
|
||||
}
|
||||
|
||||
return &Result{
|
||||
Entries: []Entry{{RelPath: filepath.Base(cfg.AbsRoot), Size: info.Size()}},
|
||||
AbsRoot: filepath.Dir(cfg.AbsRoot),
|
||||
SingleFile: true,
|
||||
OmitTest: cfg.OmitTest,
|
||||
Commit: commitInfo,
|
||||
Instructions: instContent,
|
||||
}, nil
|
||||
|
||||
}
|
||||
|
||||
func walkDirectory(cfg *config.Config) (*Result, error) {
|
||||
@@ -62,9 +78,23 @@ func walkDirectory(cfg *config.Config) (*Result, error) {
|
||||
MaxSize: cfg.MaxSize,
|
||||
Extensions: cfg.Extensions,
|
||||
Ignores: cfg.Ignores,
|
||||
OmitTest: cfg.OmitTest,
|
||||
FrontendOnly: cfg.FrontendOnly,
|
||||
OmitMd: cfg.OmitMd,
|
||||
}
|
||||
|
||||
result := &Result{AbsRoot: cfg.AbsRoot}
|
||||
result := &Result{AbsRoot: cfg.AbsRoot, OmitTest: cfg.OmitTest}
|
||||
|
||||
result.Commit = git.GetCommitInfo(cfg.AbsRoot)
|
||||
|
||||
if cfg.IncludeInstructions {
|
||||
instContent, err := instructions.Read(cfg.AbsRoot)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: could not read .dirmd: %v\n", err)
|
||||
} else {
|
||||
result.Instructions = instContent
|
||||
}
|
||||
}
|
||||
|
||||
err := filepath.WalkDir(cfg.AbsRoot, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user