add an eden remove command

Summary:
Add a `remove` command to the Eden CLI.  This behaves like
`eden unmount --destroy`, but calling this "remove" is hopefully a more
intuitive UI.  If stdin is a TTY this command also prompts the user for
confirmation before removing the checkout.

I plan to deprecate the `eden unmount --destroy` command in a subsequent
diff.

Reviewed By: wez

Differential Revision: D8086823

fbshipit-source-id: 562cf0f998eea416b80589b188eee255a10b9699
This commit is contained in:
Adam Simpkins 2018-05-22 19:45:21 -07:00 committed by Facebook Github Bot
parent f8b2f2830c
commit b32e6b3e57
7 changed files with 127 additions and 24 deletions

View File

@ -12,12 +12,14 @@ import errno
import glob
import json
import os
import readline # noqa: F401 Importing readline improves the behavior of input()
import signal
import subprocess
import sys
import typing
from typing import Any, List, Optional, Set, Tuple
import eden.thrift
from eden.thrift import EdenNotRunningError
from facebook.eden import EdenService
@ -71,7 +73,7 @@ def do_version(args: argparse.Namespace) -> int:
print("Running: %s" % rv)
if rv.startswith("-") or rv.endswith("-"):
print("(Dev version of eden seems to be running)")
except eden.thrift.client.EdenNotRunningError:
except EdenNotRunningError:
print("Running: Unknown (edenfs does not appear to be running)")
return 0
@ -445,6 +447,66 @@ class MountCmd(Subcmd):
return 0
@subcmd("remove", "Remove an eden checkout", aliases=["rm"])
class RemoveCmd(Subcmd):
def setup_parser(self, parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"-y",
"--yes",
"--no-prompt",
dest="prompt",
default=True,
action="store_false",
help="Do not prompt for confirmation before removing the checkouts.",
)
parser.add_argument(
"paths", nargs="+", metavar="path", help="The Eden checkout(s) to remove"
)
def run(self, args: argparse.Namespace) -> int:
config = create_config(args)
# First translate the list of paths into mount point names
mounts = []
for path in args.paths:
try:
mount_path = util.get_eden_mount_name(path)
except util.NotAnEdenMountError as ex:
print(f"error: {ex}")
return 1
except Exception as ex:
print(f"error: cannot determine moint point for {path}: {ex}")
return 1
mounts.append(mount_path)
# Warn the user since this operation permanently destroys data
if args.prompt and sys.stdin.isatty():
mounts_list = "\n ".join(mounts)
print(
f"""\
Warning: this operation will permanently delete the following checkouts:
{mounts_list}
Any uncommitted changes and shelves in this checkout will be lost forever."""
)
if not prompt_confirmation("Proceed?"):
print("Not confirmed")
return 2
# Unmount + destroy everything
for mount in mounts:
print(f"Removing {mount}...")
try:
config.unmount(mount, delete_config=True)
except EdenService.EdenError as ex:
print_stderr("error: {}", ex)
return 1
print(f"Success")
return 0
@subcmd("unmount", "Unmount a specific checkout")
class UnmountCmd(Subcmd):
@ -527,7 +589,7 @@ class StartCmd(Subcmd):
)
def stop_aux_processes(client) -> None:
def stop_aux_processes(client: eden.thrift.EdenClient) -> None:
"""Tear down processes that will hold onto file handles and prevent shutdown"""
active_mount_points: Set[Optional[str]] = {
@ -738,16 +800,16 @@ def create_parser() -> argparse.ArgumentParser:
return parser
def main() -> int:
parser = create_parser()
args = parser.parse_args()
if args.version:
return do_version(args)
if getattr(args, "func", None) is None:
parser.print_help()
return 0
return_code: int = args.func(args)
return return_code
def prompt_confirmation(prompt: str) -> bool:
prompt_str = f"{prompt} [y/N] "
while True:
response = input(prompt_str)
value = response.lower()
if value in ("y", "yes"):
return True
if value in ("", "n", "no"):
return False
print('Please enter "yes" or "no"')
def normalize_path_arg(path_arg: str, may_need_tilde_expansion: bool = False) -> str:
@ -766,6 +828,18 @@ def normalize_path_arg(path_arg: str, may_need_tilde_expansion: bool = False) ->
return path_arg
def main() -> int:
parser = create_parser()
args = parser.parse_args()
if args.version:
return do_version(args)
if getattr(args, "func", None) is None:
parser.print_help()
return 0
return_code: int = args.func(args)
return return_code
if __name__ == "__main__":
retcode = main()
sys.exit(retcode)

View File

@ -39,6 +39,15 @@ class ShutdownError(Exception):
pass
class NotAnEdenMountError(Exception):
def __init__(self, path: str) -> None:
self.path = path
def __str__(self) -> str:
return f"{self.path} does not appear to be inside an Eden checkout"
class HealthStatus(object):
def __init__(self, status: int, pid: Optional[int], detail: str) -> None:
@ -423,3 +432,19 @@ def read_all(path: str) -> str:
"""One-liner to read the contents of a file and properly close the fd."""
with open(path, "r") as f:
return f.read()
def get_eden_mount_name(path_arg: str) -> str:
"""
Get the path to the Eden checkout containing the specified path
"""
path = os.path.join(path_arg, ".eden", "root")
try:
return os.readlink(path)
except OSError as ex:
if ex.errno == errno.ENOTDIR:
path = os.path.join(os.path.dirname(path_arg), ".eden", "root")
return os.readlink(path)
elif ex.errno == errno.ENOENT:
raise NotAnEdenMountError(path_arg)
raise

View File

@ -216,13 +216,17 @@ class BasicTest(testcase.EdenRepoTest):
msg="attempting to run noexec.sh should fail with " "EACCES",
)
def test_remove_invalid_paths(self) -> None:
self.eden.run_unchecked("remove", "/tmp")
self.eden.run_unchecked("remove", "/root")
def test_unmount(self) -> None:
entries = set(os.listdir(self.mount))
self.assertEqual(self.expected_mount_entries, entries)
self.assertTrue(self.eden.in_proc_mounts(self.mount))
self.eden.unmount(self.mount)
self.eden.remove(self.mount)
self.assertFalse(self.eden.in_proc_mounts(self.mount))
self.assertFalse(os.path.exists(self.mount))

