Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ fuori
test_tree
test_ignore
fuori-test
src/generated_unpacker.h

# misc
.git/

# macOS
.DS_Store

/scripts

_export.md
14 changes: 10 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,25 @@ CFLAGS = -Wall -Wextra -Wshadow -Wcast-align -Wwrite-strings -Wredundant-decls \
-Wstrict-prototypes -Wold-style-definition -std=c99 -O2 -D_POSIX_C_SOURCE=200809L
TARGET = fuori
TEST_CLI_TARGET = fuori-test
SOURCES = src/main.c src/collect.c src/render.c src/git_paths.c src/ignore.c src/options.c src/tree.c src/sensitive.c
SOURCES = src/main.c src/collect.c src/render.c src/git_paths.c src/ignore.c src/options.c src/tree.c src/sensitive.c src/unpacker.c
TEST_TARGET = test_ignore
TREE_TEST_TARGET = test_tree
UNPACKER_SOURCE = scripts/extract_full_export.py.txt
UNPACKER_GENERATOR = scripts/generate_unpacker_header.py
GENERATED_UNPACKER = src/generated_unpacker.h
PREFIX ?= /usr/local
BINDIR ?= $(PREFIX)/bin
VERSION ?= dev

all: $(TARGET)

$(TARGET): $(SOURCES)
$(GENERATED_UNPACKER): $(UNPACKER_SOURCE) $(UNPACKER_GENERATOR)
python3 $(UNPACKER_GENERATOR) $(UNPACKER_SOURCE) $(GENERATED_UNPACKER)

$(TARGET): $(SOURCES) $(GENERATED_UNPACKER)
$(CC) $(CPPFLAGS) $(CFLAGS) -o $(TARGET) $(SOURCES)

$(TEST_CLI_TARGET): $(SOURCES)
$(TEST_CLI_TARGET): $(SOURCES) $(GENERATED_UNPACKER)
$(CC) $(CPPFLAGS) $(CFLAGS) -DFUORI_TESTING -o $(TEST_CLI_TARGET) $(SOURCES)

$(TEST_TARGET): tests/test_ignore.c src/ignore.c src/ignore.h
Expand All @@ -32,7 +38,7 @@ test: $(TARGET) $(TEST_CLI_TARGET) $(TEST_TARGET) $(TREE_TEST_TARGET)
BIN=./$(TEST_CLI_TARGET) sh ./tests/test_cli.sh

clean:
rm -f $(TARGET) $(TEST_CLI_TARGET) $(TEST_TARGET) $(TREE_TEST_TARGET)
rm -f $(TARGET) $(TEST_CLI_TARGET) $(TEST_TARGET) $(TREE_TEST_TARGET) $(GENERATED_UNPACKER)

install: $(TARGET)
install -d $(BINDIR)
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ fuori [OPTIONS]
| `-0`, `--null` | Use NUL as the stdin delimiter (requires `--from-stdin`) |
| `--line-numbers` | Prefix exported code lines with line numbers |
| `--hunks [<n>]` | In Git delta modes, export only changed hunks plus context lines |
| `--unpacker` | Append an LLM-oriented unpacker appendix for full exports |
| `--tree` / `--no-tree` | Include/omit project tree (default: on) |
| `--tree-depth <n>` | Limit tree render depth |
| `-s <size_kb>` | Max file size in KB (default: 100) |
Expand All @@ -111,6 +112,7 @@ fuori [OPTIONS]
Git selection flags (`--staged`, `--unstaged`, `--diff`) and `--from-stdin` are mutually exclusive; `--no-git` cannot be combined with them.
`--no-default-ignore` only applies to filesystem selection.
`--hunks` only applies to `--staged`, `--unstaged`, and `--diff`.
`--unpacker` cannot be combined with `--hunks`.

**Examples:**

