Skip to content

Implement tomb-based rm replacement with recovery, indexing, and restore#1

Open
Copilot wants to merge 11 commits into
mainfrom
copilot/implement-tomb-based-rm-replacement
Open

Implement tomb-based rm replacement with recovery, indexing, and restore#1
Copilot wants to merge 11 commits into
mainfrom
copilot/implement-tomb-based-rm-replacement

Conversation

Copy link
Copy Markdown

Copilot AI commented Mar 20, 2026

Implements a Python-based safer rm replacement that moves files into versioned "tomb slots" instead of permanently deleting them, with full recovery support via unrm and a tomb management CLI.

Storage model

  • One slot directory per top-level deletion event: ~/.tomb/{slot-id}/ containing meta.json, optional entries.ndjson, and payload/
  • Slot IDs are lexically sortable and collision-resistant: 20260320T153255.123456Z-host-p12345-r4f9c2a1
  • SQLite (index.db) is a rebuildable cache; slot filesystem is source of truth

Commands

  • bin/rm — Drop-in wrapper supporting -r/-R/-f/-i/-v/--; combined flags; exit codes 0/1/2/130. Writes entries.ndjson only for verbose or interactive-recursive modes.
  • bin/unrm — Restores most-recent or named deletion to original path. No-clobber by default; -f to overwrite. TTY-aware: prompts when no arg given on a terminal. --to <path> supported for single-item slots only.
  • bin/tomb — Management subcommands: list, show, restore, purge, index, reindex, verify

Launcher pattern

All bin/ scripts use the POSIX shell+Python dual-mode pattern so python3 is resolved via shell PATH (supports virtualenvs, works without /usr/bin/env):

#! /bin/sh
""":"
exec python3 $0 ${1+"$@"}
"""
# Python follows

Key design points

  • meta.json preserves argv_path, original_abs_path, and stored_relpath as distinct fields
  • host_fqdn stored via socket.getfqdn()
  • Per-entry entries.ndjson written only when -v with recursion or -ri (not for every deletion)
  • Incremental index (tomb index) skips unchanged slots by mtime/hash; tomb reindex does a full rebuild
  • Slot directory created with os.mkdir (atomic); random suffix retry on collision; creation is deferred until the first actual file move (no empty slots for no-op invocations like rm -f /nonexistent)
  • Zero stdlib-external dependencies
  • TOMB_DIR environment variable supports ~-prefixed paths via os.path.expanduser()

File movement strategy

For regular files on the same filesystem as the tomb, rm creates a hard link in the payload then unlinks the original — matching native /bin/rm unlink semantics and avoiding unnecessary data copies. Directories, symlinks, and cross-filesystem files fall back to shutil.move.

Interactive recursive deletion (-ri)

Per-item prompts genuinely control what is moved: files and directories the user declines remain on disk. Only approved items are moved into the tomb payload. entries.ndjson records both "deleted" and "skipped" outcomes for each entry.

Original prompt

Implementation Contract: Tomb-Based rm Replacement

  1. Objective

Build a Python-based deletion and recovery system with these properties:

  • Near drop-in command compatibility for rm and core flags.
  • Recoverability through tomb slots.
  • Per-slot metadata as source of truth.
  • SQLite index for fast queries and recovery UX.
  • Incremental indexing by default, full reindex as fallback.
  1. Product Intent and Boundaries

In scope:

  • Replace interactive shell rm behavior with tomb-backed deletion.
  • Support recursive deletions and selective restore.
  • Support unrm command and tomb management command.
  • Preserve enough metadata for robust restore and auditing.

Out of scope:

  • Perfect syscall identity with native rm in every kernel edge case.
  • Transparent replacement of unlink syscall at libc or kernel boundary.
  • Remote distributed sync protocol for tomb data.
  1. Runtime and Launcher Contract

Implementation language:

  • Python 3

Launcher strategy:

  • Use shell-plus-python launcher style rather than relying on /usr/bin/env.
  • Required behavior is to execute python3 via shell PATH lookup.

Required launcher block pattern:

  • Must execute equivalent to: exec python3 "$0" "$@".
  • Must preserve argument forwarding exactly.
  • Must allow activated virtual environments to naturally override python3 via PATH.

