Last active
February 26, 2024 14:49
-
-
Save bsidhom/a0b21c1830f4eb0629948efb2feff2c5 to your computer and use it in GitHub Desktop.
Update symlinks under a directory root after moving from one location to another
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
# NOTE: This is intended to be used with dump-links.py: | |
# https://gist.github.com/bsidhom/c5c83a8d38f500db4be397926cae8d02 | |
import argparse | |
import base64 | |
import json | |
import os | |
import os.path | |
import subprocess | |
import sys | |
def main(): | |
parser = argparse.ArgumentParser( | |
"Read in a sequence of links from stdin as output by dump-links.py and " | |
"translate each link into a new relative location. This translation " | |
"happens only on the JSON representation and does not perform and " | |
"filesystem operations. Paths that cannot be made relative to the new " | |
"root are left as absolute paths. This will not work as expected if " | |
"symlink targets are directories.") | |
parser.add_argument( | |
"--old-root", | |
help= | |
"Original root directory before the migration. The original targets " | |
"are resolved relative to this, if possible.", | |
type=os.fsencode, | |
required="True") | |
parser.add_argument( | |
"--new-root", | |
help="New root directory after the migration. The new targets will be " | |
"resolved relative to this root, if possible.", | |
type=os.fsencode, | |
required="True") | |
parser.add_argument( | |
"--dry-run", | |
action="store_true", | |
help="Dry run. Dump a JSON representation of the result rather than " | |
"actually mutating the FS.") | |
parser.add_argument( | |
"--check-result", | |
action="store_true", | |
help= | |
"Actually check for the existence of the translated link on the FS. " | |
"Any broken links will not be rewritten. In dry-run mode, this adds a " | |
"JSON field indicting breakage. Note that this check only runs at a " | |
"single level of link indirection (i.e., if the target is a symlink, " | |
"this is not checked).") | |
args = parser.parse_args() | |
# NOTE: We normalize the paths first to make them semi-canonical (no up-dirs | |
# or trailing slashes). This simplifies common ancestor checks. | |
# TODO: As in dump-links.py, this does not correctly work when the old or | |
# new root is not representable in a Python string. Figure out how to make | |
# this work. | |
translate_links(args.dry_run, args.check_result, | |
os.path.normpath(args.old_root), | |
os.path.normpath(args.new_root)) | |
def translate_links(dry_run: bool, check_result: bool, old_root: bytes, | |
new_root: bytes): | |
for line in sys.stdin: | |
line = line.rstrip() | |
d = json.loads(line) | |
link = base64.b64decode(d["link"]) | |
target = base64.b64decode(d["target"]) | |
new_target = translate_link(link, target, old_root, new_root) | |
if dry_run: | |
dump_json(link, target, new_target, check_result) | |
else: | |
move_link(link, new_target, check_result) | |
def translate_link(link: bytes, target: bytes, old_root: bytes, | |
new_root: bytes) -> bytes: | |
if not os.path.isabs(target): | |
# DO NOT attempt to resolve relative paths. These should either already | |
# work as expected or they point to locations _outside_ of the old root, | |
# in which case they cannot be migrated. | |
return target | |
if os.path.commonpath((target, old_root)) != old_root: | |
# Don't translate in this case, as the target was _not_ a subdir of the | |
# original root. | |
return target | |
# Now, infer the location of the old link by computing the new link's | |
# location relative to the new root. Then construct the same relative path | |
# from the old root. | |
link_relpath = os.path.relpath(link, start=new_root) | |
old_link = os.path.join(old_root, link_relpath) | |
# Compute the target _relative_ to the old link's location (i.e., turn it | |
# into a relative path). We assume that the old link was _not_ a directory | |
# because it was assumed to have been a symlink. Therefore, we can safely | |
# call os.path.dirname() to find the old symlink's parent directory and | |
# expect correct behavior. The result is a correct symlink _relative_ to the | |
# new location, which should point to a target within the new root. | |
return os.path.relpath(target, start=os.path.dirname(old_link)) | |
def relative_target_exists(link: bytes, target: bytes) -> bool: | |
# Link is assumed to be a symlink. We resolve it by getting the parent | |
# directory of link and traversing to target from there. This function | |
# assumes that target is a _relative_ path. | |
if os.path.isabs(target): | |
raise Exception( | |
f"assertion failure: link {link} should have pointed to a relative path, but got {target}" | |
) | |
dirname = os.path.dirname(link) | |
resolved = os.path.join(dirname, target) | |
return os.path.lexists(resolved) | |
def dump_json(link: bytes, old_target: bytes, new_target: bytes, | |
check_result: bool): | |
link_encoded = base64.b64encode(link).decode("utf-8") | |
link_str = link.decode("utf-8", errors="replace") | |
old_target_encoded = base64.b64encode(old_target).decode("utf-8") | |
old_target_str = old_target.decode("utf-8", errors="replace") | |
new_target_encoded = base64.b64encode(new_target).decode("utf-8") | |
new_target_str = new_target.decode("utf-8", errors="replace") | |
d = { | |
"link": link_encoded, | |
"link_str": link_str, | |
"old_target": old_target_encoded, | |
"old_target_str": old_target_str, | |
"new_target": new_target_encoded, | |
"new_target_str": new_target_str, | |
} | |
if check_result: | |
d["new_target_exists"] = relative_target_exists(link, new_target) | |
print(json.dumps(d)) | |
def move_link(link: bytes, new_target: bytes, check_result: bool): | |
if check_result and not relative_target_exists(link, new_target): | |
print( | |
f"New link from {link} to {new_target} does not exist. Not rewriting.", | |
file=sys.stderr) | |
else: | |
os.remove(link) | |
os.symlink(new_target, link) | |
if __name__ == "__main__": | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment