152 """Load optional per-project config, merging with module-level defaults."""
154 "include_extensions": list(INCLUDE_EXTENSIONS),
155 "exclude_dirs": set(EXCLUDE_DIRS),
156 "wildcard_exclude_dirs": list(WILDCARD_EXCLUDE_DIRS),
157 "header_text": HEADER_TEXT,
158 "separator_file": SEPARATOR_FILE,
159 "separator_subdir": SEPARATOR_SUBDIR,
160 "root_header": ROOT_HEADER,
161 "max_safe_path_len": _MAX_SAFE_PATH_LEN,
164 config_path = root / _CONFIG_FILENAME
165 if not config_path.is_file():
167 _oct_root = Path(__file__).resolve().parent.parent.parent
168 fallback_path = _oct_root / _CONFIG_FILENAME
169 if fallback_path.is_file()
and fallback_path != config_path:
170 print(f
" Note: No {_CONFIG_FILENAME} in {root}; "
171 f
"using oct default from {fallback_path}.",
173 config_path = fallback_path
175 print(f
" Warning: No {_CONFIG_FILENAME} found; using built-in defaults.",
180 raw = json.loads(config_path.read_text(encoding=
"utf-8"))
181 except json.JSONDecodeError
as e:
182 print(f
" Warning: {_CONFIG_FILENAME} has invalid JSON ({e}); using defaults.",
186 if not isinstance(raw, dict):
187 print(f
" Warning: {_CONFIG_FILENAME} root must be a JSON object; using defaults.",
191 print(f
"Using {_CONFIG_FILENAME}")
193 def _merge_collection(key, default, collection_type):
194 """Apply replace / add / remove semantics for a collection-type key."""
195 add_key = f
"{key}_add"
196 remove_key = f
"{key}_remove"
199 result = collection_type(raw[key])
200 print(f
" {key}: replaced -> {sorted(result)}")
202 result = collection_type(default)
206 if collection_type
is set:
207 result = result | set(added)
209 result = result + [v
for v
in added
if v
not in result]
210 print(f
" {key}: added {added}")
212 if remove_key
in raw:
213 removed = raw[remove_key]
214 removals = set(removed)
215 if collection_type
is set:
216 result = result - removals
218 result = [v
for v
in result
if v
not in removals]
219 print(f
" {key}: removed {removed}")
223 cfg[
"include_extensions"] = _merge_collection(
224 "include_extensions", INCLUDE_EXTENSIONS, list
226 cfg[
"exclude_dirs"] = _merge_collection(
227 "exclude_dirs", EXCLUDE_DIRS, set
229 cfg[
"wildcard_exclude_dirs"] = _merge_collection(
230 "wildcard_exclude_dirs", WILDCARD_EXCLUDE_DIRS, list
233 for scalar_key
in (
"header_text",
"separator_file",
"separator_subdir",
234 "root_header",
"max_safe_path_len"):
235 if scalar_key
in raw:
236 cfg[scalar_key] = raw[scalar_key]
237 print(f
" {scalar_key}: overridden -> {raw[scalar_key]!r}")
337 prefix: str =
"_source") -> Path:
339 Build the output file path, truncating the dotpath if the full path would
340 exceed the Windows MAX_PATH limit (~260 chars). A short MD5 hash suffix is
341 appended when truncation is applied to ensure uniqueness.
343 max_len = cfg[
"max_safe_path_len"]
344 filename = f
"{prefix}.{dotpath}.txt"
345 candidate = out_dir / filename
346 if len(str(candidate)) <= max_len:
348 h = hashlib.md5(dotpath.encode()).hexdigest()[:8]
349 overhead = len(str(out_dir)) + len(f
"{prefix}....{h}.txt") + 2
350 max_dotpath_len = max_len - overhead
351 truncated = dotpath[:max(0, max_dotpath_len)].rstrip(
".")
352 return out_dir / f
"{prefix}.{truncated}...{h}.txt"
360 cfg: dict, warn_secrets: bool =
False,
361 block_secrets: bool =
False,
362 diff_filter: set[Path] |
None =
None):
364 if diff_filter
is not None:
365 files = [f
for f
in files
if f.resolve()
in diff_filter]
367 return 0, 0, 0, 0, []
375 fh = output_path.open(
"w", encoding=
"utf-8")
377 print(f
" Warning: cannot write '{output_path.name}': {e}")
378 return 0, 0, 0, 0, files
381 out.write(cfg[
"header_text"].format(directory=folder))
382 out.write(cfg[
"root_header"])
384 for idx, file_path
in enumerate(files, start=1):
385 rel_path = file_path.relative_to(folder)
388 content = file_path.read_text(encoding=
"utf-8", errors=
"replace")
389 except (OSError, UnicodeDecodeError):
390 print(f
"Warning: Could not read {file_path}: skipping", file=sys.stderr)
394 if warn_secrets
and content:
395 abs_key = str(file_path.resolve())
397 file_path.relative_to(root)
398 if file_path.is_relative_to(root)
else rel_path
401 abs_key, content, str(rel_from_root)
403 if abs_key
not in _scanned_secret_paths:
404 _scanned_secret_paths.add(abs_key)
405 for w
in sec_warnings:
406 print(f
"Warning: {w}", file=sys.stderr)
407 if block_secrets
and sec_warnings:
409 f
" Skipping {rel_from_root} due to potential secrets",
414 out.write(cfg[
"separator_file"].format(num=idx, path=str(rel_path)))
419 total_bytes += file_path.stat().st_size
421 total_lines += content.count(
"\n") + 1
422 total_words += len(content.split())
424 return file_count, total_lines, total_words, total_bytes, files
433 Execute the source exporter with the given root directory and arguments.
435 do_clean =
"--clean" in args
436 verbose =
"--verbose" in args
437 single_dir_mode =
"--single-dir" in args
438 warn_secrets =
"--warn-secrets" in args
or "--block-secrets" in args
439 block_secrets =
"--block-secrets" in args
442 diff_ref: str |
None =
None
444 if arg.startswith(
"--diff="):
445 diff_ref = arg.split(
"=", 1)[1]
450 _scanned_secret_paths.clear()
451 _secret_scan_cache.clear()
458 diff_filter: set[Path] |
None =
None
461 from oct.core.git
import git_diff_name_only
462 diff_paths = git_diff_name_only(root, diff_ref)
463 diff_filter = set(diff_paths)
465 print(f
"No files changed since {diff_ref}. Nothing to export.", file=sys.stderr)
467 print(f
"Exporting {len(diff_filter)} file(s) changed since {diff_ref}.", file=sys.stderr)
468 except Exception
as exc:
469 print(f
"Warning: --diff failed ({exc}). Exporting all files.", file=sys.stderr)
473 timestamp = datetime.now().strftime(
"%Y%m%d-%H%M")
474 root_name = root.name
476 summary: Dict[str, Tuple[int, int, int, int]] = {}
477 per_dir_files: Dict[str, List[Path]] = {}
478 skipped: List[str] = []
481 single_out_root: Path |
None =
None
483 single_out_root = root / f
"_source_code-{timestamp}"
485 single_out_root.mkdir(parents=
True, exist_ok=
True)
487 print(f
" Error: cannot create single output dir: {e}")
496 rel_to_root = folder.relative_to(root)
if folder != root
else Path(
".")
497 out_dir = single_out_root / rel_to_root
499 out_dir = folder / f
"_source_code-{timestamp}"
502 out_dir.mkdir(parents=
True, exist_ok=
True)
504 rel_err =
"." if folder == root
else str(folder.relative_to(root))
505 print(f
" Warning: cannot create output dir for '{folder.name}': {e}")
506 skipped.append(rel_err)
509 rel =
"." if folder == root
else str(folder.relative_to(root))
510 dotpath = root_name
if folder == root
else f
"{root_name}.{rel.replace(os.sep, '.')}"
515 folder, out_path, root, cfg,
516 warn_secrets=warn_secrets, block_secrets=block_secrets,
517 diff_filter=diff_filter,
519 except Exception
as e:
520 print(f
" Warning: export failed for '{rel}': {e}")
524 summary[rel] = (file_count, total_lines, total_words, total_bytes)
525 per_dir_files[rel] = files
529 root_out_dir = single_out_root
531 root_out_dir = root / f
"_source_code-{timestamp}"
533 prefix=
"_full_source")
536 if diff_filter
is not None:
537 all_files = [f
for f
in all_files
if f.resolve()
in diff_filter]
538 grouped: Dict[str, List[Path]] = {}
540 parent_rel = p.parent.relative_to(root)
541 key =
"." if str(parent_rel) ==
"." else str(parent_rel)
542 grouped.setdefault(key, []).append(p)
545 fh = full_output_path.open(
"w", encoding=
"utf-8")
547 print(f
" Warning: cannot write full-source file: {e}")
552 out.write(cfg[
"header_text"].format(directory=root))
553 out.write(cfg[
"root_header"])
555 for idx_dir, dirname
in enumerate(sorted(grouped.keys()), start=1):
556 out.write(cfg[
"separator_subdir"].format(num=idx_dir, dirname=dirname))
557 for idx_file, file_path
in enumerate(grouped[dirname], start=1):
558 rel_path = file_path.relative_to(root)
560 content = file_path.read_text(encoding=
"utf-8", errors=
"replace")
561 except (OSError, UnicodeDecodeError):
562 print(f
"Warning: Could not read {file_path}: skipping", file=sys.stderr)
566 if warn_secrets
and content:
567 abs_key = str(file_path.resolve())
569 abs_key, content, str(rel_path)
571 if abs_key
not in _scanned_secret_paths:
572 _scanned_secret_paths.add(abs_key)
573 for w
in sec_warnings:
574 print(f
"Warning: {w}", file=sys.stderr)
575 if block_secrets
and sec_warnings:
577 f
" Skipping {rel_path} due to potential secrets",
582 out.write(cfg[
"separator_file"].format(num=idx_file, path=str(rel_path)))
587 print(
"\n========== SUMMARY ==========")
591 for dirname
in sorted(summary.keys()):
592 files, lines, words, bytes_ = summary[dirname]
594 print(f
"Directory '{dirname}': {files} files, {lines} lines, {words} words, {kb:.1f} KB")
597 for p
in per_dir_files.get(dirname, []):
598 if p
not in seen_files:
601 fkb = fbytes / 1024.0
602 rel_path = p.relative_to(root)
603 print(f
" {rel_path} — {flines} lines, {fwords} words, {fkb:.1f} KB")
610 total_files, total_lines, total_words, total_bytes = summary[
"."]
612 total_files = total_lines = total_words = total_bytes = 0
616 total_lines += flines
617 total_words += fwords
618 total_bytes += fbytes
620 print(f
"\nTotal directories processed: {len(summary)}")
621 print(f
"Total source files processed: {total_files}")
622 print(f
"Total lines: {total_lines}")
623 print(f
"Total words: {total_words}")
624 print(f
"Total size: {total_bytes / 1024.0:.1f} KB")
626 print(f
"Output stored in single directory: {single_out_root}")
628 print(f
"Output stored under _source_code-{timestamp} directories")
632 print(f
"\nSkipped {n} director{'y' if n == 1 else 'ies'} due to errors:")
636 print(
"=============================\n")