Example shebang block:

#!/bin/sh
""":"
exec python3 "$0" "$@"
""":"

Rationale captured from decisions

  • Works on systems where /usr/bin/env may not exist.
  • Preserves long-standing shell portability behavior.
  1. Compatibility Target

Target level

  • Near drop-in CLI compatibility, not strict syscall equivalence.

Must match user expectations for:

  • Option parsing behavior for common flags.
  • TTY prompting semantics for interactive modes.
  • Exit code behavior and basic error messaging shape.
  • Recursive deletion semantics and force handling.

Known semantic differences allowed:

  • Objects are retained in tomb storage for recovery.
  • Additional metadata and index artifacts are created.
  1. Commands and CLI Surface

5.1 rm wrapper behavior

Supported options at minimum:

  • recursive: -r, -R
  • force: -f
  • interactive: -i
  • verbose: -v
  • end-of-options marker: --

Compatibility notes:

  • Accept combined flags.
  • Unknown options fail non-zero.
  • No target fails non-zero.
  • Non-option arguments are deletion targets in order.
  • If confused, match GNU rm behavior as closely as possible.

Behavior by mode:

  • rm path
    • default event-level deletion record.
  • rm -r path
    • event-level deletion record for tree target.
  • rm -rf path
    • force suppresses missing-target errors.
  • rm -v path or rm -rfv path
    • emit per-item user output.
    • write entry detail records for deleted entries.
  • rm -ri path
    • per-item prompts while descending.
    • write entry detail records including deleted and skipped outcomes.

Granularity contract:

  • Default behavior for recursive deletion is one event row and one slot per top-level argument.
  • Detailed per-entry tracking is enabled when semantics require or strongly imply detail:
    • interactive recursive runs (-ri)
    • verbose recursive runs (-v with recursion)

5.2 unrm behavior

Primary command:

  • unrm is the user-facing restore command. It's likely a thin wrapper around tomb restore with some UX conveniences.

Default behavior:

  • Quiet on success.
  • Error output only on failure.
  • Restore should work regardless of current directory.
  • Collision handling is explicit and safe by default.

Argument behavior:

  • Preferred default is explicit restore target argument.
  • Optional extension agreed during design:
    • if no argument and TTY: propose most recent deletion and prompt yes or no.
    • if no argument and non-TTY: restore most recent deletion without prompt.

Flags:

  • force overwrite existing restore destination.
  • interactive restore confirmations.
  • verbose restore output.
  • optional target-path override.
  • If confused, match GNU rm behavior as closely as possible.

Ambiguity policy:

  • If argument matches multiple candidates:
    • TTY: prompt selection.
    • non-TTY: fail unless explicit disambiguator provided.

5.3 tomb command family

Required subcommands:

  • list
  • show
  • restore
  • purge
  • index
  • reindex
  • verify

Optional but recommended:

  • entries
  • restore-entry
  • doctor
  1. Storage Model

6.1 Source of truth

Source of truth is tomb slot filesystem content:

  • per-slot metadata file
  • optional per-slot entries detail file
  • payload files and directories

SQLite is index or cache:

  • SQLite is rebuildable from slot files.
  • Tomb must remain usable when DB is missing or corrupt.

6.2 Slot layout

One slot directory per top-level deletion event.

Example shape:

  • tomb root
    • slot-id
      • meta.json
      • entries.ndjson optional
      • payload
        • item data

6.3 Slot identifier

Slot ID requirements:

  • Unique under concurrent writers.
  • Lexically sortable by deletion time.
  • Human-debuggable but not user-facing API.

Recommended form:

  • UTC timestamp with sub-second precision ...

⚡ Quickly spin up Copilot coding agent tasks from anywhere on your macOS or Windows machine with Raycast.

