24 Commits
Author SHA1 Message Date
bartal-lsn f908bffc41 ignore uv.lock 2026-09-03 17:27:00 +01:00
bartal-lsn a1638bd944 add .dirmd and other metadata 2026-09-02 12:01:42 +01:00
bartal-lsn 93d830d500 fix pre delete dirmd set dir bug and implement last git sha and commit# 2026-09-02 11:43:45 +01:00
bartal-lsn 89b7eb78a7 add timestamp to dirmd files 2026-09-02 10:39:55 +01:00
bartal-lsn b1dd808d1e add python support 2026-08-29 21:58:16 +01:00
bartal-lsn d247095e87 fix(drive cli)!: delete remote flag changed 2026-08-28 14:08:58 +01:00
bartal-lsn 66579d068d webm 2026-08-28 01:14:26 +01:00
bartal-lsn 4c14ef2cee webm 2026-08-28 01:10:37 +01:00
bartal-lsn 0f94e50746 webm 2026-08-28 01:07:41 +01:00
bartal-lsn c9aa028b3c webm 2026-08-28 01:01:49 +01:00
bartal-lsn bca125cb92 Update README.md 2026-08-28 00:50:13 +01:00
bartal-lsn 7d73901631 Update README.md 2026-08-28 00:49:26 +01:00
bartal-lsn 6663cb25f8 webm 2026-08-28 00:42:23 +01:00
bartal-lsn 8a28364839 Update README.md 2026-08-28 00:38:34 +01:00
bartal-lsn 894f97820e webm 2026-08-28 00:37:43 +01:00
bartal-lsn fb646a94be Update README.md 2026-08-28 00:32:49 +01:00
bartal-lsn 4bcae1358d Update README.md 2026-08-28 00:32:33 +01:00
bartal-lsn 2428a33046 Update README.md 2026-08-28 00:31:40 +01:00
bartal-lsn a48fe55d4b Update README.md 2026-08-28 00:29:47 +01:00
bartal-lsn 0df346dbf9 Update README.md 2026-08-28 00:29:18 +01:00
bartal-lsn d7f4c6826b Update README.md 2026-08-28 00:27:34 +01:00
bartal-lsn 79be211df3 test 2026-08-28 00:26:00 +01:00
bartal-lsn ba87d6184b test webm 2026-08-28 00:24:20 +01:00
bartal-lsn 180c3f0eba test webm 2026-08-28 00:19:24 +01:00
13 changed files with 316 additions and 190 deletions
+1
View File
@@ -1,3 +1,4 @@
bin/ bin/
.env .env
vendor/ vendor/
.dirmd
+32 -5
View File
@@ -2,6 +2,8 @@
Generate markdown documentation from directory structure. Optimized for LLM ingestion. Generate markdown documentation from directory structure. Optimized for LLM ingestion.
<video src="./docs/videos/dirmd.webm" autoplay loop muted playsinline></video>
## Features ## Features
- Recursive directory traversal - Recursive directory traversal
@@ -136,10 +138,32 @@ Example output with `dirmd -o docs.md ~/repos/myproject --vertical-slices`:
├── cmd.md ← slice: tree + contents of cmd/ ├── cmd.md ← slice: tree + contents of cmd/
└── internal.md ← slice: tree + contents of internal/ └── 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 ## LLM-Optimized Output
Output is designed for RAG ingestion and LLM context windows, not human presentation: 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 - **ASCII tree format** (`+-` instead of Unicode box-drawing) to reduce token overhead
- **Explicit file delimiters**: `--- FILE: ./path (bytes, lines) ---` and `--- END FILE ---` - **Explicit file delimiters**: `--- FILE: ./path (bytes, lines) ---` and `--- END FILE ---`
- **Document type markers**: `[ROOT]` and `[SLICE]` in H1 headers - **Document type markers**: `[ROOT]` and `[SLICE]` in H1 headers
@@ -216,15 +240,18 @@ For full documentation on the Proton Drive CLI, including installation, authenti
Produces markdown with: Produces markdown with:
1. Absolute path as H1 header with `[ROOT]` or `[SLICE]` marker 1. Absolute path as H1 header with `[ROOT]` or `[SLICE]` marker
2. ASCII directory tree (indented with `+-` and `|` characters) 2. **Timestamp metadata line** (ISO 8601) immediately after the H1 header (only for new files)
3. Vertical slices index table (sliced mode only) 3. ASCII directory tree (indented with `+-` and `|` characters)
4. All text files with content in fenced code blocks 4. Vertical slices index table (sliced mode only)
5. Explicit `--- FILE: ---` and `--- END FILE ---` delimiters with byte and line counts 5. All text files with content in fenced code blocks
6. Language detection from file extensions 6. Explicit `--- FILE: ---` and `--- END FILE ---` delimiters with byte and line counts
7. Language detection from file extensions
Example output structure (non-sliced mode): Example output structure (non-sliced mode):
# /home/user/repos/myproject [ROOT] # /home/user/repos/myproject [ROOT]
> Generated: 2026-09-02T14:30:00Z
> Commit: a1b2c3d (Fix login bug)
## Directory Tree ## Directory Tree
+30 -40
View File
@@ -27,33 +27,24 @@ Output is always written to a directory specified by -o.
Examples: Examples:
# Create new documentation (writes index.md inside ./docs/) # Create new documentation
dirmd -o ./docs ~/repos/myproject dirmd -o ./docs ~/repos/myproject
# Overwrite existing output directory # Overwrite existing output directory (deletes all contents)
dirmd -o ./docs ~/repos/myproject -f dirmd -o ./docs ~/repos/myproject -f
# Append a single file # Append a single file
dirmd -a -i README.md -o ./docs 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 dirmd -o ./docs ~/repos/myproject --vertical-slices
# Skip frontend files # Include instructions from .dirmd
dirmd -o ./docs ~/repos/myproject --skip-frontend dirmd -o ./docs ~/repos/myproject --instructions
# Frontend only
dirmd -o ./docs ~/repos/myproject --frontend-only
# Omit test files and test blocks
dirmd -o ./docs ~/repos/myproject --omit-test
# Generate and upload to Proton Drive # Generate and upload to Proton Drive
dirmd -o ./docs ~/repos/myproject --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 # Interactive TUI mode
dirmd --tui`, dirmd --tui`,
RunE: run, RunE: run,
@@ -75,6 +66,7 @@ Examples:
omitTest bool omitTest bool
omitMd bool omitMd bool
tuiMode bool tuiMode bool
instructions bool
) )
func Execute() { func Execute() {
@@ -86,7 +78,7 @@ func Execute() {
func init() { func init() {
rootCmd.PersistentFlags().StringVarP(&outputPath, "output", "o", "", "output directory (required)") rootCmd.PersistentFlags().StringVarP(&outputPath, "output", "o", "", "output directory (required)")
rootCmd.Flags().BoolVarP(&appendMode, "append", "a", false, "append to existing index.md") 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().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().StringVar(&singleFile, "input-file", "", "single file input (append mode only)")
rootCmd.Flags().BoolVarP(&tuiMode, "interactive", "i", false, "launch interactive TUI configuration wizard") 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().Int64Var(&maxSize, "max-size", config.DefaultMaxSize, "max file size in bytes")
@@ -100,6 +92,7 @@ func init() {
rootCmd.Flags().BoolVar(&frontendOnly, "frontend-only", false, "include only frontend file types and README.md") 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(&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(&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 { func run(cmd *cobra.Command, args []string) error {
@@ -133,20 +126,21 @@ func run(cmd *cobra.Command, args []string) error {
} }
cfg := &config.Config{ cfg := &config.Config{
OutputPath: outputPath, OutputPath: outputPath,
AppendMode: appendMode, AppendMode: appendMode,
Force: force, Force: force,
MaxSize: maxSize, MaxSize: maxSize,
MaxDepth: maxDepth, MaxDepth: maxDepth,
Ignores: append(config.DefaultIgnores, ignorePatterns...), Ignores: append(config.DefaultIgnores, ignorePatterns...),
Extensions: excludedExts, Extensions: excludedExts,
ProtonDrive: protonDrive, ProtonDrive: protonDrive,
DrivePath: drivePath, DrivePath: drivePath,
VerticalSlices: verticalSlices, VerticalSlices: verticalSlices,
SkipFrontend: skipFrontend, SkipFrontend: skipFrontend,
FrontendOnly: frontendOnly, FrontendOnly: frontendOnly,
OmitTest: omitTest, OmitTest: omitTest,
OmitMd: omitMd, OmitMd: omitMd,
IncludeInstructions: instructions,
} }
if singleFile != "" { if singleFile != "" {
@@ -174,8 +168,13 @@ func run(cmd *cobra.Command, args []string) error {
} }
if !appendMode { if !appendMode {
if _, err := os.Stat(absOutput); err == nil && !force { if _, err := os.Stat(absOutput); err == nil {
return fmt.Errorf("output directory %s already exists (use -f to overwrite)", absOutput) 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)
}
} }
} }
@@ -202,7 +201,6 @@ func run(cmd *cobra.Command, args []string) error {
if err != nil { if err != nil {
return err return err
} }
fmt.Fprintf(os.Stderr, "generated %d files in %s\n", len(generated), absOutput) fmt.Fprintf(os.Stderr, "generated %d files in %s\n", len(generated), absOutput)
if protonDrive { if protonDrive {
@@ -210,27 +208,22 @@ func run(cmd *cobra.Command, args []string) error {
if remoteDir == "" { if remoteDir == "" {
remoteDir = "/my-files/md/" + filepath.Base(absOutput) remoteDir = "/my-files/md/" + filepath.Base(absOutput)
} }
if err := drive.CheckAuth(); err != nil { if err := drive.CheckAuth(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
fmt.Fprintln(os.Stderr, "local files were written successfully, but upload was skipped") fmt.Fprintln(os.Stderr, "local files were written successfully, but upload was skipped")
return err return err
} }
if err := drive.UploadDir(absOutput, remoteDir); err != nil { if err := drive.UploadDir(absOutput, remoteDir); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
fmt.Fprintln(os.Stderr, "local files were written successfully, but upload failed") fmt.Fprintln(os.Stderr, "local files were written successfully, but upload failed")
return err return err
} }
fmt.Fprintf(os.Stderr, "uploaded %s to %s on Proton Drive\n", absOutput, remoteDir) fmt.Fprintf(os.Stderr, "uploaded %s to %s on Proton Drive\n", absOutput, remoteDir)
} }
return nil return nil
} }
indexPath := filepath.Join(absOutput, indexBasename) indexPath := filepath.Join(absOutput, indexBasename)
if err := renderer.Write(result, indexPath, appendMode, cfg.SingleFile); err != nil { if err := renderer.Write(result, indexPath, appendMode, cfg.SingleFile); err != nil {
return err return err
} }
@@ -242,19 +235,16 @@ func run(cmd *cobra.Command, args []string) error {
if remoteDir == "" { if remoteDir == "" {
remoteDir = "/my-files/md/" + filepath.Base(absOutput) remoteDir = "/my-files/md/" + filepath.Base(absOutput)
} }
if err := drive.CheckAuth(); err != nil { if err := drive.CheckAuth(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
fmt.Fprintln(os.Stderr, "local file was written successfully, but upload was skipped") fmt.Fprintln(os.Stderr, "local file was written successfully, but upload was skipped")
return err return err
} }
if err := drive.UploadDir(absOutput, remoteDir); err != nil { if err := drive.UploadDir(absOutput, remoteDir); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
fmt.Fprintln(os.Stderr, "local file was written successfully, but upload failed") fmt.Fprintln(os.Stderr, "local file was written successfully, but upload failed")
return err return err
} }
fmt.Fprintf(os.Stderr, "uploaded %s to %s on Proton Drive\n", absOutput, remoteDir) fmt.Fprintf(os.Stderr, "uploaded %s to %s on Proton Drive\n", absOutput, remoteDir)
} }
Binary file not shown.
Binary file not shown.
+60 -78
View File
@@ -15,19 +15,9 @@ const (
) )
var DefaultIgnores = []string{ var DefaultIgnores = []string{
".git", ".git", "node_modules", "vendor", "bin", "go.mod", "go.sum",
"node_modules", "target", "build", "dist", "Cargo.lock", ".idea", ".vscode",
"vendor", "dependency-reduced-pom.xml", "__pycache__", "venv", "env", "uv.lock",
"bin",
"go.mod",
"go.sum",
"target",
"build",
"dist",
"Cargo.lock",
".idea",
".vscode",
"dependency-reduced-pom.xml",
} }
var DefaultExts = []string{ var DefaultExts = []string{
@@ -39,10 +29,8 @@ var DefaultExts = []string{
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".pdf", ".doc", ".docx", ".xls", ".xlsx",
".pem", ".key", ".cert", ".pem", ".key", ".cert",
".db", ".sqlite", ".sqlite3", ".db", ".sqlite", ".sqlite3",
".class", ".class", ".jar", ".war", ".ear",
".jar", ".pyc", ".pyo",
".war",
".ear",
} }
var FrontendExts = []string{ var FrontendExts = []string{
@@ -55,33 +43,28 @@ var FrontendExts = []string{
type stringSlice []string type stringSlice []string
func (s *stringSlice) String() string { func (s *stringSlice) String() string { return strings.Join(*s, ", ") }
return strings.Join(*s, ", ") func (s *stringSlice) Set(v string) error { *s = append(*s, v); return nil }
}
func (s *stringSlice) Set(v string) error {
*s = append(*s, v)
return nil
}
type Config struct { type Config struct {
InputPath string InputPath string
OutputPath string OutputPath string
AppendMode bool AppendMode bool
Force bool Force bool
SingleFile bool SingleFile bool
MaxSize int64 MaxSize int64
MaxDepth int MaxDepth int
Ignores []string Ignores []string
Extensions []string Extensions []string
AbsRoot string AbsRoot string
ProtonDrive bool ProtonDrive bool
DrivePath string DrivePath string
VerticalSlices bool VerticalSlices bool
SkipFrontend bool SkipFrontend bool
FrontendOnly bool FrontendOnly bool
OmitTest bool OmitTest bool
OmitMd bool OmitMd bool
IncludeInstructions bool
} }
func PrintUsage() { func PrintUsage() {
@@ -94,22 +77,23 @@ func PrintUsage() {
func Parse() (*Config, error) { func Parse() (*Config, error) {
var ( var (
input string input string
output string output string
appendMd bool appendMd bool
force bool force bool
maxSize int64 maxSize int64
maxDepth int maxDepth int
ignores stringSlice ignores stringSlice
exts stringSlice exts stringSlice
showVer bool showVer bool
protonDrive bool protonDrive bool
drivePath string drivePath string
verticalSlices bool verticalSlices bool
skipFrontend bool skipFrontend bool
frontendOnly bool frontendOnly bool
omitTest bool omitTest bool
omitMd bool omitMd bool
includeInstructions bool
) )
flag.StringVar(&input, "i", "", "single file input (append mode only)") flag.StringVar(&input, "i", "", "single file input (append mode only)")
@@ -124,10 +108,11 @@ func Parse() (*Config, error) {
flag.BoolVar(&protonDrive, "proton-drive", false, "upload output to Proton Drive after writing locally") 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(&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(&frontendOnly, "frontend-only", false, "include only frontend file types and README.md")
flag.BoolVar(&omitTest, "omit-test", false, "exclude test files (Go *_test.go, Java *Test.java) and strip #[cfg(test)] blocks from Rust") 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(&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.Usage = PrintUsage
flag.Parse() flag.Parse()
@@ -140,19 +125,15 @@ func Parse() (*Config, error) {
if output == "" { if output == "" {
return nil, fmt.Errorf("-o is required") return nil, fmt.Errorf("-o is required")
} }
if drivePath != "" && !protonDrive { if drivePath != "" && !protonDrive {
return nil, fmt.Errorf("--drive-path requires --proton-drive") return nil, fmt.Errorf("--drive-path requires --proton-drive")
} }
if verticalSlices && appendMd { if verticalSlices && appendMd {
return nil, fmt.Errorf("--vertical-slices cannot be used with --append") return nil, fmt.Errorf("--vertical-slices cannot be used with --append")
} }
if verticalSlices && input != "" { if verticalSlices && input != "" {
return nil, fmt.Errorf("--vertical-slices cannot be used with --input-file") return nil, fmt.Errorf("--vertical-slices cannot be used with --input-file")
} }
if skipFrontend && frontendOnly { if skipFrontend && frontendOnly {
return nil, fmt.Errorf("--skip-frontend and --frontend-only are mutually exclusive") return nil, fmt.Errorf("--skip-frontend and --frontend-only are mutually exclusive")
} }
@@ -163,20 +144,21 @@ func Parse() (*Config, error) {
} }
cfg := &Config{ cfg := &Config{
OutputPath: output, OutputPath: output,
AppendMode: appendMd, AppendMode: appendMd,
Force: force, Force: force,
MaxSize: maxSize, MaxSize: maxSize,
MaxDepth: maxDepth, MaxDepth: maxDepth,
Ignores: append(DefaultIgnores, ignores...), Ignores: append(DefaultIgnores, ignores...),
Extensions: excludedExts, Extensions: excludedExts,
ProtonDrive: protonDrive, ProtonDrive: protonDrive,
DrivePath: drivePath, DrivePath: drivePath,
VerticalSlices: verticalSlices, VerticalSlices: verticalSlices,
SkipFrontend: skipFrontend, SkipFrontend: skipFrontend,
FrontendOnly: frontendOnly, FrontendOnly: frontendOnly,
OmitTest: omitTest, OmitTest: omitTest,
OmitMd: omitMd, OmitMd: omitMd,
IncludeInstructions: includeInstructions,
} }
if input != "" { if input != "" {
+17 -11
View File
@@ -30,7 +30,6 @@ func CheckAuth() error {
return nil return nil
} }
// EnsureRemoteParent creates the parent remote directory if it doesn't exist.
func EnsureRemoteParent(remoteDir string) error { func EnsureRemoteParent(remoteDir string) error {
binary, err := Lookup() binary, err := Lookup()
if err != nil { if err != nil {
@@ -38,37 +37,45 @@ func EnsureRemoteParent(remoteDir string) error {
} }
parentDir := filepath.Dir(remoteDir) parentDir := filepath.Dir(remoteDir)
// Try create-folder on the grandparent for the parent
grandparent := filepath.Dir(parentDir) grandparent := filepath.Dir(parentDir)
parentName := filepath.Base(parentDir) parentName := filepath.Base(parentDir)
cmd := exec.Command(binary, "filesystem", "create-folder", grandparent, parentName) cmd := exec.Command(binary, "filesystem", "create-folder", grandparent, parentName)
cmd.Stdout = nil cmd.Stdout = nil
cmd.Stderr = nil cmd.Stderr = nil
cmd.Run() // Ignore errors — folder may already exist cmd.Run()
return nil return nil
} }
// UploadDir uploads an entire local directory to Proton Drive. func CleanRemote(remoteDir string) {
// The local directory's basename becomes the remote directory name. 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 { func UploadDir(localDir, remoteDir string) error {
binary, err := Lookup() binary, err := Lookup()
if err != nil { if err != nil {
return fmt.Errorf("proton-drive not found in PATH: %w", err) return fmt.Errorf("proton-drive not found in PATH: %w", err)
} }
// Ensure parent of remote dir exists
if err := EnsureRemoteParent(remoteDir); err != nil { if err := EnsureRemoteParent(remoteDir); err != nil {
return fmt.Errorf("cannot prepare remote directory: %w", err) 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) remoteParent := filepath.Dir(remoteDir)
cmd := exec.Command(binary, "filesystem", "upload", localDir, remoteParent, cmd := exec.Command(binary, "filesystem", "upload", localDir, remoteParent,
"--conflict-strategy", "replace") "--folder-conflict-strategy", "replace", "--file-conflict-strategy", "replace")
var stderr strings.Builder var stderr strings.Builder
cmd.Stderr = &stderr cmd.Stderr = &stderr
@@ -80,7 +87,6 @@ func UploadDir(localDir, remoteDir string) error {
return nil return nil
} }
// Upload uploads a single local file to a remote path on Proton Drive.
func Upload(localPath, remotePath string) error { func Upload(localPath, remotePath string) error {
binary, err := Lookup() binary, err := Lookup()
if err != nil { if err != nil {
@@ -111,7 +117,7 @@ func Upload(localPath, remotePath string) error {
func runUpload(binary, localPath, remoteDir string) error { func runUpload(binary, localPath, remoteDir string) error {
cmd := exec.Command(binary, "filesystem", "upload", localPath, remoteDir, cmd := exec.Command(binary, "filesystem", "upload", localPath, remoteDir,
"--conflict-strategy", "replace") "--folder-conflict-strategy", "replace", "--file-conflict-strategy", "replace")
var stderr strings.Builder var stderr strings.Builder
cmd.Stderr = &stderr cmd.Stderr = &stderr
+10
View File
@@ -99,6 +99,16 @@ func isTestFile(path, name string) bool {
} }
slashPath := filepath.ToSlash(path) slashPath := filepath.ToSlash(path)
return strings.Contains(slashPath, "/src/test/") 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 return false
+37
View File
@@ -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)
}
+41
View File
@@ -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()
}
+40 -34
View File
@@ -6,8 +6,11 @@ import (
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
"time"
"unicode/utf8" "unicode/utf8"
"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/tree"
"git.flo.fo/FLO/dirmd/internal/walker" "git.flo.fo/FLO/dirmd/internal/walker"
) )
@@ -35,7 +38,18 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile
h1Path = filepath.Join(result.AbsRoot, result.Entries[0].RelPath) 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 { if !singleFile {
root := tree.Build(result.Entries) root := tree.Build(result.Entries)
@@ -53,7 +67,6 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile
for _, entry := range result.Entries { for _, entry := range result.Entries {
fullPath := filepath.Join(result.AbsRoot, entry.RelPath) fullPath := filepath.Join(result.AbsRoot, entry.RelPath)
content, err := readFileForOutput(fullPath, result.OmitTest) content, err := readFileForOutput(fullPath, result.OmitTest)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err) fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err)
@@ -105,12 +118,11 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e
} }
var generated []string var generated []string
indexBasename := "index_" + repoName + ".md" indexBasename := "index_" + repoName + ".md"
rootPath := filepath.Join(outputDir, indexBasename) rootPath := filepath.Join(outputDir, indexBasename)
generated = append(generated, rootPath) generated = append(generated, rootPath)
if err := writeRootSlice(rootPath, absRoot, rootName, repoName, rootFiles, topLevelDirs, result.OmitTest); 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) return nil, fmt.Errorf("failed to write root slice: %w", err)
} }
@@ -136,16 +148,13 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e
for i, e := range dirEntries { for i, e := range dirEntries {
sepIdx := strings.Index(e.RelPath, string(filepath.Separator)) sepIdx := strings.Index(e.RelPath, string(filepath.Separator))
if sepIdx >= 0 { if sepIdx >= 0 {
treeEntries[i] = walker.Entry{ treeEntries[i] = walker.Entry{RelPath: e.RelPath[sepIdx+1:], Size: e.Size}
RelPath: e.RelPath[sepIdx+1:],
Size: e.Size,
}
} else { } else {
treeEntries[i] = e treeEntries[i] = e
} }
} }
if err := writeDirSlice(slicePath, absDir, absRoot, dirName, treeEntries, dirEntries, indexBasename, result.OmitTest); 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) return nil, fmt.Errorf("failed to write slice %s: %w", sliceName, err)
} }
} }
@@ -153,7 +162,7 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e
return generated, nil return generated, nil
} }
func writeRootSlice(path, absRoot, rootName, repoName string, rootFiles []walker.Entry, topLevelDirs map[string][]walker.Entry, omitTest bool) 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) f, err := os.Create(path)
if err != nil { if err != nil {
return fmt.Errorf("cannot create root file: %w", err) return fmt.Errorf("cannot create root file: %w", err)
@@ -161,8 +170,14 @@ func writeRootSlice(path, absRoot, rootName, repoName string, rootFiles []walker
defer f.Close() defer f.Close()
var sb strings.Builder var sb strings.Builder
fmt.Fprintf(&sb, "# %s [ROOT]\n", absRoot)
fmt.Fprintf(&sb, "# %s [ROOT]\n\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")
}
treeLines := buildTreeWithRefs(rootName, repoName, topLevelDirs, rootFiles) treeLines := buildTreeWithRefs(rootName, repoName, topLevelDirs, rootFiles)
sb.WriteString("## Directory Tree\n\n") sb.WriteString("## Directory Tree\n\n")
@@ -196,11 +211,7 @@ func writeRootSlice(path, absRoot, rootName, repoName string, rootFiles []walker
if len(rootFiles) > 0 { if len(rootFiles) > 0 {
sb.WriteString("## Root Contents\n\n") 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 { for _, entry := range rootFiles {
fullPath := filepath.Join(absRoot, entry.RelPath) fullPath := filepath.Join(absRoot, entry.RelPath)
content, err := readFileForOutput(fullPath, omitTest) content, err := readFileForOutput(fullPath, omitTest)
@@ -208,17 +219,14 @@ func writeRootSlice(path, absRoot, rootName, repoName string, rootFiles []walker
fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err) fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err)
continue continue
} }
language := extToLang(filepath.Ext(entry.RelPath)) language := extToLang(filepath.Ext(entry.RelPath))
lines := countLines(content) lines := countLines(content)
fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", entry.RelPath, entry.Size, lines) fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", entry.RelPath, entry.Size, lines)
fmt.Fprintf(&sb, "%s", fenceChars) fmt.Fprintf(&sb, "%s", fenceChars)
if language != "" { if language != "" {
fmt.Fprintf(&sb, "%s", language) fmt.Fprintf(&sb, "%s", language)
} }
fmt.Fprintf(&sb, "\n") fmt.Fprintf(&sb, "\n%s", content)
fmt.Fprintf(&sb, "%s", content)
if !strings.HasSuffix(content, "\n") { if !strings.HasSuffix(content, "\n") {
fmt.Fprintf(&sb, "\n") fmt.Fprintf(&sb, "\n")
} }
@@ -230,7 +238,7 @@ func writeRootSlice(path, absRoot, rootName, repoName string, rootFiles []walker
return err return err
} }
func writeDirSlice(path, absDir, absRoot, dirName string, treeEntries, fileEntries []walker.Entry, rootFileName string, omitTest bool) 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) f, err := os.Create(path)
if err != nil { if err != nil {
return fmt.Errorf("cannot create slice file: %w", err) return fmt.Errorf("cannot create slice file: %w", err)
@@ -238,24 +246,25 @@ func writeDirSlice(path, absDir, absRoot, dirName string, treeEntries, fileEntri
defer f.Close() defer f.Close()
var sb strings.Builder var sb strings.Builder
fmt.Fprintf(&sb, "# %s [SLICE]\n", absDir)
fmt.Fprintf(&sb, "# %s [SLICE]\n\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) fmt.Fprintf(&sb, "Root document: %s\n\n", rootFileName)
sb.WriteString("## Directory Tree\n\n") sb.WriteString("## Directory Tree\n\n")
sb.WriteString(fenceChars + "\n") sb.WriteString(fenceChars + "\n")
sliceTree := buildTreeFromEntries(dirName, treeEntries) sliceTree := buildTreeFromEntries(dirName, treeEntries)
sb.WriteString(sliceTree) sb.WriteString(sliceTree)
sb.WriteString(fenceChars + "\n\n") sb.WriteString(fenceChars + "\n\n")
if len(fileEntries) > 0 { if len(fileEntries) > 0 {
sb.WriteString("## Contents\n\n") 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 { for _, entry := range fileEntries {
fullPath := filepath.Join(absRoot, entry.RelPath) fullPath := filepath.Join(absRoot, entry.RelPath)
content, err := readFileForOutput(fullPath, omitTest) content, err := readFileForOutput(fullPath, omitTest)
@@ -263,17 +272,14 @@ func writeDirSlice(path, absDir, absRoot, dirName string, treeEntries, fileEntri
fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err) fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err)
continue continue
} }
language := extToLang(filepath.Ext(entry.RelPath)) language := extToLang(filepath.Ext(entry.RelPath))
lines := countLines(content) lines := countLines(content)
fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", entry.RelPath, entry.Size, lines) fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", entry.RelPath, entry.Size, lines)
fmt.Fprintf(&sb, "%s", fenceChars) fmt.Fprintf(&sb, "%s", fenceChars)
if language != "" { if language != "" {
fmt.Fprintf(&sb, "%s", language) fmt.Fprintf(&sb, "%s", language)
} }
fmt.Fprintf(&sb, "\n") fmt.Fprintf(&sb, "\n%s", content)
fmt.Fprintf(&sb, "%s", content)
if !strings.HasSuffix(content, "\n") { if !strings.HasSuffix(content, "\n") {
fmt.Fprintf(&sb, "\n") fmt.Fprintf(&sb, "\n")
} }
+17 -14
View File
@@ -79,6 +79,7 @@ func Run() error {
huh.NewOption("Omit test files", "omit-test"), huh.NewOption("Omit test files", "omit-test"),
huh.NewOption("Omit non-README markdown", "omit-md"), huh.NewOption("Omit non-README markdown", "omit-md"),
huh.NewOption("Upload to Proton Drive", "proton-drive"), huh.NewOption("Upload to Proton Drive", "proton-drive"),
huh.NewOption("Include .dirmd instructions", "instructions"),
). ).
Value(&options). Value(&options).
Height(10), Height(10),
@@ -115,6 +116,7 @@ func Run() error {
omitTest := containsOption(options, "omit-test") omitTest := containsOption(options, "omit-test")
omitMd := containsOption(options, "omit-md") omitMd := containsOption(options, "omit-md")
protonDrive := containsOption(options, "proton-drive") protonDrive := containsOption(options, "proton-drive")
includeInstructions := containsOption(options, "instructions")
if skipFrontend && frontendOnly { if skipFrontend && frontendOnly {
return fmt.Errorf("--skip-frontend and --frontend-only are mutually exclusive") return fmt.Errorf("--skip-frontend and --frontend-only are mutually exclusive")
@@ -155,20 +157,21 @@ func Run() error {
allIgnores = append(allIgnores, ignores...) allIgnores = append(allIgnores, ignores...)
cfg := &config.Config{ cfg := &config.Config{
InputPath: inputDir, InputPath: inputDir,
OutputPath: outputDir, OutputPath: outputDir,
Force: force, Force: force,
MaxSize: int64(maxSizeKB) * 1024, MaxSize: int64(maxSizeKB) * 1024,
MaxDepth: maxDepth, MaxDepth: maxDepth,
Ignores: allIgnores, Ignores: allIgnores,
Extensions: excludedExts, Extensions: excludedExts,
ProtonDrive: protonDrive, ProtonDrive: protonDrive,
DrivePath: drivePath, DrivePath: drivePath,
VerticalSlices: verticalSlices, VerticalSlices: verticalSlices,
SkipFrontend: skipFrontend, SkipFrontend: skipFrontend,
FrontendOnly: frontendOnly, FrontendOnly: frontendOnly,
OmitTest: omitTest, OmitTest: omitTest,
OmitMd: omitMd, OmitMd: omitMd,
IncludeInstructions: includeInstructions,
} }
cmd := buildCommand(cfg) cmd := buildCommand(cfg)
+31 -8
View File
@@ -9,6 +9,8 @@ import (
"git.flo.fo/FLO/dirmd/internal/config" "git.flo.fo/FLO/dirmd/internal/config"
"git.flo.fo/FLO/dirmd/internal/filter" "git.flo.fo/FLO/dirmd/internal/filter"
"git.flo.fo/FLO/dirmd/internal/git"
"git.flo.fo/FLO/dirmd/internal/instructions"
) )
type Entry struct { type Entry struct {
@@ -17,10 +19,12 @@ type Entry struct {
} }
type Result struct { type Result struct {
Entries []Entry Entries []Entry
AbsRoot string AbsRoot string
SingleFile bool SingleFile bool
OmitTest bool OmitTest bool
Commit *git.CommitInfo
Instructions string
} }
func Run(cfg *config.Config) (*Result, error) { func Run(cfg *config.Config) (*Result, error) {
@@ -53,11 +57,19 @@ func processSingleFile(cfg *config.Config) (*Result, error) {
return nil, fmt.Errorf("file %s filtered out", cfg.AbsRoot) 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{ return &Result{
Entries: []Entry{{RelPath: filepath.Base(cfg.AbsRoot), Size: info.Size()}}, Entries: []Entry{{RelPath: filepath.Base(cfg.AbsRoot), Size: info.Size()}},
AbsRoot: filepath.Dir(cfg.AbsRoot), AbsRoot: filepath.Dir(cfg.AbsRoot),
SingleFile: true, SingleFile: true,
OmitTest: cfg.OmitTest, OmitTest: cfg.OmitTest,
Commit: commitInfo,
Instructions: instContent,
}, nil }, nil
} }
@@ -72,6 +84,17 @@ func walkDirectory(cfg *config.Config) (*Result, error) {
} }
result := &Result{AbsRoot: cfg.AbsRoot, OmitTest: cfg.OmitTest} 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 { err := filepath.WalkDir(cfg.AbsRoot, func(path string, d fs.DirEntry, err error) error {
if err != nil { if err != nil {