Expand All @@ -122,6 +124,7 @@ fuori --diff HEAD~3..HEAD # Files changed in the last 3 commits
fuori --diff main...HEAD # Changes since branching from main
fuori --staged --hunks # Only changed hunks with default context
fuori --diff main...HEAD --hunks=8 # Wider hunk context for review
fuori --unpacker # Append an unpacker appendix for LLM reconstruction
fuori -o - > codebase.md # Pipe to stdout
fuori --no-tree # Skip the project tree section
fuori --tree-depth 2 # Shallow tree
Expand Down Expand Up @@ -268,7 +271,8 @@ The output markdown file will contain:
5. Either a full-file code block or one or more hunk slices separated by omission markers such as `... 84 unchanged lines omitted ...`
6. Optional line-number prefixes inside code blocks when `--line-numbers` is set; hunk exports keep original file line numbers
7. Appropriate language identifiers for syntax highlighting
8. A `stderr` summary of files, bytes, and estimated tokens after successful completion
8. An optional unpacker appendix with reconstruction instructions and an embedded Python helper when `--unpacker` is set
9. A `stderr` summary of files, bytes, and estimated tokens after successful completion

Example file contents excerpt (the `Makefile` section is omitted for brevity):
````markdown
Expand Down
240 changes: 240 additions & 0 deletions scripts/extract_full_export.py.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
#!/usr/bin/env python3

from __future__ import annotations

import argparse
import html
import re
import sys
from pathlib import Path


LINE_NUMBER_RE = re.compile(r"^\s*\d+ \| ?(.*)$")
FILES_BEGIN_MARKER = "<!-- FUORI_FILES_BEGIN -->"
FILES_END_MARKER = "<!-- FUORI_FILES_END -->"


class ExportParseError(Exception):
pass


def decode_heading_path(text: str) -> str:
text = html.unescape(text)
result: list[str] = []
i = 0

while i < len(text):
if text[i] != "\\":
result.append(text[i])
i += 1
continue

if i + 1 >= len(text):
result.append("\\")
break

next_char = text[i + 1]
if next_char == "n":
result.append("\n")
i += 2
elif next_char == "r":
result.append("\r")
i += 2
elif next_char == "t":
result.append("\t")
i += 2
elif next_char in ("\\", "`", "*", "[", "]"):
result.append(next_char)
i += 2
elif next_char == "x" and i + 3 < len(text):
hex_value = text[i + 2 : i + 4]
try:
result.append(chr(int(hex_value, 16)))
i += 4
except ValueError:
result.append("\\")
result.append(next_char)
i += 2
else:
result.append("\\")
result.append(next_char)
i += 2

return "".join(result)


def parse_open_fence(line: str) -> int | None:
if not line.startswith("```"):
return None

count = 0
while count < len(line) and line[count] == "`":
count += 1

return count if count >= 3 else None


def parse_preamble_flags(lines: list[str]) -> tuple[bool, bool]:
line_numbers_on = False
hunks_on = False

for line in lines:
heading = line.rstrip("\n")
if heading.startswith("## "):
break
if heading == "Line numbers: on":
line_numbers_on = True
elif heading.startswith("Hunks: on"):
hunks_on = True

return line_numbers_on, hunks_on


def strip_line_number(line: str) -> str:
newline = "\n" if line.endswith("\n") else ""
content = line[:-1] if newline else line
match = LINE_NUMBER_RE.fullmatch(content)
if not match:
raise ExportParseError(f"invalid numbered line: {content!r}")
return match.group(1) + newline


def next_nonblank_index(lines: list[str], start: int) -> int | None:
i = start
while i < len(lines) and lines[i].strip() == "":
i += 1
return i if i < len(lines) else None


def read_export_entries(export_path: Path) -> tuple[list[tuple[str, str]], bool]:
text = export_path.read_text(encoding="utf-8")
lines = text.splitlines(keepends=True)
line_numbers_on, hunks_on = parse_preamble_flags(lines)
has_files_marker = any(line.rstrip("\n") == FILES_BEGIN_MARKER for line in lines)

if hunks_on:
raise ExportParseError("hunk exports are not supported; use a full export without --hunks")

entries: list[tuple[str, str]] = []
i = 0
seen_file = False
saw_files_marker = False

while i < len(lines):
heading = lines[i].rstrip("\n")

if has_files_marker and not saw_files_marker:
if heading == FILES_BEGIN_MARKER:
saw_files_marker = True
i += 1
continue

if heading == FILES_BEGIN_MARKER:
saw_files_marker = True
i += 1
continue
if heading == FILES_END_MARKER:
break