Copilot AI and others added 3 commits March 20, 2026 21:45
- tomb_pkg/slot.py: slot ID generation, atomic directory creation, meta.json R/W, entries.ndjson
- tomb_pkg/index.py: SQLite index with events/entries/slots_index tables; incremental + full reindex; verify_slot
- tomb_pkg/restore.py: restore payload to original paths; no-clobber default; -f overwrite
- tomb_pkg/cli/rm_cmd.py: GNU rm-compatible flag parser; moves files to tomb slot
- tomb_pkg/cli/unrm_cmd.py: restore most-recent deletion; --to override; TTY prompt
- tomb_pkg/cli/tomb_cmd.py: list/show/restore/purge/index/reindex/verify subcommands
- bin/rm, bin/unrm, bin/tomb: shell+python dual-mode launchers (chmod +x)
- tests/: 37 pytest tests covering slot, rm, unrm, tomb (all passing)
- .github/workflows/ci.yml: CI with Python 3.11 and pytest
- pyproject.toml: project metadata

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI changed the title [WIP] Build Python-based tomb-backed deletion and recovery system Implement tomb-based rm replacement with recovery, indexing, and restore Mar 20, 2026
Copilot AI requested a review from ahernsean March 20, 2026 21:49
@ahernsean ahernsean requested review from Copilot and removed request for ahernsean March 21, 2026 04:20
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements a tomb-based safer rm system in Python, introducing slot-based storage (filesystem source of truth), restore workflows (unrm / tomb restore), and a rebuildable SQLite index to support listing/verification and recovery UX.

Changes:

  • Added core slot, restore, and indexing modules (tomb_pkg.slot, tomb_pkg.restore, tomb_pkg.index) plus CLIs for rm, unrm, and tomb.
  • Added CLI launchers in bin/ using the shell+Python dual-mode pattern.
  • Added pytest coverage and CI workflow to run tests on PRs.

Reviewed changes

Copilot reviewed 14 out of 32 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
tomb_pkg/slot.py Slot root discovery, ID generation, meta/entries IO, slot listing.
tomb_pkg/restore.py Slot restore logic and helpers to find latest slots.
tomb_pkg/index.py SQLite index schema + incremental/full indexing and slot verification.
tomb_pkg/cli/init.py Shared exit code constants for CLI commands.
tomb_pkg/cli/rm_cmd.py rm wrapper logic to move targets into tomb slots and emit entries.ndjson.
tomb_pkg/cli/unrm_cmd.py unrm argument parsing and restore flow (latest slot selection + prompts).
tomb_pkg/cli/tomb_cmd.py tomb subcommands for list/show/restore/purge/index/reindex/verify.
bin/rm Dual-mode launcher for rm CLI entry point.
bin/unrm Dual-mode launcher for unrm CLI entry point.
bin/tomb Dual-mode launcher for tomb CLI entry point.
pyproject.toml Project metadata, tomb console script entry, pytest config.
.github/workflows/ci.yml GitHub Actions CI job to run pytest.
tests/test_rm.py Tests for tomb-backed rm behaviors and modes.
tests/test_unrm.py Tests for restoring deleted items via unrm (incl. no-clobber/force).
tests/test_tomb.py Tests for tomb subcommands and index/verify behavior.
tests/test_slot.py Tests for slot ID format/uniqueness, meta IO, concurrency, kind detection.
tests/init.py Marks tests as a package (empty).
.gitignore Ignores Python build/test artifacts (pyc, caches, build outputs).
tomb_pkg/init.py Package marker / top-level description comment.
tomb_pkg/pycache/init.cpython-312.pyc Compiled artifact committed (should be removed).
tomb_pkg/pycache/index.cpython-312.pyc Compiled artifact committed (should be removed).
tomb_pkg/pycache/restore.cpython-312.pyc Compiled artifact committed (should be removed).
tomb_pkg/pycache/slot.cpython-312.pyc Compiled artifact committed (should be removed).
tomb_pkg/cli/pycache/init.cpython-312.pyc Compiled artifact committed (should be removed).
tomb_pkg/cli/pycache/rm_cmd.cpython-312.pyc Compiled artifact committed (should be removed).
tomb_pkg/cli/pycache/tomb_cmd.cpython-312.pyc Compiled artifact committed (should be removed).
tomb_pkg/cli/pycache/unrm_cmd.cpython-312.pyc Compiled artifact committed (should be removed).
tests/pycache/init.cpython-312.pyc Compiled artifact committed (should be removed).
tests/pycache/test_slot.cpython-312-pytest-9.0.2.pyc Compiled artifact committed (should be removed).
tests/pycache/test_tomb.cpython-312-pytest-9.0.2.pyc Compiled artifact committed (should be removed).
tests/pycache/test_unrm.cpython-312-pytest-9.0.2.pyc Compiled artifact committed (should be removed).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tomb_pkg/cli/rm_cmd.py Outdated
Comment on lines +98 to +101
if interactive and not _prompt_confirm(f"rm: remove {kind} '{full}'? "):
action = "skipped"
result = "user-declined"

Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In interactive recursive mode (-ri), the per-entry prompts here only change the recorded NDJSON entry (action/result) but don’t affect what actually gets moved: _delete_target() later shutil.move()s the whole directory tree regardless, so “skipped” items are still removed. This breaks interactive semantics; either implement selective moves based on the prompts, or change behavior to prompt once for the top-level directory and skip the entire deletion if declined.

