文章

文件复制时保留原始时间戳

在复制文件时,默认情况下,新文件的创建时间通常会被设置为当前时间,而不会自动继承原文件的时间戳。但可以通过特定命令或工具,使新文件的创建时间、修改时间、访问时间与原文件完全一致。

以下是不同操作系统下的解决方案:

一、Windows 系统

使用 robocopy(推荐)

Windows 自带的 robocopy(Robust File Copy)是最可靠的文件复制工具,其优势包括:

  • 精确保留时间戳:使用 SetFileTime 和 CopyFileEx
  • 支持目录时间戳:/DCOPY:T 保留目录的创建/修改/访问时间
  • 高性能:支持多线程、断点续传、错误重试
  • 拥有系统级的权限:可处理只读、隐藏文件

完整保留时间戳:

# 复制单个文件
robocopy "C:\源目录" "D:\目标目录" "文件名" /COPY:DAT /DCOPY:T /IS /IT /R:0 /W:0

# 复制整个目录
robocopy "C:\源目录" "D:\目标目录" /COPY:DAT /DCOPY:T /IS /IT /E /R:0 /W:0 /NFL /NDL /NP

参数说明:

  • /COPY:DAT:复制数据(D)、属性(A)、时间戳(T)
  • /DCOPY:T:复制目录时间戳
  • /E:复制子目录(包括空目录)
  • /R:0 /W:0:不重试,失败后立即跳过
  • /IS:复制相同文件(不跳过)
  • /IT:复制相同文件的时间戳(即使数据没变)
  • 隐藏信息输出:
    • /NFL:不列出文件名(No File List)
    • /NDL:不列出目录名(No Directory List)
    • /NJH:无作业标题(No Job Header)
    • /NJS:无统计摘要(No Job Summary)
    • /NP:无进度显示(No Progress)

二、Linux / macOS 系统

使用 cp 命令保留时间戳

cp -p "源文件" "目标文件"
  • -p 选项等价于 --preserve=mode,ownership,timestamps
  • 保留以下元数据:
    • 权限(mode)
    • 所有者与属组(ownership)
    • 时间戳(timestamps):包括:
      • mtime(Modification Time):文件内容最后修改时间
      • atime(Access Time):文件最后访问时间

新生成的目标文件将具有:

  • 创建时间(birth time / btime):设为当前时间(不可继承)
  • inode 变更时间(ctime):设为当前时间(因创建新 inode)
  • 新文件的 创建时间(birth time)和 inode 变更时间(ctime)将为当前时间,无法通过普通复制工具继承。

注意:Linux 中的 ctime 不是“创建时间”,而是 “元数据变更时间”(Change Time),每当文件权限、所有者、链接数或内容发生变化时自动更新,无法手动设置或复制

Linux 文件系统三大时间戳

时间类型 含义 查看命令
mtime 内容修改时间 stat file 或 ls -l(默认)
atime 访问时间(读取) stat file 或 ls -lu
ctime inode 元数据变更时间 stat file 或 ls -lc
  • ls 命令:-l 默认显示 mtime,-lu 显示 atime,-lc 显示 ctime。

