import:
- Shell copy commands to copy:
- * nodes in B that are newer than A (same relative path), and
- * nodes in B that do not exist in A at all.
- Direction: B → A
+ * in-between nodes in B that are newer than A (same relative path), or
+ * in-between nodes in B that do not exist in A at all.
+ Direction: B -> A
+ Also emits:
+ * a mkdir list (directories to create in A)
+ * an "other" list for type mismatches / non-file/dir nodes.
export:
- Shell copy commands to copy:
* nodes in A that are newer than B, and
* nodes in A that do not exist in B.
- Direction: A → B
- (Uses the "older" list: B entries older than A → copy A→B.)
+ Direction: A -> B
+ Also emits:
+ * a mkdir list (directories to create in B)
+ * an "other" list for type mismatches / non-file/dir nodes.
suspicious:
- Nodes in B that fall "in between" the Harmony skeleton topology:
from __future__ import annotations
-import meta
import os
from typing import Any, Dict, List, Tuple
-import skeleton
+import skeleton
TreeDict = Dict[str, Dict[str, Any]]
-def build_import_commands(
- A_tree: Dict[str, Dict[str, Any]]
- ,B_tree: Dict[str, Dict[str, Any]]
- ,A_root: str
- ,B_root: str
-) -> Tuple[List[str], List[str]]:
- """
- Compute shell commands to update A from B.
-
- Returns:
- (mkdir_cmds, cp_cmds)
-
- Semantics:
- - mkdir_cmds:
- Directories that are directories in B, but are either missing
- from A or not directories in A.
- We *only* ever create dirs that are missing or wrong-type on A.
-
- - cp_cmds:
- Files (and optionally other non-directory nodes) where:
- * the path does not exist in A, OR
- * the node in A is not a file, OR
- * the B copy is newer than A (mtime comparison).
- """
- mkdir_cmds: List[str] = []
- cp_cmds: List[str] = []
-
- for rel_path, b_info in B_tree.items():
- node_type = b_info.get("node_type")
-
- # Directories: candidate for mkdir on A if missing or wrong type.
- if node_type == "directory":
- a_info = A_tree.get(rel_path)
- if a_info is None or a_info.get("node_type") != "directory":
- # Missing or not a directory on A: mkdir -p
- target_dir = os.path.join(A_root, rel_path) if rel_path else A_root
- mkdir_cmds.append(f"mkdir -p '{target_dir}'")
- continue
-
- # Files / other nodes: candidate for cp from B -> A
- b_mtime = b_info.get("mtime")
- a_info = A_tree.get(rel_path)
-
- need_copy = False
-
- if a_info is None:
- # B-only
- need_copy = True
- else:
- a_type = a_info.get("node_type")
- if a_type != "file":
- # A has non-file, B has file/other: prefer B’s version
- need_copy = True
- else:
- # Both are files: compare mtime
- a_mtime = a_info.get("mtime")
- if isinstance(a_mtime, (int, float)) and isinstance(b_mtime, (int, float)):
- if b_mtime > a_mtime:
- need_copy = True
-
- if need_copy:
- src = os.path.join(B_root, rel_path) if rel_path else B_root
- dst = A_root # cp --parents will build the path under this root
- cp_cmds.append(
- f"cp --parents -a '{src}' '{dst}/'"
- )
-
- return mkdir_cmds, cp_cmds
def shell_quote(
s: str
# ----------------------------------------------------------------------
-# import: copy newer / A-missing nodes from B → A
+# import: B -> A (mkdir, cp, and "other" list), using in_between_newer
# ----------------------------------------------------------------------
-def _keys_only_in_B(
- A: TreeDict
- ,B: TreeDict
-) -> Iterable[str]:
- keys_A = set(A.keys())
- for k in B.keys():
- if k not in keys_A:
- yield k
+def build_import_commands(
+ A_tree: TreeDict
+ ,B_tree: TreeDict
+ ,A_root: str
+ ,B_root: str
+) -> Tuple[List[str], List[str], List[str]]:
+ """
+ Compute shell commands to update A from B.
+
+ Returns:
+ (mkdir_cmds, cp_cmds, other_list)
+
+ Semantics:
+
+ mkdir_cmds:
+ - Directories that are directories in B, but are missing in A.
+ - We DO NOT auto-resolve type mismatches (e.g. B=directory,
+ A=file); those go into other_list.
+
+ cp_cmds:
+ - Files where:
+ * the path does not exist in A, OR
+ * the node in A is not a file, OR
+ * the B copy is newer than A (mtime comparison).
+ - However, if A has a non-file at that path, we treat it as a
+ type mismatch and add that path to other_list instead of
+ emitting a cp command.
+
+ other_list:
+ - Human-readable notes for:
+ * type mismatches between A and B, and
+ * nodes in B that are neither 'file' nor 'directory'.
+ """
+ mkdir_cmds: List[str] = []
+ cp_cmds: List[str] = []
+ other_list: List[str] = []
+
+ for rel_path, b_info in B_tree.items():
+ b_type = b_info.get("node_type")
+ rel_display = rel_path if rel_path else "."
+
+ a_info = A_tree.get(rel_path)
+ a_type = a_info.get("node_type") if a_info is not None else "MISSING"
+
+ # Case 1: B node is neither file nor directory -> other_list
+ if b_type not in ("file", "directory"):
+ other_list.append(
+ f"{rel_display}: A={a_type}, B={b_type}"
+ )
+ continue
+
+ # Case 2: B directory
+ if b_type == "directory":
+ if a_info is None:
+ # Missing in A: safe to mkdir -p
+ target_dir = os.path.join(A_root, rel_path) if rel_path else A_root
+ mkdir_cmds.append(f"mkdir -p {shell_quote(target_dir)}")
+ else:
+ # Exists in A: must also be a directory to be "structurally OK"
+ if a_type != "directory":
+ # Type mismatch: do not mkdir, just report
+ other_list.append(
+ f"{rel_display}: A={a_type}, B=directory"
+ )
+ continue
+
+ # Case 3: B file
+ # Decide whether to copy B -> A, or report conflict.
+ if a_info is None:
+ # B-only file
+ src = os.path.join(B_root, rel_path) if rel_path else B_root
+ dst = A_root
+ cp_cmds.append(
+ f"cp --parents -a {shell_quote(src)} {shell_quote(dst)}/"
+ )
+ continue
+
+ # A has something at this path
+ if a_type != "file":
+ # Type mismatch (e.g. A=directory, B=file, or A=other)
+ other_list.append(
+ f"{rel_display}: A={a_type}, B=file"
+ )
+ continue
+
+ # Both files: compare mtime
+ a_mtime = a_info.get("mtime")
+ b_mtime = b_info.get("mtime")
+
+ if isinstance(a_mtime, (int, float)) and isinstance(b_mtime, (int, float)):
+ if b_mtime > a_mtime:
+ src = os.path.join(B_root, rel_path) if rel_path else B_root
+ dst = A_root
+ cp_cmds.append(
+ f"cp --parents -a {shell_quote(src)} {shell_quote(dst)}/"
+ )
+
+ return mkdir_cmds, cp_cmds, other_list
def cmd_import(
- A_tree: Dict[str, Dict[str, Any]]
- ,B_tree: Dict[str, Dict[str, Any]]
+ A_tree: TreeDict
+ ,B_tree: TreeDict
,A_root: str
,B_root: str
) -> int:
"""
- import: show directory creation and copy commands B -> A.
+ import: update the skeleton (A) from the project (B),
+ using only in_between_newer nodes.
"""
- mkdir_cmds, cp_cmds = build_import_commands(A_tree, B_tree, A_root, B_root)
+ inb_newer = skeleton.in_between_newer(A_tree, B_tree)
+
+ mkdir_cmds, cp_cmds, other_list = build_import_commands(
+ A_tree
+ ,inb_newer
+ ,A_root
+ ,B_root
+ )
- print("== import: copy from B -> A ==")
+ print("== import: copy from B -> A (in-between newer only) ==")
print(f"# A root: {A_root}")
print(f"# B root: {B_root}")
+ print("# Only considering in-between files that are new or absent in A.")
print("#")
print("# Directories to create in A (mkdir -p):")
print(line)
else:
print("# (none)")
+ print("#")
+
+ print("# Nodes NOT handled automatically (type mismatches / non-file/dir):")
+ if other_list:
+ for rel in other_list:
+ print(f"# {rel}")
+ else:
+ print("# (none)")
return 0
-def cmd_import(
- A: TreeDict
- ,B: TreeDict
+# ----------------------------------------------------------------------
+# export: A -> B (mkdir, cp, and "other" list)
+# ----------------------------------------------------------------------
+def build_export_commands(
+ A_tree: TreeDict
+ ,B_tree: TreeDict
,A_root: str
,B_root: str
-) -> int:
+) -> Tuple[List[str], List[str], List[str]]:
"""
- import: B → A
+ Compute shell commands to update B from A.
+
+ Returns:
+ (mkdir_cmds, cp_cmds, other_list)
- - Newer nodes in B than A (same path): tree_dict_newer(A,B).
- - Nodes present only in B (not in A).
- - Only file nodes are turned into copy commands.
+ Semantics:
- Output: shell 'cp' commands using GNU 'cp --parents -a'.
+ mkdir_cmds:
+ - Directories that are directories in A, but are missing in B.
+ - Type mismatches go into other_list.
+
+ cp_cmds:
+ - Files where:
+ * the path does not exist in B, OR
+ * the node in B is not a file, OR
+ * the A copy is newer than B (mtime comparison).
+ - If B has a non-file while A has a file, treat as type mismatch.
+
+ other_list:
+ - Human-readable notes for:
+ * type mismatches between A and B, and
+ * nodes in A that are neither 'file' nor 'directory'.
"""
- newer_B = skeleton.tree_dict_newer(A, B)
- only_in_B_paths = list(_keys_only_in_B(A, B))
-
- # Collect unique file paths to copy from B to A
- paths: List[str] = []
+ mkdir_cmds: List[str] = []
+ cp_cmds: List[str] = []
+ other_list: List[str] = []
- for k in newer_B.keys():
- if B.get(k, {}).get("node_type") == "file":
- paths.append(k)
+ for rel_path, a_info in A_tree.items():
+ a_type = a_info.get("node_type")
+ rel_display = rel_path if rel_path else "."
- for k in only_in_B_paths:
- if B.get(k, {}).get("node_type") == "file":
- paths.append(k)
+ b_info = B_tree.get(rel_path)
+ b_type = b_info.get("node_type") if b_info is not None else "MISSING"
- # Deduplicate while preserving order
- seen = set()
- unique_paths: List[str] = []
- for p in paths:
- if p in seen:
+ # Case 1: A node is neither file nor directory -> other_list
+ if a_type not in ("file", "directory"):
+ other_list.append(
+ f"{rel_display}: A={a_type}, B={b_type}"
+ )
continue
- seen.add(p)
- unique_paths.append(p)
- _print_header("import: copy from B → A")
+ # Case 2: A directory
+ if a_type == "directory":
+ if b_info is None:
+ # Missing in B: safe to mkdir -p
+ target_dir = os.path.join(B_root, rel_path) if rel_path else B_root
+ mkdir_cmds.append(f"mkdir -p {shell_quote(target_dir)}")
+ else:
+ # Exists in B: must also be directory
+ if b_type != "directory":
+ other_list.append(
+ f"{rel_display}: A=directory, B={b_type}"
+ )
+ continue
- if not unique_paths:
- print("# No file nodes in B to import into A.")
- return 0
+ # Case 3: A file
+ if b_info is None:
+ # A-only file
+ src = os.path.join(A_root, rel_path) if rel_path else A_root
+ dst = B_root
+ cp_cmds.append(
+ f"cp --parents -a {shell_quote(src)} {shell_quote(dst)}/"
+ )
+ continue
- print(f"# A root: {A_root}")
- print(f"# B root: {B_root}")
- print("# Copy newer and B-only files from B into A:")
- for rel in unique_paths:
- src = os.path.join(B_root, rel)
- cmd = (
- f"cp --parents -a {shell_quote(src)} "
- f"{shell_quote(A_root)}/"
- )
- print(cmd)
+ if b_type != "file":
+ other_list.append(
+ f"{rel_display}: A=file, B={b_type}"
+ )
+ continue
- return 0
+ # Both files: compare mtime
+ a_mtime = a_info.get("mtime")
+ b_mtime = b_info.get("mtime")
+ if isinstance(a_mtime, (int, float)) and isinstance(b_mtime, (int, float)):
+ if a_mtime > b_mtime:
+ src = os.path.join(A_root, rel_path) if rel_path else A_root
+ dst = B_root
+ cp_cmds.append(
+ f"cp --parents -a {shell_quote(src)} {shell_quote(dst)}/"
+ )
-# ----------------------------------------------------------------------
-# export: copy newer / B-missing nodes from A → B
-# ----------------------------------------------------------------------
-def _keys_only_in_A(
- A: TreeDict
- ,B: TreeDict
-) -> Iterable[str]:
- keys_B = set(B.keys())
- for k in A.keys():
- if k not in keys_B:
- yield k
+ return mkdir_cmds, cp_cmds, other_list
def cmd_export(
- A: TreeDict
- ,B: TreeDict
+ A_tree: TreeDict
+ ,B_tree: TreeDict
,A_root: str
,B_root: str
) -> int:
"""
- export: A → B
-
- - Nodes in B that are older than A (same path):
- tree_dict_older(A,B) -> keys of interest.
- For these keys, we copy from A_root/path to B_root/path.
-
- - Nodes present only in A (not in B).
-
- Only file nodes are turned into copy commands.
+ export: show directory creation and copy commands A -> B.
"""
- older_B = skeleton.tree_dict_older(A, B)
- only_in_A_paths = list(_keys_only_in_A(A, B))
-
- paths: List[str] = []
-
- for k in older_B.keys():
- if A.get(k, {}).get("node_type") == "file":
- paths.append(k)
-
- for k in only_in_A_paths:
- if A.get(k, {}).get("node_type") == "file":
- paths.append(k)
-
- seen = set()
- unique_paths: List[str] = []
- for p in paths:
- if p in seen:
- continue
- seen.add(p)
- unique_paths.append(p)
+ mkdir_cmds, cp_cmds, other_list = build_export_commands(
+ A_tree
+ ,B_tree
+ ,A_root
+ ,B_root
+ )
+
+ print("== export: copy from A -> B ==")
+ print(f"# A root: {A_root}")
+ print(f"# B root: {B_root}")
+ print("#")
- _print_header("export: copy from A → B")
+ print("# Directories to create in B (mkdir -p):")
+ if mkdir_cmds:
+ for line in mkdir_cmds:
+ print(line)
+ else:
+ print("# (none)")
+ print("#")
- if not unique_paths:
- print("# No file nodes in A to export into B.")
- return 0
+ print("# Files to copy from A -> B (cp --parents -a):")
+ if cp_cmds:
+ for line in cp_cmds:
+ print(line)
+ else:
+ print("# (none)")
+ print("#")
- print(f"# A root: {A_root}")
- print(f"# B root: {B_root}")
- print("# Copy newer and A-only files from A into B:")
- for rel in unique_paths:
- src = os.path.join(A_root, rel)
- cmd = (
- f"cp --parents -a {shell_quote(src)} "
- f"{shell_quote(B_root)}/"
- )
- print(cmd)
+ print("# Nodes NOT handled automatically (type mismatches / non-file/dir):")
+ if other_list:
+ for rel in other_list:
+ print(f"# {rel}")
+ else:
+ print("# (none)")
return 0
"""
suspicious: nodes in B that fall 'in between' the Harmony skeleton,
not under leaf directories.
-
- Uses tree_dict_in_between_and_below(A,B) and prints the 'in_between'
- dictionary.
"""
in_between, _below = skeleton.tree_dict_in_between_and_below(A, B)
) -> int:
"""
addendum: nodes in B that fall 'below' Harmony leaf directories.
-
- These represent work added in proper extension points.
- Uses the 'below' part from tree_dict_in_between_and_below(A,B).
"""
_in_between, below = skeleton.tree_dict_in_between_and_below(A, B)
A_root, B_root:
Root paths corresponding to A and B (for copy commands).
"""
- # Normalize commands
cmds = set(has_other_list)
if "all" in cmds:
,"addendum"
])
- # Preserve a deterministic run order
ordered = [
"structure"
,"import"
elif name == "addendum":
rc = cmd_addendum(A, B)
else:
- # Unknown has_other token; ignore for now, could log later.
rc = 0
if rc != 0:
from __future__ import annotations
-import meta
import os
import sys
from typing import Any, Callable, Dict, List, Set
+
+import meta
from GitIgnore import GitIgnore
import Harmony
+TreeDict = Dict[str, Dict[str, Any]]
+
# tree_dict_make / tree_dict_print
#
# Build a dictionary describing a project tree, respecting GitIgnore.
# Values are dicts with:
# 1. 'mtime' : last modification time (float seconds)
# 2. 'node_type' : 'file', 'directory', or 'other'
-# 3. 'dir_info' : 'not-a-directory', 'leaf', 'root'
+# 3. 'dir_info' : 'NA', 'leaf', 'branch', or 'root'
# 4. 'checksum' : present only for file nodes when checksum_fn is
# not None
#
# Traversal:
-# - Directories whose relative path GitIgnore.check() marks as
-# 'Ignore' are included in tree_dict but not traversed further.
+# - Any path (directory or file) for which GitIgnore.check(<rel_path>)
+# returns 'Ignore' is omitted from the tree_dict.
def tree_dict_make(
path: str
,checksum_fn: Callable[[str], int] | None
) -> Dict[str, Dict[str, Any]]:
"""
Build a tree_dict for the subtree rooted at <path>, respecting GitIgnore.
-
- Semantics (current):
- * Any path (directory or file) for which GitIgnore.check(<rel_path>)
- returns 'Ignore' is completely omitted from the tree_dict.
- * The root directory ('') is always included.
- * Directory dir_info:
- - 'root' for the root
- - 'branch' for directories that have child directories
- (after GitIgnore filtering)
- - 'leaf' for directories with no child directories
- * Non-directory dir_info:
- - 'NA'
"""
root = os.path.abspath(path)
gi = GitIgnore(root)
if rel_dir == ".":
rel_dir = ""
- # Skip ignored directories (except the root).
+ # Skip ignored directories (except the root)
if rel_dir != "" and gi.check(rel_dir) == "Ignore":
dirnames[:] = []
continue
# Filter child directories by GitIgnore so dir_info reflects
# only directories we will actually traverse.
- kept_dirnames: list[str] = []
+ kept_dirnames: List[str] = []
for dn in list(dirnames):
child_rel = dn if rel_dir == "" else os.path.join(rel_dir, dn)
if gi.check(child_rel) == "Ignore":
return tree_dict
+
def tree_dict_print(
tree_dict: Dict[str, Dict[str, Any]]
) -> None:
Only the values are printed in each column (no 'field=' prefixes).
mtime is formatted via the Z module for human readability.
"""
- entries: list[tuple[str, str, str, str, str]] = []
+ entries: List[tuple[str, str, str, str, str]] = []
has_checksum = False
for rel_path in sorted(tree_dict.keys()):
,display_path
))
- # Compute column widths (values only)
+ # Compute column widths
type_w = 0
dir_w = 0
mtime_w = 0
if len(type_val) > type_w:
type_w = len(type_val)
if len(dir_val) > dir_w:
- dir_w = len(dir_w) if False else len(dir_val) # keep RT style simple
+ dir_w = len(dir_val)
if len(mtime_val) > mtime_w:
mtime_w = len(mtime_val)
if has_checksum and len(checksum_val) > checksum_w:
print("Tree dictionary contents:")
for type_val, dir_val, mtime_val, checksum_val, display_path in entries:
line = " "
-
line += type_val.ljust(type_w)
line += " "
line += dir_val.ljust(dir_w)
That is, return a new tree_dict containing only those entries whose
keys are present in A but NOT present in B.
-
- Notes:
- * We treat the key sets as abstract sets; values are copied from A.
- * A and B are assumed to have relative-path keys built from
- independent scans (possibly with different GitIgnore instances).
- The contract that the underlying filesystem trees do not overlap
- is irrelevant here because we compare only the relative keys.
"""
result: Dict[str, Dict[str, Any]] = {}
return result
+
def tree_dict_in_between_and_below(
A: Dict[str, Dict[str, Any]]
,B: Dict[str, Dict[str, Any]]
if info.get("node_type") == "directory"
)
- # 2. Compute leaf directories in A:
- # leaf_dir = directory key with no other directory key as a proper descendant
+ # 2. Compute leaf directories in A
leaf_dirs: Set[str] = set()
for d in A_dir_keys:
- # The root "" is never treated as a leaf; it always has descendants
if d == "":
continue
below: Dict[str, Dict[str, Any]] = {}
for key, info in B.items():
- # Skip B's root; it is typically not useful in this partitioning.
+ # Skip B's root
if key in ("", "."):
continue
parts = key.split(os.sep)
- # Build directory ancestor chain for this B node.
- # If the node itself is a directory, include its full path;
- # otherwise include directories up to its parent.
+ # Build directory ancestor chain
node_is_dir = (info.get("node_type") == "directory")
ancestors: List[str] = [""]
prefix = None
if node_is_dir:
- upto = parts # include the last component
+ upto = parts
else:
- upto = parts[:-1] # only directories above the basename
+ upto = parts[:-1]
for part in upto:
if prefix is None:
in_between[key] = info
if meta.debug_has("tree_dict_in_between_and_below"):
- tree_dict_print(result)
+ merged: Dict[str, Dict[str, Any]] = {}
+ merged.update(in_between)
+ merged.update(below)
+ tree_dict_print(merged)
return in_between, below
then k is included in the result with value B[k].
- Keys that are only in B (not in A) are ignored here; use a separate
- set-difference function for "only-in-B" detection.
+ Keys that are only in B (not in A) are ignored here.
"""
result: Dict[str, Dict[str, Any]] = {}
return result
+
def tree_dict_older(
- A: Dict[str, Dict[str, Any]]
- ,B: Dict[str, Dict[str, Any]]
+ A: Dict[str, Dict[str, Dict[str, Any]]]
+ ,B: Dict[str, Dict[str, Dict[str, Any]]]
) -> Dict[str, Dict[str, Any]]:
"""
Return a dictionary of nodes from B that are older than their
return result
+def in_between_newer(
+ A: TreeDict
+ ,B: TreeDict
+) -> TreeDict:
+ """
+ in_between_newer(A, B) -> TreeDict
+
+ Return the subset of B's nodes that:
+
+ 1. Are in the 'in_between' region with respect to A's topology, and
+ 2. Are "newer" than A at the same path, or absent from A.
+
+ Only file nodes in B are considered.
+ """
+ in_between, _below = tree_dict_in_between_and_below(A, B)
+
+ result: TreeDict = {}
+
+ for path, b_info in in_between.items():
+ b_type = b_info.get("node_type")
+
+ # Only consider files for "newer" semantics
+ if b_type != "file":
+ continue
+
+ b_mtime = b_info.get("mtime")
+ a_info = A.get(path)
+
+ # Case 1: path not in A
+ if a_info is None:
+ result[path] = b_info
+ continue
+
+ a_type = a_info.get("node_type")
+
+ # Case 2: A has non-file, B has file
+ if a_type != "file":
+ result[path] = b_info
+ continue
+
+ # Case 3: both files, compare mtime
+ a_mtime = a_info.get("mtime")
+ if (
+ isinstance(a_mtime, (int, float))
+ and isinstance(b_mtime, (int, float))
+ and b_mtime > a_mtime
+ ):
+ result[path] = b_info
+
+ if meta.debug_has("in_between_newer"):
+ tree_dict_print(result)
+
+ return result