Copilot uses AI. Check for mistakes.
Comment thread tomb_pkg/slot.py
Comment on lines +18 to +21
env = os.environ.get("TOMB_DIR")
if env:
return Path(env)
return Path.home() / ".tomb"
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TOMB_DIR is returned as a raw Path(env), which won’t expand ~ (common in env vars) and can lead to unexpected tomb locations. Consider using Path(os.path.expanduser(env)) (and possibly resolving it) before returning.

Copilot uses AI. Check for mistakes.
Comment thread tomb_pkg/restore.py
Comment on lines +59 to +77
def restore_slot(
slot_dir: Path,
force: bool = False,
verbose: bool = False,
dest_override: Optional[str] = None,
) -> List[str]:
"""Restore all items from a slot. Returns list of restored destination paths."""
meta = read_meta(slot_dir)
payload_dir = slot_dir / "payload"
restored: List[str] = []

for item in meta.get("items", []):
dest = _restore_item(
item,
payload_dir,
force=force,
dest_override=dest_override,
verbose=verbose,
)
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dest_override is applied to every item in the slot. If a slot contains multiple items, --to <path> will attempt to restore multiple payloads to the same destination path, causing partial restores (DestinationExistsError) or data loss when force=True. Consider rejecting dest_override when the slot has more than one item, or interpreting it as a destination directory and restoring each item underneath it.

Copilot uses AI. Check for mistakes.
Comment thread tomb_pkg/cli/tomb_cmd.py Outdated
Comment on lines +71 to +83
if not args:
print("tomb restore: requires a SLOT argument", file=sys.stderr)
return 2

slot_id = args[0]
slot_dir = tomb_root / slot_id
if not slot_dir.is_dir():
print(f"tomb restore: slot not found: {slot_id}", file=sys.stderr)
return 1

force = "-f" in args
verbose = "-v" in args

Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tomb restore currently assumes the first arg is the slot ID and only checks for "-f"/"-v" anywhere in args. This means common forms like tomb restore -f <slot> will be treated as slot_id="-f" and fail. Consider parsing options first (or using argparse), allowing flags before/after the slot argument, and rejecting unknown flags consistently.

Suggested change
if not args:
print("tomb restore: requires a SLOT argument", file=sys.stderr)
return 2
slot_id = args[0]
slot_dir = tomb_root / slot_id
if not slot_dir.is_dir():
print(f"tomb restore: slot not found: {slot_id}", file=sys.stderr)
return 1
force = "-f" in args
verbose = "-v" in args
force = False
verbose = False
slot_id: Optional[str] = None
for arg in args:
if arg == "-f":
force = True
elif arg == "-v":
verbose = True
elif arg.startswith("-"):
print(f"tomb restore: unknown option: {arg}", file=sys.stderr)
return 2
elif slot_id is None:
slot_id = arg
else:
print(f"tomb restore: unexpected extra argument: {arg}", file=sys.stderr)
return 2
if slot_id is None:
print("tomb restore: requires a SLOT argument", file=sys.stderr)
return 2
slot_dir = tomb_root / slot_id
if not slot_dir.is_dir():
print(f"tomb restore: slot not found: {slot_id}", file=sys.stderr)
return 1

Copilot uses AI. Check for mistakes.
Comment thread tomb_pkg/cli/rm_cmd.py Outdated
Comment on lines +232 to +238
root = tomb_root if tomb_root is not None else _tomb_root()
now = datetime.now(timezone.utc)
deleted_at = now.strftime("%Y-%m-%dT%H:%M:%S.%f") + "Z"
cwd = os.getcwd()

