122 lines
4.9 KiB
Python
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))
|