if not seen_file and heading == "## Change Context":
i += 1
while i < len(lines) and not lines[i].startswith("## "):
i += 1
continue

if not seen_file and heading == "## Project Tree":
i += 1
while i < len(lines) and lines[i].strip() == "":
i += 1
if i >= len(lines):
raise ExportParseError("unterminated project tree section")

fence_len = parse_open_fence(lines[i].rstrip("\n"))
if fence_len is None:
raise ExportParseError("project tree section is missing its opening fence")
i += 1

while i < len(lines) and lines[i].rstrip("\n") != ("`" * fence_len):
i += 1
if i >= len(lines):
raise ExportParseError("unterminated project tree code fence")
i += 1
continue

if not heading.startswith("## "):
i += 1
continue

body_start = next_nonblank_index(lines, i + 1)
if body_start is None:
raise ExportParseError(f"missing code fence for section {heading[3:]!r}")

fence_len = parse_open_fence(lines[body_start].rstrip("\n"))
if fence_len is None:
if not seen_file and not saw_files_marker:
raise ExportParseError(f"unexpected non-file section before file entries: {heading!r}")
raise ExportParseError(f"invalid code fence for {heading[3:]!r}")

file_path = decode_heading_path(heading[3:])
i = body_start + 1

body_lines: list[str] = []
while i < len(lines):
if lines[i].rstrip("\n") == ("`" * fence_len):
i += 1
break
body_lines.append(lines[i])
i += 1
else:
raise ExportParseError(f"unterminated code fence for {file_path!r}")

if line_numbers_on:
body_lines = [strip_line_number(line) for line in body_lines]

entries.append((file_path, "".join(body_lines)))
seen_file = True

return entries, line_numbers_on


def safe_output_path(output_dir: Path, export_path: str) -> Path:
relative = Path(export_path.lstrip("/")) if export_path.startswith("/") else Path(export_path)
destination = (output_dir / relative).resolve()
output_root = output_dir.resolve()

try:
destination.relative_to(output_root)
except ValueError as exc:
raise ExportParseError(f"refusing to write outside output directory: {export_path!r}") from exc

return destination


def write_entries(output_dir: Path, entries: list[tuple[str, str]]) -> None:
for export_path, content in entries:
destination = safe_output_path(output_dir, export_path)
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(content, encoding="utf-8")


def main() -> int:
parser = argparse.ArgumentParser(
description="Extract source files from a full fuori markdown export."
)
parser.add_argument("export", type=Path, help="Path to the markdown export")
parser.add_argument("output_dir", type=Path, help="Directory to reconstruct files into")
args = parser.parse_args()

try:
entries, _ = read_export_entries(args.export)
args.output_dir.mkdir(parents=True, exist_ok=True)
write_entries(args.output_dir, entries)
except (OSError, ExportParseError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1

return 0


if __name__ == "__main__":
raise SystemExit(main())
47 changes: 47 additions & 0 deletions scripts/generate_unpacker_header.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/usr/bin/env python3

from __future__ import annotations

import sys
from pathlib import Path


def main() -> int:
if len(sys.argv) != 3:
print("usage: generate_unpacker_header.py <source> <output>", file=sys.stderr)
return 1

source = Path(sys.argv[1])
output = Path(sys.argv[2])
data = source.read_bytes()

lines = [
"#ifndef GENERATED_UNPACKER_H",
"#define GENERATED_UNPACKER_H",
"",
"#include <stddef.h>",
"",
"static const unsigned char FUORI_UNPACKER_SCRIPT[] = {",
]

for offset in range(0, len(data), 12):
row = ", ".join(f"0x{byte:02X}" for byte in data[offset : offset + 12])
lines.append(f" {row},")

lines.extend(
[
" 0x00",
"};",
"",
f"static const size_t FUORI_UNPACKER_SCRIPT_LEN = {len(data)};",
"",
"#endif",
]
)

output.write_text("\n".join(lines) + "\n", encoding="utf-8")
return 0


if __name__ == "__main__":
raise SystemExit(main())
1 change: 1 addition & 0 deletions src/app.h
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ typedef struct {
size_t skipped_ignored;
size_t skipped_symlink;
size_t skipped_sensitive;
size_t skipped_unreadable_dirs;
} AppContext;

typedef enum {
Expand Down
Loading