某些现代文件系统(如 ext4、XFS、Btrfs、APFS)支持 birth time(btime),可通过 stat 命令查看(字段名为 Birth),但标准工具(包括 cp无法保留或复制该属性

使用 scp 命令(跨平台)

scp -p /path/to/source/file user@remote:/destination/path/
  • -p:尝试保留源文件的:
    • 修改时间(mtime
    • 访问时间(atime
    • 权限(permissions)
  • 不保留:
    • 所有者/属组(仅本地有效)
    • 扩展属性、ACL
    • 创建时间(birth time
    • ctime(自动更新为当前时间)

限制scp 功能较基础,适合简单场景。若需更完整的元数据同步,推荐使用 rsync

使用 rsync 命令(跨平台)

rsync 是目前最强大、灵活且跨平台的文件同步工具,能精细控制元数据保留。

rsync 是一个强大且跨平台的文件同步工具,能够有效保留文件 权限、所有者、链接、mtime 和 atime(需显式指定) 等关键元数据。新文件的 创建时间(birth time)和 inode 变更时间(ctime)将为当前时间,无法通过普通复制工具继承。

本地到本地复制:

rsync -a --atimes /path/to/source/file /destination/path/

选项说明:

  • -a:归档模式,等价于 -rlptgoD,包含:
    • r:递归复制目录
    • l:保留符号链接
    • p:保留权限
    • t:保留修改时间( mtime
    • go:保留属组和属主(需目标端有权限)
    • D:保留设备文件、FIFO 等特殊文件
  • --atimes:显式请求保留源文件的访问时间( atime

本地到远程复制:

rsync -a --atimes /path/to/source/file user@remote:/destination/path/

若需更全面地保留元数据(如扩展属性、ACL),可结合以下选项:

rsync -aAX --atimes /path/to/source/ user@remote:/destination/path/
  • -A:保留 ACL(访问控制列表)
  • -X:保留扩展属性(extended attributes, xattrs)
  • -a:已包含 -rlptgoD
  • --atimes:保留 atime

建议使用 rsync 3.2.0 及以上版本,对 --atimes 支持更稳定。旧版本可能不支持该选项或行为不一致。

最佳实践

cpscprsync 均可在不同程度上保留 mtimeatime(需启用对应选项),但 无法继承创建时间(birth time)和 inode 变更时间(ctime)。这是由文件系统设计决定的限制。

推荐使用 rsync -aAX --atimes 实现最完整的元数据保留。

现代扩展:birth time(出生时间)

在现代文件系统中,birth time(也称为 file creation timecrtime)指的是文件或目录在其所在文件系统上首次被创建的精确时间。它由内核在文件对应的 inode 被分配时自动记录,是文件生命周期的“起点”。

支持 birth time(创建时间)的文件系统主要包括:

  • ext4 (Linux 4.11+):需新建卷且启用 inode version=2。
  • XFS (Linux 5.x+):需新建卷。
  • Btrfs、JFS、ZFS、APFS:原生支持 birth time

在这些文件系统上,birth time 作为 inode 的元数据被持久保存。

在 Linux 上,使用 stat file 命令可查看 Birth 字段信息:

stat filename

注意:birth timemtime(修改时间)、atime(访问时间)、ctime(状态变更时间)不同,它仅在文件创建时设置一次,之后永远不可修改

当你使用常见的文件复制工具(如 cprsynctar 等)复制文件时:

  • 系统会为新文件分配一个新的 inode
  • 新 inode 的 birth time 被设置为当前时间
  • 原文件的 birth time 无法被写入新 inode

要真正保留文件的 birth time,必须在不重新创建 inode 的前提下进行数据复制。

唯一可行的方式是:文件系统级克隆(如 LVM 快照、btrfs send/receive、dd 镜像)。暂不赘述。

三、Python 脚本(跨平台)

在文件同步、备份或元数据敏感的场景中(如 Obsidian 笔记系统),精确保留文件时间戳(创建时间、修改时间、访问时间)至关重要。然而,Python 标准库中的 shutil 模块在跨平台时间戳处理上存在显著局限,尤其是在需要完整保留“创建时间”的场景下。

shutil.copy2() 的局限性

虽然 shutil.copy2() 声称“复制内容和元数据”,但在实际使用中存在以下问题:

  1. 无法保留创建时间(ctime)

    • shutil.copy2() 依赖 os.utime() 来设置目标文件的时间戳。
    • os.utime() 的限制:该函数仅支持设置两个时间戳:
      • atime(最后访问时间)
      • mtime(最后修改时间)
    • 创建时间(Creation Time / Birth Time)无法通过 shutil 被复制或设置。新文件的创建时间将被设置为复制操作发生的时刻。
  2. 访问时间(atime)可能被干扰

    • 读取干扰:在调用 shutil.copy2() 读取源文件时,操作系统通常会更新该文件的 atime(取决于文件系统挂载选项,如 noatime)。
    • 写入干扰:目标文件在创建和写入过程中,其 atime 也可能被系统“修正”或默认更新。
    • 即使 shutil 尝试复制原始 atime,其值也可能已不是文件最初的状态。
  3. 唯一可靠的(mtime)

    • 在 shutil.copy2() 复制的三个时间戳中,只有 mtime(修改时间)是可靠且精确的
    • mtime 是文件内容最后更改的时间,是同步和备份中最核心的判断依据。

因此,shutil.copy2() 实际上只能保证 mtime 的精确复制,atime 可能失真,而 ctime(创建时间)完全丢失。

跨平台时间戳处理差异

不同操作系统对文件时间戳的处理机制存在根本性差异。

时间戳类型 Windows (NTFS) Linux/macOS (ext4, APFS, etc.)
创建时间 支持读取和写入 (st_ctime) 通常只读 (birth time),无法修改
修改时间 支持 (st_mtime) 支持 (st_mtime)
访问时间 支持 (st_atime) 支持 (st_atime),但常被 noatime 禁用
元数据更改时间 N/A st_ctime 表示 inode 更改时间

关键差异:在 Windows 上可以主动修改文件的创建时间,而在 Linux/macOS 上,文件的“出生时间”一旦确定就无法更改

总结

  • 通用场景:使用 shutil.copy2() 足够,mtime 是同步的可靠依据。
  • Windows 高保真场景:必须使用 pywin32 调用 SetFileTime(),才能实现与 robocopy 相同的元数据完整性。
  • Linux/macOS无法修改文件的创建时间(birth time),这是系统级限制,一般工具都无法绕过。

Windows 高保真文件复制方案

为了在 Windows 平台上实现与 robocopy /COPY:DAT 相同的高保真效果(即完整复制数据、属性和所有时间戳),必须绕过 shutil 的限制,直接调用 Windows 原生 API。

原理机制

Windows 提供了 SetFileTime() 函数,允许程序精确设置文件的三个核心时间戳:

  • Creation Time(创建时间)
  • Last Access Time(最后访问时间)
  • Last Write Time(最后写入时间,即 mtime

Python 实现:pywin32 库

Python 通过 pywin32pypiwin32)包封装了对 Windows API 的调用,使得我们可以使用 win32file.SetFileTime() 实现高保真复制。

安装 pywin32

win32file 模块包含在 pywin32 包中。需要先安装它:

pip install pywin32

注意:安装后,有时需要运行一个额外的脚本来配置环境(尤其是在某些 IDE 或服务中)。如果遇到导入问题,可以尝试运行:

python Scripts/pywin32_postinstall.py -install
  • 这个脚本通常位于您的 Python 安装目录的 Scripts 文件夹下。

安装 pywin32 的 stubs 文件

pip install pywin32-stubs
  • 重启 IDE 或重新加载 Python 环境。

导入模块

import win32file
import win32con  # 包含常量,如 GENERIC_READ, FILE_ATTRIBUTE_NORMAL 等
import pywintypes  # 用于处理 Windows 时间类型

实现步骤

  1. 使用 shutil.copy2() 复制文件内容、基本属性和 atime/mtime
  2. 读取源文件的原始时间戳(st_ctimest_atimest_mtime)。
  3. 将 Unix 时间戳(秒)转换为 Windows FILETIME 格式(100纳秒间隔,自 1601-01-01 UTC)。
  4. 使用 win32file.CreateFile() 获取目标文件句柄。
  5. 调用 win32file.SetFileTime() 精确设置三个时间戳。
  6. 关闭文件句柄。

代码实现

import shutil
import os
import win32file
import pywintypes

def set_file_times(path, ctime, atime, mtime):
    """
    给文件或目录设置完整时间戳(Windows)时间戳
    """
    handle = win32file.CreateFile(
        path,
        win32file.GENERIC_WRITE,
        win32file.FILE_SHARE_READ | win32file.FILE_SHARE_WRITE,
        None,
        win32file.OPEN_EXISTING,
        win32file.FILE_FLAG_BACKUP_SEMANTICS,  # 支持目录
        None
    )

    try:
        # 设置完整时间戳
        win32file.SetFileTime(handle, ctime, atime, mtime)
    finally:
        handle.close()

    # print(f"文件复制完成,时间戳已继承:{dst}")
    

def copy_with_timestamps(src, dst):
    """
    复制单个文件并完整保留时间戳(Windows 专用)
    支持:创建时间、访问时间、修改时间
    """
    # 1. 获取源文件时间戳
    src_stat = os.stat(src)
    ctime = pywintypes.Time(src_stat.st_ctime)
    atime = pywintypes.Time(src_stat.st_atime)
    mtime = pywintypes.Time(src_stat.st_mtime)

    # 2. 使用 shutil.copy2 复制内容和基本元数据
    shutil.copy2(src, dst)
    
    # 3. 设置目标文件时间戳
    set_file_times(dst, ctime, atime, mtime)
    
    
def copytree_with_timestamps(src, dst):
    """递归复制目录及其内容,并保留时间戳"""
    dir_times = []  # 保存目录及其时间戳,最后统一设置
    for root, dirs, files in os.walk(src):
        rel_path = os.path.relpath(root, src)
        target_subdir = os.path.join(dst, rel_path)
        os.makedirs(target_subdir, exist_ok=True)
        
        # 记录当前目录时间戳
        root_stat = os.stat(root)
        dir_times.append((target_subdir,
             pywintypes.Time(root_stat.st_ctime),
             pywintypes.Time(root_stat.st_atime),
             pywintypes.Time(root_stat.st_mtime)))
        
        # 复制文件及时间戳
        for file in files:
            src_file = os.path.join(root, file)
            dst_file = os.path.join(target_subdir, file)
            copy_with_timestamps(src_file, dst_file)
            
    # 反向遍历设置目录时间戳(先子目录后父目录)
    for path, ctime, atime, mtime in reversed(dir_times):
        set_file_times(path, ctime, atime, mtime)


# 使用示例
src = r"D:\Obsidian\Middle\linkres\obsidian"
dst = r"D:\Obsidian\Middle\linkres\xcopy\obsidian"

# copywithtimestamps(src, dst)
copytree_with_timestamps(src, dst)

注意:此方法在 Windows 上通常有效,但某些系统策略(如防病毒软件、atime 延迟更新)可能干扰 atime 的精确性。

使用 Python 封装文件复制命令

  • Windows 系统:使用 robocopy 命令进行文件和目录的复制,保留时间戳等属性。
  • Unix/Linux/macOS 系统:使用 rsync 命令进行文件和目录的复制,支持保留时间戳、权限等属性。
import argparse
import subprocess
import os
import re
import shlex
import shutil
from typing import Optional


# 只在 Windows 平台导入 pywin32 模块
if os.name == 'nt':
    try:
        import win32file
        import pywintypes
    except ImportError:
        print("请安装 pywin32 库以修复目录的时间戳")  


def fix_directory_timestamps(src_dir: str, dst_dir: str):
    """
    修复 Windows 下目标目录时间戳(创建、修改、访问)
    """
    if not os.path.exists(dst_dir):
        print(f"无法修复时间戳:目标目录不存在 {dst_dir}")
        return

    try:
        src_stat = os.stat(src_dir)
        ctime = pywintypes.Time(src_stat.st_ctime)
        atime = pywintypes.Time(src_stat.st_atime)
        mtime = pywintypes.Time(src_stat.st_mtime)

        handle = win32file.CreateFile(
            dst_dir,
            win32file.GENERIC_WRITE,
            win32file.FILE_SHARE_READ | win32file.FILE_SHARE_WRITE,
            None,
            win32file.OPEN_EXISTING,
            win32file.FILE_FLAG_BACKUP_SEMANTICS,  # 用于操作目录
            None
        )
        try:
            win32file.SetFileTime(handle, ctime, atime, mtime)
        finally:
            handle.close()
    except Exception as e:
        print(f"修复目录时间戳失败 {dst_dir}: {e}")


def is_target_directory(src: str, dst: str) -> bool:
    """
    判断目标路径是否是目录(考虑隐藏文件特殊性)
    """
    # 如果目标路径已存在,则判断是否是目录
    if os.path.exists(dst):
        if os.path.isdir(dst):
            return True
        else: 
            raise FileExistsError(f"目标路径已存在且不是目录: {dst}")
    
    # 以路径分隔符结尾,则认为是目录
    if dst.endswith('/') or dst.endswith('\\'):
        return True
    
    # 如果源是隐藏文件,且目标路径无扩展名但以 . 开头,则视为文件
    if src and os.path.isfile(src):
        src_name = os.path.basename(src)
        if src_name.startswith('.'):
            dst_name = os.path.basename(dst)
            if dst_name.startswith('.') and os.path.splitext(dst)[1] == '':
                return False
    
    # 如果源是目录,则认为目标路径是目录     
    elif src and os.path.isdir(src):
            return True
    
    # 一般情况:如果目标路径无扩展名,则认为是目录
    return os.path.splitext(dst)[1] == ''
            
    
def robocopy_copy(src: str, dst: str) -> bool:
    """
    Windows 系统下使用 robocopy 复制文件或目录,保留时间戳(创建、修改、访问)
    :param src: 源文件或目录路径
    :param dst: 目标路径(文件或目录)
    """
    if not os.path.exists(src):
        raise FileNotFoundError(f"源路径不存在: {src}")

    is_file = os.path.isfile(src)
    src_name = os.path.basename(src)

    dst_is_directory = is_target_directory(src, dst)
    if dst_is_directory:
        # 目标是目录,复制到该目录下,使用原文件名
        parent_dst = dst.rstrip('/\\')
        final_dst = os.path.join(parent_dst, src_name)
    else:
        # 目标是文件,直接使用目标文件名
        parent_dst = os.path.dirname(dst) or '.'  # 假如 data.txt 父目录为空,使用当前目录
        final_dst = dst
    
    # 确保父目录存在 
    os.makedirs(parent_dst, exist_ok=True)
    
    if is_file:
        parent_src = os.path.dirname(src)
        file_list = [src_name]
    else:
        parent_src = src
        file_list = []


    # 优先使用 shell=False + 列表
    # 构建 robocopy 命令
    cmd = [
        "robocopy",
        parent_src,      # 指定源目录
        parent_dst,      # 指定目标目录(robocopy 只支持目录)
        *file_list,      # 指定文件名列表
        "/COPY:DAT",     # 复制数据、属性、时间戳
        "/DCOPY:T",      # 复制目录时间戳(创建、修改、访问)
        # "/E",            # 包含子目录(含空目录)
        "/R:0", "/W:0",  # 不重试
        "/NFL", "/NDL",  # 不输出文件和目录
        "/NJH", "/NJS",  # 无作业头和尾
        "/NC", "/NS",    # 不输出文件大小、摘要
        "/IS",           # 复制相同文件(不跳过)
        "/IT"            # 复制相同文件的时间戳(即使数据没变)
    ]

    if not is_file:
        cmd.append("/E")  # 包含子目录(含空目录)

    try:
        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=300,    # 5分钟超时
            shell=False     # 避免 shell 注入
            # shell=True      # 支持内建命令和变量替换
        )

        # robocopy 返回码:0~7 成功,8+ 失败
        # 0: 无复制(文件已最新)
        # 1: 成功复制文件
        # 2: 有额外文件
        # 3: 1+2
        # 8+: 严重错误
        success = result.returncode < 8

        # 输出日志
        if result.stdout.strip():
            print("=== robocopy 输出 ===\n" + result.stdout)
        if result.stderr.strip():
            print("=== robocopy 错误 ===\n" + result.stderr)

        if success:
            # 文件场景:robocopy 实际复制到了 parent_dst/src_name,需重命名为 final_dst
            if is_file:
                temp_copied = os.path.join(parent_dst, src_name)
                if os.path.exists(temp_copied) and temp_copied != final_dst:
                    os.replace(temp_copied, final_dst)
            # 修复目录时间戳(可选)
            if os.path.isdir(dst) and os.path.isdir(src):
                fix_directory_timestamps(src, dst)
        else:
            print(f"复制失败(返回码: {result.returncode}")

        return success

    except subprocess.TimeoutExpired:
        print("robocopy 执行超时")
        return False
    except Exception as e:
        print(f"robocopy 执行失败: {e}")
        return False


def remote_path_type(user_host: str, remote_path: str) -> Optional[str]:
    """
    检查远程路径类型
    :return: 'file', 'directory', 'link', 'not_exists', None(执行失败)
    """
    quoted = shlex.quote(remote_path)
    check_cmd = (
        f"if [ -d {quoted} ]; then echo 'directory'; "
        f"elif [ -f {quoted} ]; then echo 'file'; "
        f"elif [ -L {quoted} ]; then echo 'link'; "
        f"else echo 'not_exists'; fi"
    )
    cmd = ["ssh", user_host, check_cmd]
    try:
        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=10,
            encoding='utf-8',
            errors='replace'
        )
        out = result.stdout.strip()
        if out in ('file', 'directory', 'link', 'not_exists'):
            return out
        return None
    except Exception as e:
        print(f"SSH 检查失败: {e}")
        return None


def ensure_remote_dir(user_host: str, remote_path: str) -> bool:
    """通过 SSH 确保远程目录存在"""
    cmd = ["ssh", user_host, f"mkdir -p {shlex.quote(remote_path)}"]
    try:
        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            encoding='utf-8',
            errors='replace'
        )
        return result.returncode == 0
    except Exception as e:
        print(f"创建远程目录失败: {e}")
        return False


def rsync_copy(src: str, dst: str) -> bool:
    """
    Unix 系统,使用 rsync 复制保留修改、访问时间戳
    支持:本地到本地、本地到远程的复制
    :param src: 源文件或目录路径
    :param dst: 目标路径(文件或目录),支持 user@host:/path
    """
    if not os.path.exists(src):
        raise FileNotFoundError(f"源路径不存在: {src}")

    src_path = src.rstrip('/') + '/' if os.path.isdir(src) else src

    # 检查是否是远程路径
    # is_remote = '@' in dst and ':' in dst
    # 使用正则解析远程路径(支持 IPv6)
    remote_match = r'^((?P<user>[^@]+)@)?(?P<host>\[[^\]]+\]|[^:]+):(?P<path>/.*)$'
    match = re.match(remote_match, dst)
    is_remote = bool(match)

    if is_remote:
        user = match.group('user') or ''
        host = match.group('host')
        user_host = f"{user}@{host}" if user else host
        remote_path = match.group('path').rstrip('/')
        remote_type = remote_path_type(user_host, remote_path)

        if remote_type is None:
            raise RuntimeError(f"无法确定远程路径类型:{dst}")

        if os.path.isdir(src):
            # 如果源是目录,则目标路径要确保是目录
            if remote_type in ("directory", "link"):
                final_dst = f"{user_host}:{remote_path}/"
            elif remote_type == 'not_exists':
                ensure_remote_dir(user_host, remote_path)
                final_dst = f"{user_host}:{remote_path}/"
            else:
                raise RuntimeError(f"源是目录,目标不能是文件: {dst}")
        else:  # 源是文件
            bname = os.path.basename(src)
            if remote_type == 'not_exists':
                if dst.endswith('/') or os.path.splitext(remote_path)[1] == '':
                    # 目标是目录
                    target_dir = remote_path.rstrip('/')
                    ensure_remote_dir(user_host, target_dir)
                    final_dst = f"{user_host}:{target_dir}/{bname}"
                else:
                    parent_remote = os.path.dirname(remote_path)
                    if parent_remote.strip('/') != "":  # 避免根目录
                        ensure_remote_dir(user_host, parent_remote)
                    final_dst = f"{user_host}:{remote_path}"
            elif remote_type == 'directory':
                final_dst = f"{user_host}:{remote_path}/{bname}"
            else:
                final_dst = f"{user_host}:{remote_path}"
    else:
        if os.path.isdir(src):
            # 源是目录,目标路径要确保是目录
            final_dst = dst.rstrip("/") + "/"
            os.makedirs(final_dst, exist_ok=True)
        else:
            # 源是文件,目标路径判断
            if dst.endswith('/') or os.path.splitext(dst)[1] == '':
                dst = dst.rstrip('/')
                final_dst = os.path.join(dst, os.path.basename(src))
            else:
                final_dst = dst
            os.makedirs(os.path.dirname(final_dst), exist_ok=True)

    # 构建 rsync 命令
    cmd = ["rsync", "-a", "--atimes", src_path, final_dst]
    try:
        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            encoding='utf-8',
            errors='replace',
            timeout=300
        )
        if result.returncode == 0:
            return True

        outerr = result.stderr.lower()

        if "permission denied" in outerr or "rsync error" in outerr:
            cmd_sudo = ["sudo"] + cmd
            try:
                result2 = subprocess.run(
                    cmd_sudo,
                    capture_output=True,
                    text=True,
                    encoding='utf-8',
                    errors='replace',
                    timeout=300
                )
                return result2.returncode == 0
            except Exception:
                pass
        print("rsync 失败:", result.stderr.strip())
        return False

    except subprocess.TimeoutExpired:
        print("rsync 执行超时")
        return False
    except FileNotFoundError:
        print("未找到 rsync,回退到 shutil.copy2")
        return fallback_copy(src, dst)
    except Exception as e:
        print(f"rsync 复制失败: {e}")
        return False


def fallback_copy(src: str, dst: str) -> bool:
    """回退复制方案(使用 shutil.copy2,保留基本时间戳)"""
    try:
        if os.path.isdir(src):
            if os.path.exists(dst):
                shutil.rmtree(dst)
            shutil.copytree(src, dst, copy_function=shutil.copy2)
        else:
            os.makedirs(os.path.dirname(dst), exist_ok=True)
            shutil.copy2(src, dst)
        return True
    except Exception as e:
        print(f"回退复制失败: {e}")
        return False


def copy_with_timestamps(src: str, dst: str) -> bool:
    """统一接口:复制并保留时间戳"""
    if os.name == 'nt':  # Windows 
        return robocopy_copy(src, dst)
    else:  # Unix/Linux/macOS
        return rsync_copy(src, dst)


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description='复制文件或目录,保留时间戳')
    parser.add_argument('src', type=str, help='源文件或目录路径')
    parser.add_argument('dst', type=str, help='目标文件或目录路径')
    args = parser.parse_args()

    success = copy_with_timestamps(args.src, args.dst)
    if success:
        print("保留时间戳的复制成功!")
    else:
        print("保留时间戳的复制失败!")
    exit(0 if success else 1)


# 使用示例
# Windows 系统:
#   源是目录:src = r'C:\source\dir'
#   源是文件:src = r'C:\source\file.txt'
#   目标:    dst = r'D:\backup\dir' 或 r'D:\backup\file.txt'

# Linux / macOS:
#   本地复制:
#       src = '/local/source/dir'
#       dst = '/local/backup/dir'
#   复制到远程:
#       src = '/local/source/dir'
#       dst = 'user@remote:/remote/backup/dir'

# 命令行调用:
#   sudo python3 copywithtimestamps.py /local/source/dir /local/backup/dir
#   sudo python3 copywithtimestamps.py /local/source/file.txt user@remote:/remote/backup/
本文由作者按照 CC BY 4.0 进行授权。