slot_dir = create_slot(root)
slot_id = slot_dir.name
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

create_slot() is called before verifying that any target will actually be moved. As a result, cases like rm -f /missing (or all interactive declines) will still create a new slot directory and write meta with an empty items list, polluting the tomb with empty events. Consider delaying slot creation until the first successful move, or deleting the slot directory if nothing was deleted.

Copilot uses AI. Check for mistakes.
Comment thread tomb_pkg/index.py Outdated
sp = item.get("stored_relpath")
if sp and payload_path.is_dir():
full = payload_path / sp
if not full.exists():
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

verify_slot() treats a payload symlink as missing when it’s broken because Path.exists() follows symlinks. Slots can legitimately contain symlinks (including broken ones), and restore logic already handles that with os.path.islink(). Consider checking full.exists() or full.is_symlink() (or os.path.islink(full)), so symlink payloads aren’t incorrectly reported as missing.

Suggested change
if not full.exists():
if not (full.exists() or full.is_symlink()):

Copilot uses AI. Check for mistakes.
Comment thread tomb_pkg/cli/tomb_cmd.py Outdated
Comment on lines +23 to +25
conn = open_db(tomb_root)
incremental_index(tomb_root)
conn.close()
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_cmd_list opens the DB twice (and the first connection is only used to immediately call incremental_index, which opens its own DB connection). This adds unnecessary work and file handles; consider letting incremental_index() handle DB initialization, then opening a single connection just for query_events.

Suggested change
conn = open_db(tomb_root)
incremental_index(tomb_root)
conn.close()
incremental_index(tomb_root)

Copilot uses AI. Check for mistakes.
Copilot AI and others added 2 commits March 21, 2026 04:31
Add inline comments throughout the codebase
@ahernsean ahernsean force-pushed the copilot/implement-tomb-based-rm-replacement branch from 367af54 to bc8b8f4 Compare March 21, 2026 17:46
@ahernsean ahernsean marked this pull request as ready for review March 21, 2026 17:46
@ahernsean
Copy link
Copy Markdown
Owner

ahernsean commented Mar 21, 2026

Three things:

  • Get rid of all __pycache__ files from the repo. They should never have gotten in there in the first place.
  • Fix the .gitignore file so that future pycache files don't get committed.
  • I thought we were going to do "move" only if the file was on another file system. If the file is on the same file system as the tomb, create a hardlink in the tomb then do a proper unlink of the original file. This has closer behavior to the actual /bin/rm. Or are we actually doing this already? Give me a report.

@ahernsean
Copy link
Copy Markdown
Owner

@copilot apply changes based on the comments in this thread

Copilot AI and others added 2 commits March 21, 2026 17:54
Co-authored-by: ahernsean <1649300+ahernsean@users.noreply.github.com>
Agent-Logs-Url: https://github.com/ahernsean/tomb/sessions/22ace665-f326-43da-89ca-3b987c683bc8
Copy link
Copy Markdown
Author

Copilot AI commented Mar 21, 2026

Three things:

  • Get rid of all __pycache__ files from the repo. They should never have gotten in there in the first place.
  • Fix the .gitignore file so that future pycache files don't get comm...

Done in commit d91725a:

  • All __pycache__ directories and .pyc files have been removed from git tracking via git rm --cached. The .gitignore already had the correct __pycache__/ and *.pyc patterns — those files were tracked because they were committed before .gitignore was in place.

Hardlink strategy: The previous implementation used shutil.move() for everything. Now _move_to_payload() in rm_cmd.py uses os.link() + os.unlink() for same-filesystem regular files (same st_dev), and falls back to shutil.move() for directories, symlinks, and cross-filesystem cases. This is closer to how /bin/rm (unlink syscall) works and avoids unnecessary data copies. The new test_rm_hardlink_same_filesystem test verifies the inode is preserved in the payload after deletion.

@copilot apply changes based on the comments in this thread