View File

@ -67,7 +67,7 @@ class CloneTest(testcase.EdenRepoTest):
empty_dir, active_mount_points, msg="mounted using the realpath"
)
self.eden.run_cmd("unmount", "--destroy", symlinked_target)
self.eden.run_cmd("remove", "--yes", symlinked_target)
def test_clone_to_existing_empty_directory(self) -> None:
tmp = self._new_tmp_dir()

View File

@ -361,11 +361,11 @@ class EdenFS(object):
params.append("--allow-empty-repo")
self.run_cmd(*params)
def unmount(self, path: str) -> None:
def remove(self, path: str) -> None:
"""
Run "eden unmount --destroy <path>"
Run "eden remove <path>"
"""
self.run_cmd("unmount", "--destroy", path)
self.run_cmd("remove", "--yes", path)
def in_proc_mounts(self, mount_path: str) -> bool:
"""Gets all eden mounts found in /proc/mounts, and returns

View File

@ -27,9 +27,9 @@ class RCTest(testcase.EdenRepoTest):
mounts = self.eden.list_cmd()
self.assertEqual({self.mount: self.eden.CLIENT_ACTIVE}, mounts)
self.eden.unmount(self.mount)
self.eden.remove(self.mount)
mounts = self.eden.list_cmd()
self.assertEqual({}, mounts, msg="There should be 0 mount paths after unmount")
self.assertEqual({}, mounts, msg="There should be 0 mount paths after remove")
self.eden.clone(self.repo_name, self.mount)
mounts = self.eden.list_cmd()
@ -45,10 +45,10 @@ class RCTest(testcase.EdenRepoTest):
mounts = self.eden.list_cmd()
self.assertEqual({self.mount: self.eden.CLIENT_ACTIVE}, mounts)
self.eden.unmount(self.mount)
self.eden.remove(self.mount)
self.assertFalse(os.path.isdir(test_client_dir))
# Check that _remove_path_from_directory_map in unmount is successful
# Check that _remove_path_from_directory_map in remove is successful
mounts = self.eden.list_cmd()
self.assertEqual({}, mounts, msg="There should be 0 paths in the directory map")

View File

@ -85,7 +85,7 @@ class RemountTest(testcase.EdenRepoTest):
self.eden.clone(self.repo_name, self.mount + "-" + str(i))
# Unmount a client
self.eden.unmount(self.mount)
self.eden.remove(self.mount)
self.assertFalse(os.path.exists(self.mount))
self.eden.shutdown()
@ -108,8 +108,8 @@ class RemountTest(testcase.EdenRepoTest):
self.eden.start()
# Unmount clients
self.eden.unmount(self.mount)
self.eden.unmount(self.mount + "-3")
self.eden.remove(self.mount)
self.eden.remove(self.mount + "-3")
self.eden.shutdown()
self.eden.start()