Files
soleprint/soleprint/station/tools/histgen/snapshot.py

122 lines
4.9 KiB
Python

"""
Copy a repo's files out of it, without the repo.
Named snapshot.py and not copy.py, which is what it was for about ten minutes.
A module called `copy` beside the code shadows the standard library's, and the
directory lands on sys.path whenever anything is run from inside it — so
`dataclasses` imported this file instead, and every command died on an import
error before parsing a single argument. The verb is still `copy`; the file
cannot be.
The plain utility underneath everything else: point it at a tree, get a folder
holding what the project actually is — no `.git`, nothing gitignored, nothing a
build regenerates, and nothing that looks like a key.
It is the thing to reach for when the history is not the point. Handing a
snapshot to someone, feeding a tree to something that should not see the
history, or getting a clean starting tree before planning one.
What is dropped is reported and written to a manifest, never assumed. A file
missing from a copy without a line saying so is the same failure this whole
tool exists to prevent, one directory earlier.
"""
import shutil
from pathlib import Path
from .census import file_set, ignored_but_tracked, is_git, state_dir
from .cli import fail
from .sift import REASONS, sift
MANIFEST = "copied.md"
def destination(source: Path, out: Path) -> Path:
"""`out/<name>`, so the folder keeps the name the thing already had."""
return Path(out) / source.name
def take(source: Path, out, keep_noise=False, keep_secrets=False, max_bytes=None,
exclude=(), include=(), force=False, dry_run=False, quiet=False):
dest = destination(source, out)
if dest.exists() and any(dest.iterdir()) and not force and not dry_run:
fail(f"{dest} already exists and is not empty.",
"Pass --force to write into it anyway, or point --out elsewhere.")
paths = file_set(source)
kept, dropped = sift(source, paths, keep_noise=keep_noise,
keep_secrets=keep_secrets, max_bytes=max_bytes,
exclude=exclude, include=include,
ignored=ignored_but_tracked(source, paths))
if not quiet:
print(f"{len(paths)} files tracked, {len(kept)} to copy, {len(dropped)} left behind.")
by_reason = {}
for rel, why in dropped:
by_reason.setdefault(why, []).append(rel)
for why in ("secret", "ignored", "derived", "oversize", "excluded"):
hits = by_reason.get(why)
if not hits:
continue
# Secrets are listed in full however many there are. The others are
# bulk and a count is enough; a key that got dropped is a thing you
# want to see the name of, because it means it was tracked.
shown = hits if why in ("secret", "ignored") else hits[:5]
print(f"\n {why}{REASONS[why]} ({len(hits)}):")
for rel in shown:
print(f" {rel}")
if len(hits) > len(shown):
print(f" ... and {len(hits) - len(shown)} more")
if dry_run:
if not quiet:
print(f"\nNothing written. Would copy to {dest}.")
return {"kept": kept, "dropped": dropped, "dest": dest}
dest.mkdir(parents=True, exist_ok=True)
for rel in kept:
target = dest / rel
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source / rel, target)
_manifest(source, out, dest, paths, kept, dropped)
if not quiet:
print(f"\nCopied to {dest}")
print(f" no .git — {'the source has one and it was not copied'
if is_git(source) else 'the source has none either'}")
print(f" what was left behind: {state_dir(out) / MANIFEST}")
return {"kept": kept, "dropped": dropped, "dest": dest}
def _manifest(source, out, dest, paths, kept, dropped):
"""A record of the decision, beside the copy rather than inside it."""
lines = [
f"# Copied from `{source}`", "",
f"- source: `{source}`",
f"- copy: `{dest}`",
f"- {len(paths)} files tracked, {len(kept)} copied, {len(dropped)} left behind",
"",
"No `.git` was copied. The file list is what git tracks, so nothing "
"untracked or ignored came across — except where a file was tracked "
"*despite* the ignore rules, which is listed below rather than assumed.",
"",
]
by_reason = {}
for rel, why in dropped:
by_reason.setdefault(why, []).append(rel)
for why in ("secret", "ignored", "derived", "oversize", "excluded"):
hits = by_reason.get(why)
if not hits:
continue
lines += [f"## {why}{REASONS[why]}", ""]
lines += [f"- `{rel}`" for rel in hits]
lines.append("")
if not dropped:
lines += ["Nothing was left behind.", ""]
path = state_dir(out) / MANIFEST
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("\n".join(lines))