All 7 review comments addressed in commit d91725a:

  1. Interactive -ri actually skips files — Replaced the old _walk_entries + shutil.move (which moved everything regardless) with _interactive_recursive_move() that walks the tree item-by-item, prompts for each file/subdir, and only moves approved items. Declined files stay on disk. entries.ndjson records both "deleted" and "skipped" outcomes accurately.

  2. TOMB_DIR ~ expansionslot.py now calls os.path.expanduser() on the env var value.

  3. dest_override with multiple itemsrestore.py raises RestoreError when --to is used with a slot containing more than one item.

  4. tomb restore arg parsing_cmd_restore() now parses options before and after the slot ID properly, so tomb restore -f <slot> works correctly.

  5. Slot created before any targets validatedrm_main now uses a lazy _ensure_slot() callable; the slot directory is only created on the first actual file move. rm -f /nonexistent no longer pollutes the tomb with empty slots.

  6. verify_slot broken symlinks — Changed full.exists() to full.exists() or full.is_symlink() so broken symlinks in the payload are not falsely reported as missing.

  7. _cmd_list redundant DB open — Removed the first open_db() call; incremental_index() manages its own connection, and we open one connection after it completes.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 19 changed files in this pull request and generated 11 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tomb_pkg/cli/tomb_cmd.py Outdated
return EXIT_USER_INTERRUPT
if answer not in ("y", "yes"):
print("Aborted.")
return EXIT_USER_INTERRUPT
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In tomb purge, if the user answers anything other than yes, the command returns EXIT_USER_INTERRUPT (130). That exit code generally implies Ctrl-C; for a normal "no"/abort it should return 0 (no changes) or a dedicated non-error code. Reserve 130 for actual KeyboardInterrupt/EOF only.

Suggested change
return EXIT_USER_INTERRUPT
return 0

Copilot uses AI. Check for mistakes.
Comment thread tomb_pkg/cli/rm_cmd.py Outdated
Comment on lines +288 to +291
if flags["interactive"] and not os.path.isdir(target):
kind_str = detect_kind(target)
if not _prompt_confirm(f"rm: remove {kind_str} '{target}'? "):
return {} # user declined, not an error
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interactive prompting for non-directories uses not os.path.isdir(target), but os.path.isdir() follows symlinks. A symlink to a directory will skip the -i prompt and be removed immediately. Use a non-following check (e.g., os.path.islink()/os.stat(..., follow_symlinks=False)/Path.is_dir() with symlink handling) or base the decision on detect_kind() instead.

Copilot uses AI. Check for mistakes.
Comment thread tomb_pkg/cli/rm_cmd.py Outdated
Comment on lines +70 to +71
except (EOFError, KeyboardInterrupt):
# Treat EOF (piped input exhausted) or Ctrl-C as a 'no'
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_prompt_confirm catches KeyboardInterrupt and treats it as a simple "no". That prevents the caller from returning exit code 130 and can cause rm to continue processing later targets after Ctrl-C. Consider letting KeyboardInterrupt propagate (or returning a sentinel) so rm_main can stop immediately and return 130 as advertised.

Suggested change
except (EOFError, KeyboardInterrupt):
# Treat EOF (piped input exhausted) or Ctrl-C as a 'no'
except EOFError:
# Treat EOF (piped input exhausted) as a 'no'

Copilot uses AI. Check for mistakes.
Comment thread tomb_pkg/cli/rm_cmd.py
Comment on lines +190 to +223
if child.is_dir(follow_symlinks=False):
child_slot_relpath = os.path.join(slot_relpath, child.name).replace("\\", "/")
if _prompt_confirm(f"rm: descend into directory '{child_path}'? "):
child_moved = _interactive_recursive_move(
child_path,
src_top,
payload_dir,
child_slot_relpath,
slot_dir,
slot_id,
arg_index,
ordinal_counter,
verbose,
)
if child_moved:
moved_anything = True
action = "deleted" if child_moved else "skipped"
result = "ok" if child_moved else "user-declined"
stored = child_slot_relpath if child_moved else None
else:
action, result, stored = "skipped", "user-declined", None

entry = {
"slot": slot_id,
"arg_index": arg_index,
"rel_path": rel_path,
"kind": "dir",
"action": action,
"result": result,
"stored_relpath": stored,
"ordinal": ordinal_counter[0],
}
ordinal_counter[0] += 1
append_entry(slot_dir, entry)
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For directory entries in interactive recursive mode, action is derived from child_moved (whether anything inside was moved), not whether the directory itself was actually removed. If some children are declined, the directory will remain on disk but is still recorded as action='deleted'. Consider setting the directory's action/result based on whether os.rmdir(child_path) succeeded (or whether the directory still exists) so entries.ndjson accurately reflects outcomes.

Copilot uses AI. Check for mistakes.
Comment thread tomb_pkg/cli/tomb_cmd.py Outdated
Comment on lines +106 to +107
for dest in restored:
print(f"restored: {dest}")
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In tomb restore, output is duplicated when -v is used: restore_slot(..., verbose=True) prints restored: ... and then this command prints restored: ... again for each item. Consider making restore_slot pure (no printing) and letting the CLI print, or in this command only printing when verbose is false / passing verbose=False into restore_slot and handling all output here.

Suggested change
for dest in restored:
print(f"restored: {dest}")
if not verbose:
for dest in restored:
print(f"restored: {dest}")

Copilot uses AI. Check for mistakes.
Comment thread tomb_pkg/cli/rm_cmd.py
Comment on lines +253 to +257
try:
os.rmdir(str(src))
except OSError:
pass # Directory still has items — user declined some

Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_interactive_recursive_move attempts os.rmdir(src) even when no payload content was created for this directory. For an empty directory (or a directory that only contained empty subdirs), this will delete the directory from disk but moved_anything remains False, so the caller treats it as "nothing moved" and may omit it from meta.json—making the deletion non-recoverable and potentially creating an empty/incorrect slot. Directory deletion in interactive mode needs to preserve empty directories in the payload and treat successful directory removal as a moved/deleted outcome.

Suggested change
try:
os.rmdir(str(src))
except OSError:
pass # Directory still has items — user declined some
removed_dir = False
try:
os.rmdir(str(src))
removed_dir = True
except OSError:
# Directory still has items — user declined some
removed_dir = False
if removed_dir:
# Ensure an empty directory exists in the payload to represent this deletion
dest_dir = payload_dir / slot_relpath
dest_dir.mkdir(parents=True, exist_ok=True)
rel_path = os.path.relpath(src, src_top).replace("\\", "/")
entry = {
"slot": slot_id,
"arg_index": arg_index,
"rel_path": rel_path,
"kind": "dir",
"action": "deleted",
"result": "ok",
"stored_relpath": slot_relpath,
"ordinal": ordinal_counter[0],
}
ordinal_counter[0] += 1
append_entry(slot_dir, entry)
moved_anything = True

Copilot uses AI. Check for mistakes.
Comment thread tomb_pkg/cli/rm_cmd.py Outdated
Comment on lines +331 to +332
# User declined every item — nothing was moved; do not record in items
return {}
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In interactive recursive mode, moved being False is assumed to mean "user declined every item". However, _interactive_recursive_move can return False even after deleting the directory itself (e.g., empty directories), so this can drop the top-level directory from items and make it unrestorable. Adjust the recursive helper to report directory deletion accurately (including empty dirs) so the top-level item is recorded and recoverable.

Suggested change
# User declined every item — nothing was moved; do not record in items
return {}
# If the helper reports that nothing was moved but the directory no longer
# exists, then the directory itself was removed (e.g., empty dir) and
# must still be recorded so it remains recoverable.
if os.path.exists(target):
# User declined every item and the directory is still present —
# nothing was moved; do not record in items.
return {}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot verify and fix this

Comment thread tomb_pkg/index.py Outdated
Comment on lines +6 to +7
import json
import os
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

index.py imports json and os but doesn't use either; this triggers lint warnings and adds noise. Remove unused imports to keep the module tidy.

Suggested change
import json
import os

Copilot uses AI. Check for mistakes.
Comment thread tomb_pkg/cli/rm_cmd.py
Comment on lines +116 to +129
for dirpath, dirnames, filenames in os.walk(str(src)):
# Record subdirectory entries (directories themselves, not their contents again)
for dname in dirnames:
full = os.path.join(dirpath, dname)
rel = os.path.relpath(full, str(src.parent))
stored = os.path.join(
stored_relpath_base, os.path.relpath(full, str(src))
).replace("\\", "/")
entry = {
"slot": slot_id,
"arg_index": arg_index,
"rel_path": rel.replace("\\", "/"),
"kind": "dir",
"action": "deleted",
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In _walk_entries, entries for dirnames are hard-coded with kind: "dir", but os.walk(..., followlinks=False) can still list symlinked directories in dirnames. Those would be misclassified (should be symlink) and could confuse entries.ndjson consumers. Consider using detect_kind(full) here (and, if needed, ensuring you don't recurse into symlinked dirs).

Copilot uses AI. Check for mistakes.
Comment thread tomb_pkg/slot.py Outdated
Comment on lines +34 to +39
# Use only the short hostname (before the first dot) to keep IDs readable
host = socket.gethostname().split(".")[0]
pid = os.getpid()
# 4 random bytes (8 hex chars) make collisions within the same microsecond extremely unlikely
rand = secrets.token_hex(4)
return f"{ts}.{us}Z-{host}-p{pid}-r{rand}"
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

generate_slot_id() embeds the raw short hostname between - separators. Many hostnames contain hyphens, which breaks the documented/assumed slot-id shape (and the test regex in tests/test_slot.py expects the host segment to contain no -). Consider sanitizing the hostname component (e.g., strip/replace - and other non-alphanumerics) or using a different delimiter/escaping so slot IDs are unambiguous and tests/consumers are stable across environments.

Copilot uses AI. Check for mistakes.
@ahernsean
Copy link
Copy Markdown
Owner

@copilot Your shebang is not what I asked for. It should be this precisely:

#! /bin/sh
""":"
exec python3 $0 ${1+"$@"}
"""

No extra lines.

Co-authored-by: ahernsean <1649300+ahernsean@users.noreply.github.com>
Agent-Logs-Url: https://github.com/ahernsean/tomb/sessions/d4315ac0-302a-4c5d-9006-c82e8174d23d
Copy link
Copy Markdown
Author

Copilot AI commented Mar 21, 2026

@copilot Your shebang is not what I asked for. It should be this precisely:

...

Fixed in commit bcb684e. All three bin/ scripts now use exactly:

#! /bin/sh
""":"
exec python3 $0 ${1+"$@"}
"""

@ahernsean
Copy link
Copy Markdown
Owner

@copilot apply changes based on the unresolved comments in this thread
For every comment that you make a code change for, mark it as resolved here in GitHub.

Copy link
Copy Markdown
Author

Copilot AI commented Mar 21, 2026

@copilot apply changes based on the unresolved comments in this thread
For every comment that you make a cod...

All 11 unresolved comments addressed in commit cb1b71a:

  1. index.py — Removed unused json and os imports
  2. slot.py — Hostname hyphens replaced with underscores in generate_slot_id() so slot IDs remain unambiguous (e.g. web-server-01web_server_01)
  3. rm_cmd.py _prompt_confirm — Now only catches EOFError; KeyboardInterrupt propagates to the caller
  4. rm_cmd.py rm_main — Catches KeyboardInterrupt, writes partial metadata if a slot was started, returns exit code 130
  5. rm_cmd.py _walk_entries — Uses detect_kind(full) for directory entries so symlinked directories are correctly classified as symlink not dir
  6. rm_cmd.py _interactive_recursive_move — Directory action/result now reflects whether the directory itself was actually removed (os.path.lexists) rather than whether any children were moved
  7. rm_cmd.py _interactive_recursive_move — Empty directories removed in interactive mode now create a placeholder in the payload (recoverable) and set moved_anything=True
  8. rm_cmd.py _delete_target — Interactive prompt check uses detect_kind(target) != "dir" so symlinks-to-directories also get a prompt; uses os.path.lexists for the safety check
  9. tomb_cmd.py _cmd_restore — Verbose output no longer duplicated; CLI only prints when verbose=False (since restore_slot already prints when verbose)
  10. tomb_cmd.py _cmd_purge — User answering "no" returns 0 (not 130); 130 is reserved for actual KeyboardInterrupt/EOF
  11. unrm_cmd.py — User answering "no" returns 0 (not 130)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants