はじめに
・大容量のファイルやフォルダの差分コピーやzip圧縮、解凍において
進捗表示(特に完了予定を表示)しながら、コピーができるプログラムを作成しました。
・ほかのプログラムから簡単に実行できる
コンソールアプリ(C# .NetFramework4.8)で作成しました。
コマンドライン実行例
・フォルダコピー (差分コピー) ※簡易ハッシュ計算による同一性チェック
FileSync.exe --input="***(フォルダ)" --output="***(フォルダ)" --subdirectory --hash=partial128kb --copycheck --progress-detail --progress-singleline --progress-force-inline
・zip出力 (圧縮) 進捗表示付き
FileSync.exe --input="***(フォルダ)" --zip-output="***.zip" --zip-compression=fastest --subdirectory --progress-detail --progress-singleline --progress-force-inline
・zip解凍 進捗表示付き
FileSync.exe --unzip-input="***.zip" --output="***(フォルダ)" --unzip-shared --parallel=1 --progress-detail --progress-singleline --progress-force-inline
実行結果:例
進捗: 11/ 21 速度: 34.5 MB/s 残り:00:02:10 完了: 1.939 GB/ 2.939 GB ...
ソースコード
・もし試してみたい場合は、下記コードを確認してください。
Program.cs
// File: Program.cs
// Target: .NET Framework 4.8
// Ref : System.IO.Compression
// Ref : System.IO.Compression.FileSystem
// NuGet : Tomlyn (TOML parser)
using System;
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Tomlyn;
namespace FileSync
{
internal static class Program
{
static int Main(string[] args)
{
try
{
var norm = args.Select(NormalizeDash).ToArray();
// --config or default path
string cfgPath = GetConfigPathFromArgs(norm);
if (cfgPath == null)
{
var def = ConfigPaths.GetDefaultConfigPath();
if (File.Exists(def)) cfgPath = def;
}
// Load TOML (if any)
SyncConfig baseCfg = new SyncConfig();
TomlMapping[] maps = new TomlMapping[0];
if (!string.IsNullOrEmpty(cfgPath))
{
var loaded = TomlLoader.Load(cfgPath);
baseCfg = loaded.Item1;
maps = loaded.Item2;
}
// Apply CLI overrides
TomlMapping[] mappingsOverride;
if (!Cli.ApplyOverrides(norm, baseCfg, out mappingsOverride))
{
Help.Print();
return 2;
}
// Decide mappings
TomlMapping[] mappings;
if (mappingsOverride != null) mappings = mappingsOverride;
else if (maps.Length == 0 && Cli.TryGetPositionalMapping(args, out var pos)) mappings = new[] { pos };
else mappings = maps;
if (mappings.Length == 0)
{
Console.WriteLine("[ERROR] マッピングがありません。--config か <input> <output> を指定してください。");
Help.Print();
return 2;
}
// If zip-output is used across multiple mappings, delete existing zip once at the start
bool zipModeAll = !string.IsNullOrEmpty(baseCfg.ZipOutputPath);
if (zipModeAll && File.Exists(baseCfg.ZipOutputPath))
{
try { File.Delete(baseCfg.ZipOutputPath); }
catch (Exception ex) { Console.WriteLine("[WARN] 既存ZIP削除失敗: " + ex.Message); }
}
int rc = 0;
for (int i = 0; i < mappings.Length; i++)
{
var m = mappings[i];
bool unzipMode = !string.IsNullOrEmpty(baseCfg.UnzipInputPath);
bool zipMode = !string.IsNullOrEmpty(baseCfg.ZipOutputPath);
RunContext run = new RunContext(baseCfg, cfgPath);
try
{
run.WriteRunStart();
if (unzipMode)
{
// unzip: output だけ必須
if (string.IsNullOrWhiteSpace(m.output))
{
Console.WriteLine("[WARN] --unzip-input 使用時は mappings.output を指定してください。スキップ。");
run.AnyFailed = true; run.WriteRunEnd(false);
continue;
}
var cfg = baseCfg.Clone();
cfg.OutputDir = Path.GetFullPath(m.output);
cfg.InputDir = null;
cfg.ProgressTitle = "[zip:" + baseCfg.UnzipInputPath + " \u2192 " + cfg.OutputDir + "]";
rc |= Engine.RunDirectoryToDirectory(cfg, run);
}
else if (zipMode)
{
// zip: input だけ必須
if (string.IsNullOrWhiteSpace(m.input))
{
Console.WriteLine("[WARN] --zip-output 使用時は mappings.input を指定してください。スキップ。");
run.AnyFailed = true; run.WriteRunEnd(false);
continue;
}
if (!Directory.Exists(m.input))
{
Console.WriteLine("[WARN] 入力フォルダが見つかりません: " + m.input);
run.AnyFailed = true; run.WriteRunEnd(false);
continue;
}
var cfg = baseCfg.Clone();
cfg.InputDir = Path.GetFullPath(m.input);
cfg.OutputDir = null;
cfg.ProgressTitle = "[" + cfg.InputDir + " \u2192 zip:" + baseCfg.ZipOutputPath + "]";
rc |= Engine.RunDirectoryToDirectory(cfg, run);
}
else
{
// 通常: input/output 必須
if (string.IsNullOrWhiteSpace(m.input) || string.IsNullOrWhiteSpace(m.output))
{
Console.WriteLine("[WARN] mapping の input/output が空です。スキップします。");
run.AnyFailed = true; run.WriteRunEnd(false);
continue;
}
bool isDir = Directory.Exists(m.input);
bool isFile = File.Exists(m.input);
if (!isDir && !isFile)
{
Console.WriteLine("[WARN] 入力が見つかりません: " + m.input);
run.AnyFailed = true; run.WriteRunEnd(false);
continue;
}
if (isDir)
{
var cfg = baseCfg.Clone();
cfg.InputDir = Path.GetFullPath(m.input);
cfg.OutputDir = Path.GetFullPath(m.output);
cfg.ProgressTitle = "[" + cfg.InputDir + " \u2192 " + cfg.OutputDir + "]";
rc |= Engine.RunDirectoryToDirectory(cfg, run);
}
else
{
var cfg = baseCfg.Clone();
cfg.ProgressTitle = "[" + m.input + " \u2192 " + m.output + "]";
rc |= Engine.RunFileToFile(cfg, run, m.input, m.output);
}
}
run.WriteRunEnd(!run.AnyFailed);
}
catch (Exception ex)
{
run.AnyFailed = true;
run.WriteRunEnd(false);
Console.WriteLine("[ERR] マッピング中にエラー: " + ex.Message);
rc |= 1;
}
finally
{
run.Dispose();
}
}
return rc;
}
catch (Exception ex)
{
Console.WriteLine("[FATAL] " + ex);
return 2;
}
}
static string NormalizeDash(string a)
{
if (a.StartsWith("--")) return a;
if (a.StartsWith("-")) return "-" + a;
return a;
}
static string GetConfigPathFromArgs(string[] args)
{
for (int i = 0; i < args.Length; i++)
{
var a = args[i];
if (a.Equals("--config", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
return args[i + 1];
if (a.StartsWith("--config=", StringComparison.OrdinalIgnoreCase))
return a.Substring("--config=".Length);
}
return null;
}
}
// ========= 設定 =========
internal class SyncConfig
{
// globals
public bool Subdirectory = false; // TOML: subdirectory / recursive(互換)
public bool Recursive { get { return Subdirectory; } set { Subdirectory = value; } }
public bool MoveFiles = false;
public string HashMode = "none"; // none|full|partial
public int PartialBytes = 4 * 1024; // partial ブロック
public bool EnableCopyCheck = false;
public bool EnableSyncDelete = false;
public int Parallelism = 1;
public int Retry = 3;
public bool PauseOnError = false;
public string LogPath = "";
public long MaxLogBytes = 10L * 1024 * 1024;
public bool ProgressDetail = false;
public bool ProgressSingleLine = true; // 同じ行上書き/毎回改行
public bool ProgressForceInline = false; // リダイレクトでも強制インライン
public bool DryRun = false;
public DateTime? ModifiedAfter = null;
public bool ModifiedAfterAuto = false;
public DateTime? ModifiedBefore = null;
public List<string> FilterExt = new List<string>();
public List<string> ExcludeExt = new List<string>();
public List<string> ExcludeFolders = new List<string>();
public List<string> NameMatch = new List<string>();
public List<Regex> NameRegex = new List<Regex>();
public string ErrorListPath = "";
public string ZipOutputPath = "";
public string UnzipInputPath = "";
// ZIP オプション
public string ZipLayout = "flat"; // flat | root
public string ZipDuplicate = "seq"; // overwrite | skip | seq
public string ZipCompression = "optimal"; // optimal | fastest | none
// UNZIP 最適化
public bool UnzipShareZip = true; // true かつ Parallelism==1 で共有 ZipArchive を使う
// 実行用
public string InputDir = null;
public string OutputDir = null;
public string ProgressTitle = "";
public SyncConfig Clone()
{
var c = (SyncConfig)MemberwiseClone();
c.FilterExt = new List<string>(FilterExt);
c.ExcludeExt = new List<string>(ExcludeExt);
c.ExcludeFolders = new List<string>(ExcludeFolders);
c.NameMatch = new List<string>(NameMatch);
c.NameRegex = new List<Regex>();
foreach (var rx in NameRegex) c.NameRegex.Add(new Regex(rx.ToString(), rx.Options));
return c;
}
}
internal class TomlMapping
{
public string input;
public string output;
}
// ========= TOML ローダ =========
internal static class TomlLoader
{
public static Tuple<SyncConfig, TomlMapping[]> Load(string path)
{
string text = File.ReadAllText(path, Encoding.UTF8);
var model = Toml.ToModel(text);
SyncConfig cfg = new SyncConfig();
List<TomlMapping> maps = new List<TomlMapping>();
var root = model as Tomlyn.Model.TomlTable;
if (root != null)
{
object gobj;
if (root.TryGetValue("globals", out gobj) && gobj is Tomlyn.Model.TomlTable)
{
var g = (Tomlyn.Model.TomlTable)gobj;
SetBool(g, "recursive", v => cfg.Recursive = v); // 互換
SetBool(g, "subdirectory", v => cfg.Recursive = v); // 正式
SetBool(g, "move", v => cfg.MoveFiles = v);
SetBool(g, "copycheck", v => cfg.EnableCopyCheck = v);
SetBool(g, "syncdelete", v => cfg.EnableSyncDelete = v);
SetBool(g, "pause_on_error", v => cfg.PauseOnError = v);
SetBool(g, "progress_detail", v => cfg.ProgressDetail = v);
SetBool(g, "progress_singleline", v => cfg.ProgressSingleLine = v);
SetBool(g, "progress_force_inline", v => cfg.ProgressForceInline = v); // 追加
SetBool(g, "dryrun", v => cfg.DryRun = v);
SetString(g, "hash", s => cfg.HashMode = string.IsNullOrEmpty(s) ? "none" : s.Trim().ToLowerInvariant());
SetString(g, "log", s => cfg.LogPath = s ?? "");
SetString(g, "error_list", s => cfg.ErrorListPath = s ?? "");
SetString(g, "zip_output", s => cfg.ZipOutputPath = s ?? "");
SetString(g, "unzip_input", s => cfg.UnzipInputPath = s ?? "");
SetBool(g, "unzip_shared", v => cfg.UnzipShareZip = v);
SetString(g, "zip_layout", s =>
{
if (string.IsNullOrWhiteSpace(s)) return;
var v = s.Trim().ToLowerInvariant();
if (v == "flat" || v == "root") cfg.ZipLayout = v;
else throw new FormatException("globals.zip_layout は flat|root");
});
SetString(g, "zip_duplicate", s =>
{
if (string.IsNullOrWhiteSpace(s)) return;
var v = s.Trim().ToLowerInvariant();
if (v == "overwrite" || v == "skip" || v == "seq") cfg.ZipDuplicate = v;
else throw new FormatException("globals.zip_duplicate は overwrite|skip|seq");
});
SetString(g, "zip_compression", s =>
{
if (string.IsNullOrWhiteSpace(s)) return;
var v = s.Trim().ToLowerInvariant();
if (v == "optimal" || v == "fastest" || v == "none") cfg.ZipCompression = v;
else throw new FormatException("globals.zip_compression は optimal|fastest|none");
});
SetString(g, "partial_bytes", s =>
{
if (!string.IsNullOrWhiteSpace(s))
{
long n;
if (!SizeParser.TryParse(s, out n)) throw new FormatException("globals.partial_bytes が不正");
cfg.PartialBytes = (int)Math.Min(n, int.MaxValue);
}
});
SetString(g, "max_log_size", s =>
{
if (!string.IsNullOrWhiteSpace(s))
{
long n;
if (!SizeParser.TryParse(s, out n)) throw new FormatException("globals.max_log_size が不正");
cfg.MaxLogBytes = n;
}
});
SetInt(g, "parallel", v => cfg.Parallelism = Math.Max(1, v));
SetInt(g, "retry", v => cfg.Retry = Math.Max(0, v));
SetString(g, "modified_after", s =>
{
if (string.IsNullOrWhiteSpace(s)) return;
if (string.Equals(s.Trim(), "auto", StringComparison.OrdinalIgnoreCase)) cfg.ModifiedAfterAuto = true;
else
{
DateTime dt;
if (!DateParser.TryParse(s, out dt)) throw new FormatException("globals.modified_after が不正");
cfg.ModifiedAfter = dt;
}
});
SetString(g, "modified_before", s =>
{
if (string.IsNullOrWhiteSpace(s)) return;
DateTime dt;
if (!DateParser.TryParse(s, out dt)) throw new FormatException("globals.modified_before が不正");
cfg.ModifiedBefore = dt;
});
AddExtArray(g, "filter_ext", cfg.FilterExt);
AddExtArray(g, "exclude_ext", cfg.ExcludeExt);
AddStringArray(g, "exclude_folder", cfg.ExcludeFolders);
AddStringArray(g, "name_match", cfg.NameMatch);
object rxObj;
if (g.TryGetValue("name_regex", out rxObj) && rxObj is Tomlyn.Model.TomlArray)
{
foreach (var it in (Tomlyn.Model.TomlArray)rxObj)
{
var pat = (it == null ? "" : it.ToString()).Trim();
if (pat.Length > 0) cfg.NameRegex.Add(new Regex(pat, RegexOptions.IgnoreCase | RegexOptions.Compiled));
}
}
}
object mobs;
if (root.TryGetValue("mappings", out mobs) && mobs is Tomlyn.Model.TomlTableArray)
{
foreach (var e in (Tomlyn.Model.TomlTableArray)mobs)
{
var t = (Tomlyn.Model.TomlTable)e;
string i = t.TryGetValue("input", out var vi) ? (vi == null ? null : vi.ToString()) : null;
string o = t.TryGetValue("output", out var vo) ? (vo == null ? null : vo.ToString()) : null;
maps.Add(new TomlMapping { input = i, output = o });
}
}
}
return Tuple.Create(cfg, maps.ToArray());
}
static void SetBool(Tomlyn.Model.TomlTable t, string key, Action<bool> setter)
{
object v; if (t.TryGetValue(key, out v) && v is bool) setter((bool)v);
}
static void SetInt(Tomlyn.Model.TomlTable t, string key, Action<int> setter)
{
object v;
if (t.TryGetValue(key, out v))
{
int n; if (int.TryParse(v == null ? null : v.ToString(), out n)) setter(n);
}
}
static void SetString(Tomlyn.Model.TomlTable t, string key, Action<string> setter)
{
object v; if (t.TryGetValue(key, out v)) setter(v == null ? null : v.ToString());
}
static void AddExtArray(Tomlyn.Model.TomlTable t, string key, List<string> dest)
{
object v;
if (!t.TryGetValue(key, out v) || !(v is Tomlyn.Model.TomlArray)) return;
foreach (var it in (Tomlyn.Model.TomlArray)v)
{
var s = (it == null ? "" : it.ToString()).Trim();
if (s.Length == 0) continue;
if (!s.StartsWith(".")) s = "." + s;
dest.Add(s.ToLowerInvariant());
}
}
static void AddStringArray(Tomlyn.Model.TomlTable t, string key, List<string> dest)
{
object v;
if (!t.TryGetValue(key, out v) || !(v is Tomlyn.Model.TomlArray)) return;
foreach (var it in (Tomlyn.Model.TomlArray)v)
{
var s = (it == null ? "" : it.ToString()).Trim();
if (s.Length == 0) continue;
dest.Add(s);
}
}
}
internal static class ConfigPaths
{
public static string GetDefaultConfigPath()
{
string dir;
var plat = Environment.OSVersion.Platform;
if (plat == PlatformID.Win32NT)
dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "FileSync");
else
dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "FileSync");
Directory.CreateDirectory(dir);
return Path.Combine(dir, "config.toml");
}
}
// ========= CLI 解析 =========
internal static class Cli
{
public static bool TryGetPositionalMapping(string[] originalArgs, out TomlMapping mapping)
{
var plain = originalArgs.Where(a => !(a.StartsWith("-") || a.StartsWith("--"))).ToArray();
if (plain.Length >= 2)
{
mapping = new TomlMapping { input = plain[0], output = plain[1] };
return true;
}
mapping = new TomlMapping();
return false;
}
public static bool ApplyOverrides(string[] args, SyncConfig cfg, out TomlMapping[] mappingsOverride)
{
mappingsOverride = null;
string inputList = null, outputList = null;
foreach (var a in args)
{
var low = a.ToLowerInvariant();
if (!low.StartsWith("--")) continue;
if (low == "--subdirectory" || low == "--subdiretory" || low == "--recursive") cfg.Recursive = true;
else if (low == "--move") cfg.MoveFiles = true;
else if (low == "--copycheck") cfg.EnableCopyCheck = true;
else if (low == "--syncdelete") cfg.EnableSyncDelete = true;
else if (low == "--dryrun") cfg.DryRun = true;
else if (low == "--pause-on-error") cfg.PauseOnError = true;
else if (low == "--progress-detail") cfg.ProgressDetail = true;
else if (low == "--progress-singleline") cfg.ProgressSingleLine = true;
else if (low == "--progress-multiline") cfg.ProgressSingleLine = false;
else if (low == "--progress-force-inline") cfg.ProgressForceInline = true;
else if (low.StartsWith("--progress-mode="))
{
var v = a.Substring("--progress-mode=".Length).Trim().ToLowerInvariant();
cfg.ProgressSingleLine = (v != "multi" && v != "multiline");
}
else if (low.StartsWith("--hash="))
{
var val = a.Substring("--hash=".Length);
if (val.StartsWith("partial", StringComparison.OrdinalIgnoreCase))
{
cfg.HashMode = "partial";
var sizePart = val.Substring("partial".Length);
if (sizePart.Length > 0)
{
long n;
if (!SizeParser.TryParse(sizePart, out n)) { Console.WriteLine("Invalid --hash partial size."); return false; }
cfg.PartialBytes = (int)Math.Min(n, int.MaxValue);
}
}
else if (string.Equals(val, "none", StringComparison.OrdinalIgnoreCase) ||
string.Equals(val, "full", StringComparison.OrdinalIgnoreCase))
{
cfg.HashMode = val.ToLowerInvariant();
}
else { Console.WriteLine("Unknown --hash mode."); return false; }
}
else if (low.StartsWith("--partial-bytes="))
{
var s = a.Substring("--partial-bytes=".Length);
long n;
if (!SizeParser.TryParse(s, out n)) { Console.WriteLine("Invalid --partial-bytes"); return false; }
cfg.PartialBytes = (int)Math.Min(n, int.MaxValue);
if (cfg.HashMode == "none") cfg.HashMode = "partial";
}
else if (low.StartsWith("--parallel="))
{
int n;
if (!int.TryParse(a.Substring("--parallel=".Length), out n) || n < 1) { Console.WriteLine("Invalid --parallel"); return false; }
cfg.Parallelism = n;
}
else if (low.StartsWith("--retry="))
{
int n;
if (!int.TryParse(a.Substring("--retry=".Length), out n) || n < 0) { Console.WriteLine("Invalid --retry"); return false; }
cfg.Retry = n;
}
else if (low.StartsWith("--log="))
{
cfg.LogPath = a.Substring("--log=".Length);
}
else if (low.StartsWith("--maxlogsize="))
{
var s = a.Substring("--maxlogsize=".Length);
long n;
if (!SizeParser.TryParse(s, out n)) { Console.WriteLine("Invalid --maxlogsize"); return false; }
cfg.MaxLogBytes = n;
}
else if (low.StartsWith("--max-log-size="))
{
var s = a.Substring("--max-log-size=".Length);
long n;
if (!SizeParser.TryParse(s, out n)) { Console.WriteLine("Invalid --max-log-size"); return false; }
cfg.MaxLogBytes = n;
}
else if (low.StartsWith("--modified-after="))
{
var s = a.Substring("--modified-after=".Length);
if (string.Equals(s, "auto", StringComparison.OrdinalIgnoreCase)) cfg.ModifiedAfterAuto = true;
else
{
DateTime dt;
if (!DateParser.TryParse(s, out dt)) { Console.WriteLine("Invalid --modified-after"); return false; }
cfg.ModifiedAfter = dt;
}
}
else if (low.StartsWith("--modified-before="))
{
var s = a.Substring("--modified-before=".Length);
DateTime dt;
if (!DateParser.TryParse(s, out dt)) { Console.WriteLine("Invalid --modified-before"); return false; }
cfg.ModifiedBefore = dt;
}
else if (low.StartsWith("--filter-ext="))
{
cfg.FilterExt.Clear();
foreach (var ext in a.Substring("--filter-ext=".Length).Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries))
{
var e = ext.Trim(); if (!e.StartsWith(".")) e = "." + e;
cfg.FilterExt.Add(e.ToLowerInvariant());
}
}
else if (low.StartsWith("--exclude-ext="))
{
cfg.ExcludeExt.Clear();
foreach (var ext in a.Substring("--exclude-ext=".Length).Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries))
{
var e = ext.Trim(); if (!e.StartsWith(".")) e = "." + e;
cfg.ExcludeExt.Add(e.ToLowerInvariant());
}
}
else if (low.StartsWith("--exclude-folder="))
{
cfg.ExcludeFolders.Clear();
foreach (var f in a.Substring("--exclude-folder=".Length).Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries))
cfg.ExcludeFolders.Add(f.Trim());
}
else if (low.StartsWith("--name-match="))
{
cfg.NameMatch.Clear();
foreach (var p in a.Substring("--name-match=".Length).Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries))
cfg.NameMatch.Add(p.Trim());
}
else if (low.StartsWith("--name-regex="))
{
cfg.NameRegex.Clear();
foreach (var r in a.Substring("--name-regex=".Length).Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries))
cfg.NameRegex.Add(new Regex(r.Trim(), RegexOptions.IgnoreCase | RegexOptions.Compiled));
}
else if (low.StartsWith("--error-list="))
{
cfg.ErrorListPath = a.Substring("--error-list=".Length);
}
else if (low.StartsWith("--zip-output="))
{
cfg.ZipOutputPath = a.Substring("--zip-output=".Length);
}
else if (low.StartsWith("--unzip-input="))
{
cfg.UnzipInputPath = a.Substring("--unzip-input=".Length);
}
else if (low.StartsWith("--zip-layout="))
{
var v = a.Substring("--zip-layout=".Length).Trim().ToLowerInvariant();
if (v == "flat" || v == "root") cfg.ZipLayout = v;
else { Console.WriteLine("Invalid --zip-layout (flat|root)"); return false; }
}
else if (low.StartsWith("--zip-dup="))
{
var v = a.Substring("--zip-dup=".Length).Trim().ToLowerInvariant();
if (v == "overwrite" || v == "skip" || v == "seq") cfg.ZipDuplicate = v;
else { Console.WriteLine("Invalid --zip-dup (overwrite|skip|seq)"); return false; }
}
else if (low.StartsWith("--zip-duplicate="))
{
var v = a.Substring("--zip-duplicate=".Length).Trim().ToLowerInvariant();
if (v == "overwrite" || v == "skip" || v == "seq") cfg.ZipDuplicate = v;
else { Console.WriteLine("Invalid --zip-duplicate (overwrite|skip|seq)"); return false; }
}
else if (low.StartsWith("--zip-compression="))
{
var v = a.Substring("--zip-compression=".Length).Trim().ToLowerInvariant();
if (v == "optimal" || v == "fastest" || v == "none") cfg.ZipCompression = v;
else { Console.WriteLine("Invalid --zip-compression (optimal|fastest|none)"); return false; }
}
else if (low.StartsWith("--input="))
{
inputList = a.Substring("--input=".Length);
}
else if (low.StartsWith("--output="))
{
outputList = a.Substring("--output=".Length);
}
else if (low.StartsWith("--unzip-shared"))
{
// --unzip-shared or --unzip-shared=false
if (low == "--unzip-shared") cfg.UnzipShareZip = true;
else
{
var idx = a.IndexOf('=');
if (idx > 0)
{
var val = a.Substring(idx + 1).Trim().ToLowerInvariant();
cfg.UnzipShareZip = !(val == "0" || val == "false" || val == "no");
}
else cfg.UnzipShareZip = true;
}
}
}
// Build mappings from input/output lists when possible
if (!string.IsNullOrEmpty(inputList) && !string.IsNullOrEmpty(outputList))
{
var ins = inputList.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries);
var outs = outputList.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries);
int n = Math.Min(ins.Length, outs.Length);
var list = new List<TomlMapping>(n);
for (int i = 0; i < n; i++)
list.Add(new TomlMapping { input = ins[i].Trim(), output = outs[i].Trim() });
mappingsOverride = list.ToArray();
}
if (mappingsOverride == null)
{
if (!string.IsNullOrEmpty(cfg.UnzipInputPath) && !string.IsNullOrEmpty(outputList))
{
var outs = outputList.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries);
mappingsOverride = outs.Select(o => new TomlMapping { input = null, output = o.Trim() }).ToArray();
}
if (!string.IsNullOrEmpty(cfg.ZipOutputPath) && !string.IsNullOrEmpty(inputList))
{
var ins = inputList.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries);
mappingsOverride = ins.Select(iStr => new TomlMapping { input = iStr.Trim(), output = null }).ToArray();
}
}
if (!string.IsNullOrEmpty(cfg.ZipOutputPath) && !string.IsNullOrEmpty(cfg.UnzipInputPath))
{
Console.WriteLine("[ERROR] --zip-output と --unzip-input は同時指定不可です。");
return false;
}
return true;
}
}
internal static class Help
{
public static void Print()
{
Console.WriteLine(@"
Usage:
FileSync.exe --config <config.toml> [options]
FileSync.exe <inputDir> <outputDir> [options]
Options:
--subdirectory (alias: --recursive)
--move --hash=none|full|partialN --partial-bytes=256kb
--copycheck --syncdelete --parallel=N --retry=N --pause-on-error
--log=PATH --max-log-size=20mb (alias: --maxlogsize)
--progress-detail --progress-singleline | --progress-multiline | --progress-mode=single|multi | --progress-force-inline
--dryrun
--modified-after=auto|YYYY-MM-DD|YYYY-MM-DDTHH:mm:ss
--modified-before=YYYY-MM-DD|YYYY-MM-DDTHH:mm:ss
--filter-ext=jpg;png --exclude-ext=tmp;bak --exclude-folder=.git;node_modules
--name-match=""*.pdf;report_*.docx"" --name-regex=""^IMG_\\d+\\.jpg$""
--error-list=errors.txt
--zip-output=out.zip --zip-layout=flat|root --zip-duplicate=overwrite|skip|seq
--zip-compression=optimal|fastest|none
--unzip-input=in.zip --unzip-shared[=true|false]
--input=D:/a;D:/b --output=E:/x;E:/y
");
}
}
// ========= 進行セッション(ログ・履歴・進捗UI) =========
internal sealed class RunContext : IDisposable
{
public readonly SyncConfig BaseConfig;
public readonly string SessionId = Guid.NewGuid().ToString("N");
public readonly string ConfigPath;
public bool AnyFailed = false;
StreamWriter _log;
string _runningFlag;
public readonly Dictionary<string, HistoryEntry> History = new Dictionary<string, HistoryEntry>(StringComparer.OrdinalIgnoreCase);
readonly object _nowLock = new object();
string _nowSrcRel = "";
string _nowDstRel = "";
// 進捗出力の調整用(表示幅 単位)
readonly object _printLock = new object();
int _lastPrintedCols = 0; // 前回出力の “表示幅(桁数)”
public void SetNowCopying(string srcRel, string dstRel)
{
lock (_nowLock) { _nowSrcRel = srcRel ?? ""; _nowDstRel = dstRel ?? ""; }
}
public RunContext(SyncConfig cfg, string configPath)
{
BaseConfig = cfg;
ConfigPath = configPath;
if (!string.IsNullOrWhiteSpace(cfg.LogPath))
{
try { LoadHistoryFromLog(cfg.LogPath); } catch { }
if (cfg.ModifiedAfterAuto)
{
DateTime autoDt;
if (TryGetLastSuccessfulRunEndTime(cfg.LogPath, out autoDt))
{
BaseConfig.ModifiedAfter = autoDt;
Console.WriteLine("[auto] modified-after = " + autoDt.ToString("yyyy-MM-dd HH:mm:ss"));
}
else
{
Console.WriteLine("[auto] 前回全成功が見つかりません。");
}
}
try
{
var fs = new FileStream(cfg.LogPath, FileMode.Append, FileAccess.Write, FileShare.Read, 4096, FileOptions.WriteThrough);
_log = new StreamWriter(fs, Encoding.UTF8) { AutoFlush = true };
}
catch
{
_log = new StreamWriter(cfg.LogPath, true, Encoding.UTF8) { AutoFlush = true };
}
_runningFlag = cfg.LogPath + ".running";
try { File.WriteAllText(_runningFlag, SessionId, Encoding.UTF8); } catch { }
var oldFlag = cfg.LogPath + ".running";
if (File.Exists(oldFlag))
{
string oldSession = "";
try { oldSession = File.ReadAllText(oldFlag, Encoding.UTF8).Trim(); } catch { }
WriteLine("#RUNRECOVER," + NowLocal() + "," + SessionId + ",prev=" + oldSession);
try { File.Delete(oldFlag); } catch { }
}
}
}
public void Dispose() { try { if (_log != null) _log.Dispose(); } catch { } }
public void WriteRunStart() { WriteLine("#RUNSTART," + NowLocal() + "," + SessionId); }
public void WriteRunEnd(bool success)
{
WriteLine(success ? "#RUNEND,SUCCESS," + NowLocal() + "," + SessionId
: "#RUNEND,FAILED," + NowLocal() + "," + SessionId);
try { if (!string.IsNullOrEmpty(_runningFlag) && File.Exists(_runningFlag)) File.Delete(_runningFlag); } catch { }
}
DateTime _start;
long _totalFiles, _processedFiles, _totalBytes, _doneBytes;
public void InitProgress(long totalFiles, long totalBytes)
{
_start = DateTime.Now;
_totalFiles = totalFiles;
_totalBytes = totalBytes;
_processedFiles = 0;
_doneBytes = 0;
_lastPrintedCols = 0; // 進捗行の前回表示幅をリセット
}
public void StepProgress(bool addFile, long addBytes, bool detail, string title)
{
if (addFile) Interlocked.Increment(ref _processedFiles);
if (addBytes > 0) Interlocked.Add(ref _doneBytes, addBytes);
if (!detail) return;
// ---- 現在値の計算 ----
double seconds = (DateTime.Now - _start).TotalSeconds;
double bps = seconds > 0 ? _doneBytes / seconds : 0.0; // Bytes/s
double remain = (bps > 0) ? (_totalBytes - _doneBytes) / bps : double.NaN;
int countWidth = Math.Max(3, _totalFiles.ToString().Length);
string prog = _processedFiles.ToString().PadLeft(countWidth) + "/" +
_totalFiles.ToString().PadLeft(countWidth);
// サイズの単位は総量で決める(GB/MB/KB/B)
var unitSize = ChooseUnit(_totalBytes);
// 右側(ファイル名や「src → dst」)を組み立て
string joined;
lock (_nowLock)
{
string file = System.IO.Path.GetFileName(_nowSrcRel ?? "");
string pair = (_nowSrcRel ?? "") + " \u2192 " + (_nowDstRel ?? "");
string map = title ?? "";
// 例: "ZFC_9965.JPG albums\...\2024 → E:\backup\...\2024 [D:\in → E:\out]"
joined = (string.IsNullOrEmpty(file) ? pair : (file + " " + pair)) + " " + map;
}
int consoleWidth = GetConsoleWidth();
int rightMargin = Math.Max(3, consoleWidth / 25); // 右端に常時余白を確保
int maxCols = consoleWidth - rightMargin;
// 左側(進捗/速度/残り/完了)の整形パラメータ
const int speedDp = 1; // 速度は常に MB/s・小数1桁
int decimalsSize = 3; // 完了サイズ(done/total)の小数桁(3→2→1→0)
int numWidth = 9; // 数値部分の幅(狭い時は 9→8→...→6 まで詰める)
// 1) まずは "..." による右側の省略を**最優先**で実施
string left = BuildLeft(prog, unitSize, numWidth, decimalsSize, speedDp);
int leftCols = DisplayWidth(left);
int tailMax = maxCols - leftCols;
string tail = tailMax > 0 ? TruncateMiddleByCols(joined, tailMax) : "";
string line = left + tail;
// 2) まだはみ出す or tail を 0 まで詰めても左側が大きすぎる場合、
// 完了サイズの小数桁数を 3→2→1→0 と段階的に減らしてフィットを試みる。
int safety = 0;
while (DisplayWidth(line) > maxCols && safety++ < 32)
{
if (decimalsSize > 0)
{
decimalsSize--; // 3→2→1→0
}
else if (numWidth > 6)
{
numWidth--; // 9→8→...→6 まで詰める(桁合わせの左詰め余白を削る)
}
else
{
// これ以上は詰められないので打ち切り
break;
}
left = BuildLeft(prog, unitSize, numWidth, decimalsSize, speedDp);
leftCols = DisplayWidth(left);
tailMax = maxCols - leftCols;
tail = tailMax > 0 ? TruncateMiddleByCols(joined, tailMax) : "";
line = left + tail;
}
// 3) 出力:シングルライン上書き or 改行
bool inline = (BaseConfig != null) &&
(BaseConfig.ProgressSingleLine || BaseConfig.ProgressForceInline) &&
(!Console.IsOutputRedirected || BaseConfig.ProgressForceInline);
lock (_printLock)
{
if (inline)
{
try { Console.CursorVisible = false; } catch { }
Console.Write("\r" + line);
// 前回より短くなったぶんを“表示幅”でスペース消去
int lineCols = DisplayWidth(line);
int diffCols = _lastPrintedCols - lineCols;
if (diffCols > 0) Console.Write(new string(' ', diffCols));
_lastPrintedCols = lineCols;
}
else
{
Console.WriteLine(line);
_lastPrintedCols = 0;
}
}
// ---- ローカル: 左側の組み立て(完了サイズの小数桁を可変) ----
string BuildLeft(string progStr, SizeUnit uSize, int w, int dpSize, int dpSpeed)
{
string doneStr = FormatSizeFixed(_doneBytes, uSize, w, dpSize);
string totalStr = FormatSizeFixed(_totalBytes, uSize, w, dpSize);
string speedStr = FormatSizeFixedDouble(bps, SizeUnit.MB, w, dpSpeed) + "/s"; // 常に MB/s(小数1桁)
string remainStr = double.IsNaN(remain) ? "--:--:--" : FormatHMS(remain);
// 左側は固定順序・固定文言。ここが広すぎる場合のみ上の while で dpSize / w を詰める。
return $"進捗:{progStr} 速度:{speedStr} 残り:{remainStr} 完了:{doneStr}/{totalStr} ";
}
}
enum SizeUnit { B, KB, MB, GB, TB }
static SizeUnit ChooseUnit(long bytes)
{
if (bytes >= 1099511627776L) return SizeUnit.TB;
if (bytes >= 1073741824L) return SizeUnit.GB;
if (bytes >= 1048576L) return SizeUnit.MB;
if (bytes >= 1024L) return SizeUnit.KB;
return SizeUnit.B;
}
static string FormatSizeFixed(long bytes, SizeUnit unit, int width, int decimals)
{
double div = 1.0; string label = "B";
switch (unit)
{
case SizeUnit.KB: div = 1024.0; label = "KB"; break;
case SizeUnit.MB: div = 1024.0 * 1024.0; label = "MB"; break;
case SizeUnit.GB: div = 1024.0 * 1024.0 * 1024.0; label = "GB"; break;
case SizeUnit.TB: div = 1024.0 * 1024.0 * 1024.0 * 1024.0; label = "TB"; break;
}
double v = bytes / div;
string fmt = (decimals > 0) ? ("0." + new string('0', decimals)) : "0";
string num = v.ToString(fmt, CultureInfo.InvariantCulture);
return num.PadLeft(width) + " " + label;
}
static string FormatSizeFixedDouble(double bytes, SizeUnit unit, int width, int decimals)
{
double div = 1.0; string label = "B";
switch (unit)
{
case SizeUnit.KB: div = 1024.0; label = "KB"; break;
case SizeUnit.MB: div = 1024.0 * 1024.0; label = "MB"; break;
case SizeUnit.GB: div = 1024.0 * 1024.0 * 1024.0; label = "GB"; break;
case SizeUnit.TB: div = 1024.0 * 1024.0 * 1024.0 * 1024.0; label = "TB"; break;
}
double v = bytes / div;
string fmt = (decimals > 0) ? ("0." + new string('0', decimals)) : "0";
string num = v.ToString(fmt, CultureInfo.InvariantCulture);
return num.PadLeft(width) + " " + label;
}
static string FormatHMS(double sec)
{
if (double.IsNaN(sec) || double.IsInfinity(sec) || sec < 0) return "--:--:--";
var ts = TimeSpan.FromSeconds(sec);
long hh = (long)ts.TotalHours;
return string.Format("{0:D2}:{1:D2}:{2:D2}", hh, ts.Minutes, ts.Seconds);
}
// 進捗行の横幅(列数)を取得:リダイレクト時もそこそこ妥当な値を返す
static int GetConsoleWidth()
{
try
{
// 通常コンソール
if (!Console.IsOutputRedirected)
{
int w = 0;
try { w = Console.WindowWidth; } catch { /* 一部環境で投げる */ }
if (w <= 0 || w > 3000) w = 120; // 異常値ガード
return Math.Max(40, w); // 極端に狭いと崩れるので下限
}
// 出力がリダイレクトされている場合
// 環境変数(COLUMNS / CONSOLE_WIDTH)があれば利用、なければデフォルト
int cols;
var envCols = Environment.GetEnvironmentVariable("COLUMNS")
?? Environment.GetEnvironmentVariable("CONSOLE_WIDTH");
if (!string.IsNullOrEmpty(envCols) && int.TryParse(envCols, out cols))
return Math.Max(40, cols);
// 既定値(ログ/パイプ/CI など)
return 120;
}
catch
{
return 120;
}
}
// 表示幅(全角2桁)で長さを計算
static int DisplayWidth(string s)
{
if (string.IsNullOrEmpty(s)) return 0;
int cols = 0;
for (int i = 0; i < s.Length; i++)
{
char c = s[i];
if (char.IsSurrogate(c)) { cols += 2; continue; }
cols += IsWide(c) ? 2 : 1;
}
return cols;
}
static bool IsWide(char c)
{
int code = c;
if (code >= 0x1100 &&
(code <= 0x115F ||
code == 0x2329 || code == 0x232A ||
(code >= 0x2E80 && code <= 0xA4CF && code != 0x303F) ||
(code >= 0xAC00 && code <= 0xD7A3) ||
(code >= 0xF900 && code <= 0xFAFF) ||
(code >= 0xFE10 && code <= 0xFE19) ||
(code >= 0xFE30 && code <= 0xFE6F) ||
(code >= 0xFF00 && code <= 0xFF60) ||
(code >= 0xFFE0 && code <= 0xFFE6)))
return true;
return false;
}
// “表示幅”で中央省略(...)
static string TruncateMiddleByCols(string s, int maxCols)
{
if (maxCols <= 0) return "";
int w = DisplayWidth(s);
if (w <= maxCols) return s;
if (maxCols <= 3) return new string('.', maxCols);
int keep = maxCols - 3; // “...”
int headCols = keep / 2;
int tailCols = keep - headCols;
string head = SliceByCols(s, headCols, fromStart: true);
string tail = SliceByCols(s, tailCols, fromStart: false);
return head + "..." + tail;
}
static string SliceByCols(string s, int cols, bool fromStart)
{
if (cols <= 0 || string.IsNullOrEmpty(s)) return "";
var sb = new StringBuilder();
int acc = 0;
if (fromStart)
{
for (int i = 0; i < s.Length; i++)
{
int w = char.IsSurrogate(s[i]) ? 2 : (IsWide(s[i]) ? 2 : 1);
if (acc + w > cols) break;
sb.Append(s[i]); acc += w;
}
}
else
{
for (int i = s.Length - 1; i >= 0; i--)
{
int w = char.IsSurrogate(s[i]) ? 2 : (IsWide(s[i]) ? 2 : 1);
if (acc + w > cols) break;
sb.Insert(0, s[i]); acc += w;
}
}
return sb.ToString();
}
public void LogCsv(string inRel, string outRel, string action, string result, bool skip, string note, long size, DateTime lastWriteUtc, string hash)
{
if (_log == null) return;
CheckAndCompressLog();
string tsLocal = NowLocal();
string lwUtc = (lastWriteUtc == DateTime.MinValue) ? "" : lastWriteUtc.ToString("yyyy-MM-ddTHH:mm:ssZ");
string line = "\"" + inRel + "\",\"" + outRel + "\",\"" + action + "\",\"" + result + "\",\"" + tsLocal + "\",\"" + (skip ? "Skipped" : note) + "\",\"" + SessionId + "\",\"" + size + "\",\"" + lwUtc + "\",\"" + hash + "\"";
WriteLine(line);
if (string.Equals(result, "Success", StringComparison.OrdinalIgnoreCase) ||
string.Equals(result, "SkippedByHistory", StringComparison.OrdinalIgnoreCase))
{
if (!string.IsNullOrEmpty(lwUtc))
{
History[inRel + "|" + outRel] = new HistoryEntry { Size = size, LastWriteUtc = lastWriteUtc, Hash = hash };
}
}
}
void WriteLine(string s)
{
if (_log == null) return;
lock (_log) { _log.WriteLine(s); _log.Flush(); }
}
string NowLocal() { return DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"); }
void CheckAndCompressLog()
{
if (_log == null) return;
try
{
var info = new FileInfo(BaseConfig.LogPath);
if (info.Exists && info.Length > BaseConfig.MaxLogBytes)
{
_log.Flush(); _log.Dispose();
var lines = File.ReadAllLines(BaseConfig.LogPath, Encoding.UTF8);
var headers = new List<string>();
var latest = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < lines.Length; i++)
{
var line = lines[i];
if (line.StartsWith("#RUN")) { headers.Add(line); continue; }
var cols = Csv.Parse(line);
if (cols.Length < 2) continue;
var inRel = Csv.Unquote(cols[0]);
var outRel = Csv.Unquote(cols[1]);
latest[inRel + "|" + outRel] = line;
}
using (var sw = new StreamWriter(BaseConfig.LogPath, false, Encoding.UTF8))
{
foreach (var h in headers) sw.WriteLine(h);
foreach (var kv in latest.Values) sw.WriteLine(kv);
}
var fs = new FileStream(BaseConfig.LogPath, FileMode.Append, FileAccess.Write, FileShare.Read, 4096, FileOptions.WriteThrough);
_log = new StreamWriter(fs, Encoding.UTF8) { AutoFlush = true };
Console.WriteLine("\n[LOG] 圧縮: " + BaseConfig.LogPath);
}
}
catch (Exception ex)
{
Console.WriteLine("\n[LOG] 圧縮失敗: " + ex.Message);
}
}
void LoadHistoryFromLog(string path)
{
try
{
var lines = File.ReadAllLines(path, Encoding.UTF8);
for (int i = 0; i < lines.Length; i++)
{
var line = lines[i];
if (line.StartsWith("#RUN")) continue;
var cols = Csv.Parse(line);
if (cols.Length < 10) continue;
var inRel = Csv.Unquote(cols[0]);
var outRel = Csv.Unquote(cols[1]);
var result = Csv.Unquote(cols[3]);
if (!string.Equals(result, "Success", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(result, "SkippedByHistory", StringComparison.OrdinalIgnoreCase)) continue;
long size = 0; long.TryParse(Csv.Unquote(cols[7]), out size);
DateTime lwUtc = DateTime.MinValue;
var s = Csv.Unquote(cols[8]);
if (!string.IsNullOrEmpty(s))
DateTime.TryParseExact(s, "yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out lwUtc);
string hash = Csv.Unquote(cols[9]);
if (lwUtc != DateTime.MinValue)
History[inRel + "|" + outRel] = new HistoryEntry { Size = size, LastWriteUtc = lwUtc, Hash = hash };
}
}
catch (Exception ex)
{
Console.WriteLine("[LOG] 履歴読み込み失敗: " + ex.Message);
}
}
static bool TryGetLastSuccessfulRunEndTime(string path, out DateTime dt)
{
dt = default(DateTime);
try
{
var lines = File.ReadAllLines(path, Encoding.UTF8);
for (int i = lines.Length - 1; i >= 0; i--)
{
var L = lines[i];
if (L.StartsWith("#RUNEND,SUCCESS"))
{
var p = L.Split(',');
DateTime val;
if (p.Length >= 3 && DateTime.TryParseExact(p[2], "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture, DateTimeStyles.None, out val))
{ dt = val; return true; }
if (p.Length >= 2 && DateTime.TryParseExact(p[1], "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture, DateTimeStyles.None, out val))
{ dt = val; return true; }
}
}
}
catch { }
return false;
}
}
internal class HistoryEntry
{
public long Size;
public DateTime LastWriteUtc;
public string Hash;
}
// ========= CSV =========
internal static class Csv
{
public static string[] Parse(string line)
{
var list = new List<string>();
int i = 0;
while (i < line.Length)
{
if (line[i] == '"')
{
int j = i + 1; var sb = new StringBuilder();
while (j < line.Length)
{
if (line[j] == '"' && j + 1 < line.Length && line[j + 1] == '"') { sb.Append('"'); j += 2; }
else if (line[j] == '"') { j++; break; }
else { sb.Append(line[j]); j++; }
}
list.Add(sb.ToString());
if (j < line.Length && line[j] == ',') j++;
i = j;
}
else
{
int j = line.IndexOf(',', i); if (j < 0) j = line.Length;
list.Add(line.Substring(i, j - i).Trim());
i = j + 1;
}
}
return list.ToArray();
}
public static string Unquote(string s)
{
if (s.Length >= 2 && s[0] == '"' && s[s.Length - 1] == '"') return s.Substring(1, s.Length - 2).Replace("\"\"", "\"");
return s;
}
}
// ========= 便利パーサ =========
internal static class SizeParser
{
public static bool TryParse(string input, out long bytes)
{
bytes = 0;
if (string.IsNullOrWhiteSpace(input)) return false;
input = input.Trim().ToLowerInvariant();
int pos = 0;
while (pos < input.Length && (char.IsDigit(input[pos]) || input[pos] == '.')) pos++;
if (pos == 0) return false;
string num = input.Substring(0, pos);
string unit = input.Substring(pos).Trim();
double n;
if (!double.TryParse(num, NumberStyles.Float, CultureInfo.InvariantCulture, out n)) return false;
double mul =
unit == "" ? 1 :
unit == "b" ? 1 :
unit == "kb" ? 1024 :
unit == "mb" ? 1024 * 1024 :
unit == "gb" ? 1024d * 1024 * 1024 :
double.NaN;
if (double.IsNaN(mul)) return false;
double val = n * mul;
if (val > long.MaxValue) return false;
bytes = (long)val;
return true;
}
}
internal static class DateParser
{
public static bool TryParse(string s, out DateTime dt)
{
return DateTime.TryParseExact(
s,
new[] { "yyyy-MM-dd", "yyyy-MM-ddTHH:mm:ss" },
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeLocal,
out dt);
}
}
internal static class PathUtil
{
public static string GetRelativePath(string basePath, string fullPath)
{
string from = EnsureTrailingSlash(Path.GetFullPath(basePath));
string to = Path.GetFullPath(fullPath);
Uri fromUri = new Uri(from, UriKind.Absolute);
Uri toUri = new Uri(to, UriKind.Absolute);
string rel = Uri.UnescapeDataString(fromUri.MakeRelativeUri(toUri).ToString());
return rel.Replace('/', Path.DirectorySeparatorChar);
}
static string EnsureTrailingSlash(string p)
{
if (!p.EndsWith(Path.DirectorySeparatorChar.ToString())) return p + Path.DirectorySeparatorChar;
return p;
}
}
// ========= エンジン =========
internal static class Engine
{
public static int RunDirectoryToDirectory(SyncConfig cfg, RunContext run)
{
DateTime opStart = DateTime.Now; // 処理時間計測
if (!string.IsNullOrEmpty(cfg.ZipOutputPath) && !string.IsNullOrEmpty(cfg.UnzipInputPath))
{
Console.WriteLine("[ERROR] zip-output と unzip-input は同時指定不可。");
return 2;
}
bool unzipMode = !string.IsNullOrEmpty(cfg.UnzipInputPath);
bool zipMode = !string.IsNullOrEmpty(cfg.ZipOutputPath);
if (unzipMode)
{
if (string.IsNullOrEmpty(cfg.OutputDir))
{
Console.WriteLine("[ERROR] --unzip-input 使用時は出力先(output)が必要です。");
return 2;
}
}
else if (zipMode)
{
if (string.IsNullOrEmpty(cfg.InputDir))
{
Console.WriteLine("[ERROR] --zip-output 使用時は入力元(input)が必要です。");
return 2;
}
}
else
{
if (string.IsNullOrEmpty(cfg.InputDir) || string.IsNullOrEmpty(cfg.OutputDir))
{
Console.WriteLine("[ERROR] 入出力ディレクトリ未指定。");
return 2;
}
}
// --- 列挙:ファイルとフォルダ(フォルダは時刻反映用) ---
List<FileItem> files;
List<DirItem> dirs;
try
{
if (unzipMode) files = EnumerateFromZip(cfg.UnzipInputPath, cfg, out dirs);
else files = EnumerateFromDir(cfg.InputDir, cfg.Recursive, cfg, out dirs);
}
catch (Exception ex)
{
Console.WriteLine("[ERR] 列挙失敗: " + ex.Message);
return 2;
}
files = files.Where(x => FilterAllow(cfg, x)).ToList();
long totalFiles = files.LongCount();
long totalBytes = 0; foreach (var it in files) totalBytes += it.Length;
run.InitProgress(totalFiles, totalBytes);
if (zipMode)
{
// ZIP作成:エントリの LastWriteTime を元時刻に、ディレクトリエントリも生成
string prefix = "";
if (cfg.ZipLayout == "root")
{
var baseName = GetZipRootFolderName(cfg.InputDir);
prefix = baseName + "/";
}
int zr = RunToZip(files, dirs, cfg, run, prefix);
Console.CursorVisible = true;
Console.WriteLine();
var opEnd = DateTime.Now;
var elapsed = opEnd - opStart;
string status = run.AnyFailed || zr != 0 ? "[DONE] エラーあり" : "[DONE] 全成功";
Console.WriteLine($"{status} 完了:{opEnd:yyyy-MM-dd HH:mm:ss} 処理時間:{HMS(elapsed)}");
return run.AnyFailed || zr != 0 ? 1 : 0;
}
if (cfg.EnableSyncDelete && !unzipMode) DeleteSyncFiles(cfg, run, files);
var po = new ParallelOptions { MaxDegreeOfParallelism = cfg.Parallelism };
var bagError = new ConcurrentBag<string>();
// 共有Zip(最適化): unzip_shared && parallel==1 のときのみ有効
ZipArchive sharedZip = null;
if (unzipMode && cfg.UnzipShareZip && cfg.Parallelism == 1)
{
try { sharedZip = ZipFile.OpenRead(cfg.UnzipInputPath); }
catch (Exception ex)
{
Console.WriteLine("[WARN] 共有Zipを開けませんでした。安全モードに切替: " + ex.Message);
sharedZip = null;
}
}
Parallel.ForEach(files, po, item =>
{
string destPath = Path.Combine(cfg.OutputDir, item.RelativePath);
string inRel = item.RelativePath;
string outRel = item.RelativePath;
run.SetNowCopying(inRel, outRel);
if (!unzipMode && TrySkipByHistory(cfg, run, inRel, outRel, item, item.LastWriteUtc))
{
run.StepProgress(true, 0, cfg.ProgressDetail, cfg.ProgressTitle);
return;
}
for (int attempt = 0; attempt <= cfg.Retry; attempt++)
{
bool skipExisting = false; // リトライ毎に初期化
try
{
if (!cfg.DryRun)
{
Directory.CreateDirectory(Path.GetDirectoryName(destPath));
if (!unzipMode && File.Exists(destPath))
skipExisting = AreFilesIdenticalWithDest(cfg, item, destPath);
if (!skipExisting)
{
if (!string.IsNullOrEmpty(item.ZipEntryName))
{
// unzip(共有Zip優先)
if (sharedZip != null)
{
var entry = GetEntryCI(sharedZip, item.ZipEntryName);
if (entry == null) throw new FileNotFoundException("ZIP entry が見つかりません: " + item.ZipEntryName);
using (var src = entry.Open())
using (var dst = File.Create(destPath))
CopyStreamWithProgress(src, dst, run, cfg, item.Length);
}
else
{
using (var zip = ZipFile.OpenRead(cfg.UnzipInputPath ?? item.ZipPath))
{
var entry = GetEntryCI(zip, item.ZipEntryName);
if (entry == null) throw new FileNotFoundException("ZIP entry が見つかりません: " + item.ZipEntryName);
using (var src = entry.Open())
using (var dst = File.Create(destPath))
CopyStreamWithProgress(src, dst, run, cfg, item.Length);
}
}
try { File.SetLastWriteTime(destPath, item.LastWriteLocal); } catch { }
}
else
{
// 通常コピー(進捗付き)
using (var src = File.OpenRead(item.AbsolutePath))
using (var dst = File.Create(destPath))
CopyStreamWithProgress(src, dst, run, cfg, item.Length);
// ← コピー後に“元の更新日時”を反映
try { File.SetLastWriteTime(destPath, item.LastWriteLocal); } catch { }
if (cfg.MoveFiles) File.Delete(item.AbsolutePath);
}
if (cfg.EnableCopyCheck)
{
bool ok = VerifyAfterCopy(cfg, item, destPath, sharedZip);
if (!ok) throw new IOException("コピー後ハッシュチェック失敗");
}
}
string h = ComputeHashForHistoryIfNeeded(cfg, item, sharedZip);
run.LogCsv(inRel, outRel, cfg.MoveFiles ? "Move" : (unzipMode ? "Unzip" : "Copy"),
"Success", skipExisting, "", item.Length, item.LastWriteUtc, h);
}
else
{
// DryRun: 実コピーなし
run.LogCsv(inRel, outRel, cfg.MoveFiles ? "Move" : (unzipMode ? "Unzip" : "Copy"),
"Success", false, "(dryrun)", item.Length, item.LastWriteUtc,
ComputeHashForHistoryIfNeeded(cfg, item, sharedZip));
}
// 進捗は CopyStreamWithProgress 内で加算済み(スキップ時のみ+1)
if (skipExisting) run.StepProgress(true, 0, cfg.ProgressDetail, cfg.ProgressTitle);
return;
}
catch (Exception ex)
{
if (attempt == cfg.Retry)
{
run.AnyFailed = true;
run.LogCsv(inRel, outRel, cfg.MoveFiles ? "Move" : (unzipMode ? "Unzip" : "Copy"),
"Failed", false, ex.Message, item.Length, item.LastWriteUtc, "");
bagError.Add(inRel);
Console.WriteLine("\n[FAIL] " + inRel + " : " + ex.Message);
run.StepProgress(true, 0, cfg.ProgressDetail, cfg.ProgressTitle);
}
else
{
if (cfg.PauseOnError) Thread.Sleep(1500);
}
}
}
});
try { sharedZip?.Dispose(); } catch { }
// --- フォルダの更新日時を反映 ---
try
{
if (!zipMode)
{
if (unzipMode)
ApplyDirectoryTimesFromFiles(cfg.OutputDir, files); // ZIP内のdirエントリが無い場合も、配下ファイルの最大時刻で推定
else
ApplyDirectoryTimestampsCopy(cfg.InputDir, cfg.OutputDir, dirs); // ソースのフォルダ時刻をそのまま反映
}
}
catch (Exception ex)
{
Console.WriteLine("\n[WARN] ディレクトリ時刻反映に失敗: " + ex.Message);
}
if (!string.IsNullOrWhiteSpace(cfg.ErrorListPath))
{
try
{
File.WriteAllLines(cfg.ErrorListPath, bagError.ToArray(), Encoding.UTF8);
Console.WriteLine("\n[ERRLIST] " + cfg.ErrorListPath + " (" + bagError.Count + "件)");
}
catch (Exception ex) { Console.WriteLine("\n[ERRLIST] 書込失敗: " + ex.Message); }
}
Console.CursorVisible = true;
Console.WriteLine();
var opEndFinal = DateTime.Now;
var elapsedFinal = opEndFinal - opStart;
string statusFinal = run.AnyFailed ? "[DONE] エラーあり" : "[DONE] 全成功";
Console.WriteLine($"{statusFinal} 完了:{opEndFinal:yyyy-MM-dd HH:mm:ss} 処理時間:{HMS(elapsedFinal)}");
return run.AnyFailed ? 1 : 0;
}
public static int RunFileToFile(SyncConfig cfg, RunContext run, string srcFile, string dstFile)
{
DateTime opStart = DateTime.Now;
try
{
long sz = new FileInfo(srcFile).Length;
run.InitProgress(1, sz);
run.SetNowCopying(Path.GetFileName(srcFile), Path.GetFileName(dstFile));
if (cfg.DryRun)
{
run.LogCsv(Path.GetFileName(srcFile), Path.GetFileName(dstFile), cfg.MoveFiles ? "Move" : "Copy", "Success", false, "(dryrun)", sz, File.GetLastWriteTimeUtc(srcFile), "");
var opEndDR = DateTime.Now;
Console.WriteLine($"\n[DRYRUN] 完了 完了:{opEndDR:yyyy-MM-dd HH:mm:ss} 処理時間:{HMS(opEndDR - opStart)}");
return 0;
}
Directory.CreateDirectory(Path.GetDirectoryName(dstFile));
if (File.Exists(dstFile))
{
if (AreFilesIdentical(cfg, srcFile, dstFile))
{
run.LogCsv(Path.GetFileName(srcFile), Path.GetFileName(dstFile), cfg.MoveFiles ? "Move" : "Copy", "Success", true, "Identical", sz, File.GetLastWriteTimeUtc(srcFile), "");
run.StepProgress(true, 0, cfg.ProgressDetail, cfg.ProgressTitle);
var opEndSk = DateTime.Now;
Console.WriteLine($"\n[SKIP] 同一 完了:{opEndSk:yyyy-MM-dd HH:mm:ss} 処理時間:{HMS(opEndSk - opStart)}");
return 0;
}
}
using (var src = File.OpenRead(srcFile))
using (var dst = File.Create(dstFile))
CopyStreamWithProgress(src, dst, run, cfg, sz);
// ファイルの “元の更新日時” を反映
try { File.SetLastWriteTime(dstFile, File.GetLastWriteTime(srcFile)); } catch { }
if (cfg.MoveFiles) File.Delete(srcFile);
if (cfg.EnableCopyCheck && !AreFilesIdentical(cfg, srcFile, dstFile))
throw new IOException("コピー後ハッシュチェック失敗");
run.LogCsv(Path.GetFileName(srcFile), Path.GetFileName(dstFile), cfg.MoveFiles ? "Move" : "Copy", "Success", false, "", sz, File.GetLastWriteTimeUtc(srcFile), "");
var opEnd = DateTime.Now;
Console.WriteLine($"\n[DONE] 成功 完了:{opEnd:yyyy-MM-dd HH:mm:ss} 処理時間:{HMS(opEnd - opStart)}");
return 0;
}
catch (Exception ex)
{
run.AnyFailed = true;
long sz = 0; try { sz = new FileInfo(srcFile).Length; } catch { }
DateTime lw = DateTime.MinValue; try { lw = File.GetLastWriteTimeUtc(srcFile); } catch { }
run.LogCsv(Path.GetFileName(srcFile), Path.GetFileName(dstFile), cfg.MoveFiles ? "Move" : "Copy", "Failed", false, ex.Message, sz, lw, "");
Console.WriteLine("\n[ERR] " + ex.Message);
var opEnd = DateTime.Now;
Console.WriteLine($" 完了:{opEnd:yyyy-MM-dd HH:mm:ss} 処理時間:{HMS(opEnd - opStart)}");
return 1;
}
}
// ==== ZIP 出力(Create/Update安全・重複ポリシー・圧縮レベル・時刻反映) ====
// ==== ZIP 出力(Create/Update安全・重複ポリシー・圧縮レベル・時刻反映) ====
// Createモードでは GetEntry/Entries に触らないように修正
static int RunToZip(List<FileItem> items, List<DirItem> dirItems, SyncConfig cfg, RunContext run, string prefix = "")
{
try
{
bool append = File.Exists(cfg.ZipOutputPath);
using (var zip = ZipFile.Open(cfg.ZipOutputPath, append ? ZipArchiveMode.Update : ZipArchiveMode.Create))
{
var known = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (zip.Mode == ZipArchiveMode.Update)
{
// Update のときだけ既存エントリを収集
foreach (var e in zip.Entries) known.Add(e.FullName);
}
var created = new Dictionary<string, ZipArchiveEntry>(StringComparer.OrdinalIgnoreCase);
// 圧縮レベル
CompressionLevel level = CompressionLevel.Optimal;
switch ((cfg.ZipCompression ?? "optimal").Trim().ToLowerInvariant())
{
case "fastest": level = CompressionLevel.Fastest; break;
case "none": level = CompressionLevel.NoCompression; break;
default: level = CompressionLevel.Optimal; break;
}
// 1) ディレクトリエントリ(Createモードでは GetEntry を呼ばない)
if (dirItems != null && dirItems.Count > 0)
{
foreach (var d in dirItems.OrderBy(x => x.RelativePath, StringComparer.OrdinalIgnoreCase))
{
string dirName = (prefix ?? "") + d.RelativePath.Replace(Path.DirectorySeparatorChar, '/') + "/";
if (known.Contains(dirName) || created.ContainsKey(dirName)) continue;
ZipArchiveEntry de = null;
if (zip.Mode == ZipArchiveMode.Update)
{
// Update のときのみ既存を探す
de = zip.GetEntry(dirName);
}
if (de == null)
{
de = zip.CreateEntry(dirName, CompressionLevel.NoCompression);
}
de.LastWriteTime = new DateTimeOffset(d.LastWriteLocal);
known.Add(dirName);
created[dirName] = de;
}
}
// 2) ファイルエントリ
foreach (var item in items)
{
run.SetNowCopying(item.RelativePath, item.RelativePath);
string entryName = (prefix ?? "") + item.RelativePath.Replace(Path.DirectorySeparatorChar, '/');
string finalName = entryName;
// 重複ポリシー
switch (cfg.ZipDuplicate)
{
case "overwrite":
if (zip.Mode == ZipArchiveMode.Update && known.Contains(finalName))
{
var exist = zip.GetEntry(finalName)
?? zip.Entries.FirstOrDefault(e => string.Equals(e.FullName, finalName, StringComparison.OrdinalIgnoreCase));
if (exist != null) { exist.Delete(); known.Remove(finalName); }
}
if (created.TryGetValue(finalName, out var prev))
{
try { prev.Delete(); } catch { }
created.Remove(finalName);
known.Remove(finalName);
}
break;
case "skip":
if (known.Contains(finalName) || created.ContainsKey(finalName))
{
run.LogCsv(item.RelativePath, finalName.Replace('/', Path.DirectorySeparatorChar),
"ZipAdd", "Success", true, "dup-skip", item.Length, item.LastWriteUtc,
ComputeHashForHistoryIfNeeded(cfg, item, null));
run.StepProgress(true, 0, cfg.ProgressDetail, cfg.ProgressTitle);
continue;
}
break;
case "seq":
default:
finalName = GenerateUniqueName(finalName, known, created);
break;
}
var entry = zip.CreateEntry(finalName, level);
entry.LastWriteTime = new DateTimeOffset(item.LastWriteLocal); // 元の更新日時を付与
using (var dst = entry.Open())
using (var src = File.OpenRead(item.AbsolutePath))
{
CopyStreamWithProgress(src, dst, run, cfg, item.Length);
}
known.Add(finalName);
created[finalName] = entry;
run.LogCsv(item.RelativePath, finalName.Replace('/', Path.DirectorySeparatorChar),
"ZipAdd", "Success", false, "", item.Length, item.LastWriteUtc,
ComputeHashForHistoryIfNeeded(cfg, item, null));
}
}
Console.WriteLine("\n[DONE] ZIP 出力完了");
return 0;
}
catch (Exception ex)
{
run.AnyFailed = true;
Console.WriteLine("\n[ERR] ZIP 出力失敗: " + ex.Message);
return 1;
}
}
static string GenerateUniqueName(string desired, HashSet<string> known, Dictionary<string, ZipArchiveEntry> created)
{
if (!known.Contains(desired) && !created.ContainsKey(desired)) return desired;
int slash = desired.LastIndexOf('/');
string dir = (slash >= 0) ? desired.Substring(0, slash + 1) : "";
string name = (slash >= 0) ? desired.Substring(slash + 1) : desired;
int dot = name.LastIndexOf('.');
string baseName = (dot >= 0) ? name.Substring(0, dot) : name;
string ext = (dot >= 0) ? name.Substring(dot) : "";
for (int i = 1; i < 10000; i++)
{
string cand = dir + baseName + " (" + i + ")" + ext;
if (!known.Contains(cand) && !created.ContainsKey(cand)) return cand;
}
return dir + baseName + " (" + DateTime.Now.ToString("yyyyMMddHHmmssfff") + ")" + ext;
}
// ====== 列挙 ======
class FileItem
{
public string AbsolutePath;
public string RelativePath;
public long Length;
public DateTime LastWriteLocal;
public DateTime LastWriteUtc;
// unzip 用
public string ZipPath; // zip ファイルパス
public string ZipEntryName; // ZIP 内パス(/ 区切り)
}
class DirItem
{
public string RelativePath;
public DateTime LastWriteLocal;
}
static List<FileItem> EnumerateFromDir(string root, bool recursive, SyncConfig cfg, out List<DirItem> dirItems)
{
var items = new List<FileItem>();
dirItems = new List<DirItem>();
// ディレクトリ(時刻反映用)
var dopt = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
foreach (var d in Directory.GetDirectories(root, "*", dopt))
{
var di = new DirectoryInfo(d);
var rel = PathUtil.GetRelativePath(root, d);
dirItems.Add(new DirItem { RelativePath = rel, LastWriteLocal = di.LastWriteTime });
}
// ファイル
var fopt = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
var files = Directory.GetFiles(root, "*", fopt);
for (int i = 0; i < files.Length; i++)
{
var path = files[i];
if (IsExcludedFolder(cfg, path)) continue;
var fi = new FileInfo(path);
var rel = PathUtil.GetRelativePath(root, path);
items.Add(new FileItem
{
AbsolutePath = path,
RelativePath = rel,
Length = fi.Length,
LastWriteLocal = fi.LastWriteTime,
LastWriteUtc = fi.LastWriteTimeUtc
});
}
return items;
}
static List<FileItem> EnumerateFromZip(string zipPath, SyncConfig cfg, out List<DirItem> dirItems)
{
var items = new List<FileItem>();
var dirMap = new Dictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase); // 相対dir → 最終時刻
using (var zip = ZipFile.OpenRead(zipPath))
{
foreach (var e in zip.Entries)
{
if (string.IsNullOrEmpty(e.Name))
{
// ディレクトリエントリ(ZIP に入っていればその時刻を採用)
string relDir = e.FullName.TrimEnd('/').Replace('/', Path.DirectorySeparatorChar);
if (relDir.Length > 0)
dirMap[relDir] = e.LastWriteTime.DateTime;
continue;
}
var local = e.LastWriteTime.DateTime;
var utc = DateTime.SpecifyKind(local, DateTimeKind.Local).ToUniversalTime();
string relFile = e.FullName.Replace('/', Path.DirectorySeparatorChar);
items.Add(new FileItem
{
AbsolutePath = null,
RelativePath = relFile,
Length = e.Length,
LastWriteLocal = local,
LastWriteUtc = utc,
ZipPath = zipPath,
ZipEntryName = e.FullName
});
// 親ディレクトリの推定時刻(エントリが無ければ配下ファイルの最大時刻)
string dir = Path.GetDirectoryName(relFile) ?? "";
while (!string.IsNullOrEmpty(dir))
{
DateTime cur;
if (!dirMap.TryGetValue(dir, out cur) || cur < local) dirMap[dir] = local;
var parent = Path.GetDirectoryName(dir) ?? "";
if (string.Equals(parent, dir, StringComparison.OrdinalIgnoreCase)) break;
dir = parent;
}
}
}
dirItems = dirMap.Select(kv => new DirItem { RelativePath = kv.Key, LastWriteLocal = kv.Value }).ToList();
return items;
}
// ====== フィルタ・スキップ ======
static bool FilterAllow(SyncConfig cfg, FileItem x)
{
if (cfg.ModifiedAfter.HasValue && x.LastWriteLocal <= cfg.ModifiedAfter.Value) return false;
if (cfg.ModifiedBefore.HasValue && x.LastWriteLocal >= cfg.ModifiedBefore.Value) return false;
string ext = Path.GetExtension(x.RelativePath).ToLowerInvariant();
if (cfg.FilterExt.Count > 0 && !cfg.FilterExt.Contains(ext)) return false;
if (cfg.ExcludeExt.Contains(ext)) return false;
if (IsExcludedFolder(cfg, cfg.InputDir != null ? Path.Combine(cfg.InputDir, x.RelativePath) : x.RelativePath, fromRelative: cfg.InputDir == null))
return false;
var name = Path.GetFileName(x.RelativePath);
if (cfg.NameMatch.Count > 0)
{
bool ok = false;
foreach (var p in cfg.NameMatch) if (WildcardIsMatch(name, p)) { ok = true; break; }
if (!ok) return false;
}
if (cfg.NameRegex.Count > 0)
{
bool ok = false;
foreach (var rx in cfg.NameRegex) if (rx.IsMatch(name)) { ok = true; break; }
if (!ok) return false;
}
return true;
}
static bool IsExcludedFolder(SyncConfig cfg, string filePath, bool fromRelative = false)
{
if (cfg.ExcludeFolders.Count == 0) return false;
string[] parts;
if (fromRelative)
{
var dir = Path.GetDirectoryName(filePath) ?? "";
parts = dir.Split(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, StringSplitOptions.RemoveEmptyEntries);
}
else
{
if (string.IsNullOrEmpty(cfg.InputDir)) return false;
var dir = Path.GetDirectoryName(filePath) ?? "";
var rel = PathUtil.GetRelativePath(cfg.InputDir, dir);
parts = rel.Split(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, StringSplitOptions.RemoveEmptyEntries);
}
foreach (var d in parts)
if (cfg.ExcludeFolders.Contains(d, StringComparer.OrdinalIgnoreCase)) return true;
return false;
}
static bool WildcardIsMatch(string text, string pattern)
{
string rx = "^" + Regex.Escape(pattern).Replace("\\*", ".*").Replace("\\?", ".") + "$";
return Regex.IsMatch(text, rx, RegexOptions.IgnoreCase);
}
static bool TrySkipByHistory(SyncConfig cfg, RunContext run, string inRel, string outRel, FileItem item, DateTime lastWriteUtc)
{
HistoryEntry hist;
if (!run.History.TryGetValue(inRel + "|" + outRel, out hist)) return false;
if (hist.Size != item.Length) return false;
if (hist.LastWriteUtc != lastWriteUtc) return false;
if (!string.IsNullOrEmpty(hist.Hash))
{
string currentHash = ComputeHashForHistoryIfNeeded(cfg, item, null);
if (!string.Equals(hist.Hash, currentHash, StringComparison.OrdinalIgnoreCase)) return false;
}
run.LogCsv(inRel, outRel, cfg.MoveFiles ? "Move" : "Copy", "SkippedByHistory", true, "", item.Length, lastWriteUtc, hist.Hash ?? "");
return true;
}
static void DeleteSyncFiles(SyncConfig cfg, RunContext run, List<FileItem> inputs)
{
try
{
var expect = new HashSet<string>(inputs.Select(x => x.RelativePath), StringComparer.OrdinalIgnoreCase);
var opt = cfg.Recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
string[] outFiles = Directory.Exists(cfg.OutputDir) ? Directory.GetFiles(cfg.OutputDir, "*", opt) : new string[0];
foreach (var of in outFiles)
{
var rel = PathUtil.GetRelativePath(cfg.OutputDir, of);
if (!expect.Contains(rel))
{
if (!cfg.DryRun) File.Delete(of);
run.LogCsv(rel, rel, "Delete", "Success", false, "syncdelete", 0, DateTime.MinValue, "");
}
}
}
catch (Exception ex)
{
run.AnyFailed = true;
Console.WriteLine("[SYNCDELETE] 失敗: " + ex.Message);
}
}
// ===== ディレクトリ時刻反映 =====
// ディレクトリ→ディレクトリコピー時:ソース時刻をそのまま宛先へ
static void ApplyDirectoryTimestampsCopy(string srcRoot, string dstRoot, List<DirItem> srcDirs)
{
if (srcDirs == null) return;
foreach (var d in srcDirs.OrderByDescending(x => x.RelativePath.Length)) // 深い順に反映(子→親)
{
string dstDir = Path.Combine(dstRoot, d.RelativePath);
try
{
if (Directory.Exists(dstDir))
Directory.SetLastWriteTime(dstDir, d.LastWriteLocal);
}
catch { }
}
// ルート自身
try
{
if (Directory.Exists(dstRoot))
Directory.SetLastWriteTime(dstRoot, new DirectoryInfo(srcRoot).LastWriteTime);
}
catch { }
}
// 解凍時:ZIPにdirエントリがない場合も、配下ファイルの最大時刻で推定して反映
static void ApplyDirectoryTimesFromFiles(string dstRoot, List<FileItem> files)
{
var map = new Dictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase);
foreach (var f in files)
{
string dir = Path.GetDirectoryName(Path.Combine(dstRoot, f.RelativePath)) ?? dstRoot;
while (!string.IsNullOrEmpty(dir) && dir.StartsWith(dstRoot, StringComparison.OrdinalIgnoreCase))
{
DateTime cur;
if (!map.TryGetValue(dir, out cur) || cur < f.LastWriteLocal) map[dir] = f.LastWriteLocal;
var parent = Path.GetDirectoryName(dir);
if (string.IsNullOrEmpty(parent) || string.Equals(parent, dir, StringComparison.OrdinalIgnoreCase)) break;
dir = parent;
}
}
// 深い順で反映
foreach (var kv in map.OrderByDescending(k => k.Key.Length))
{
try { if (Directory.Exists(kv.Key)) Directory.SetLastWriteTime(kv.Key, kv.Value); } catch { }
}
}
// ===== 同一性判定 =====
static bool AreFilesIdentical(SyncConfig cfg, string src, string dst)
{
var sfi = new FileInfo(src);
var dfi = new FileInfo(dst);
if (!sfi.Exists || !dfi.Exists) return false;
if (sfi.Length != dfi.Length) return false;
if (sfi.LastWriteTime != dfi.LastWriteTime) return false;
if (cfg.HashMode == "none") return true;
if (cfg.HashMode == "full" || sfi.Length <= cfg.PartialBytes * 3)
{
byte[] h1 = HashAll(src);
byte[] h2 = HashAll(dst);
return StructuralComparisons.StructuralEqualityComparer.Equals(h1, h2);
}
else
{
byte[] h1 = HashPartial(src, cfg.PartialBytes);
byte[] h2 = HashPartial(dst, cfg.PartialBytes);
return StructuralComparisons.StructuralEqualityComparer.Equals(h1, h2);
}
}
static bool AreFilesIdenticalWithDest(SyncConfig cfg, FileItem src, string destPath)
{
var fi2 = new FileInfo(destPath);
if (src.Length != fi2.Length) return false;
if (src.LastWriteLocal != fi2.LastWriteTime) return false;
if (cfg.HashMode == "none") return true;
if (cfg.HashMode == "full" || src.Length <= cfg.PartialBytes * 3)
{
byte[] h1 = (!string.IsNullOrEmpty(src.ZipEntryName))
? HashAllFromZipPath(cfg.UnzipInputPath ?? src.ZipPath, src.ZipEntryName)
: HashAll(src.AbsolutePath);
byte[] h2 = HashAll(destPath);
return StructuralComparisons.StructuralEqualityComparer.Equals(h1, h2);
}
else
{
byte[] h1 = (!string.IsNullOrEmpty(src.ZipEntryName))
? HashPartialFromZipPath(cfg.UnzipInputPath ?? src.ZipPath, src.ZipEntryName, cfg.PartialBytes)
: HashPartial(src.AbsolutePath, cfg.PartialBytes);
byte[] h2 = HashPartial(destPath, cfg.PartialBytes);
return StructuralComparisons.StructuralEqualityComparer.Equals(h1, h2);
}
}
// ZIP の root レイアウト名
static string GetZipRootFolderName(string inputDir)
{
try
{
var full = Path.GetFullPath(inputDir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
var name = new DirectoryInfo(full).Name;
if (string.IsNullOrEmpty(name) || name.EndsWith(":"))
{
string root = Path.GetPathRoot(full).TrimEnd('\\', '/');
name = root.Replace(":", "") + "_root";
}
name = name.Replace('/', '_').Replace('\\', '_');
return name;
}
catch { return "root"; }
}
// ===== 進捗付きコピー =====
static void CopyStreamWithProgress(Stream src, Stream dst, RunContext run, SyncConfig cfg, long totalBytes)
{
const int BUF = 1024 * 1024; // 1MB
byte[] buffer = new byte[BUF];
int r;
while ((r = src.Read(buffer, 0, buffer.Length)) > 0)
{
dst.Write(buffer, 0, r);
run.StepProgress(false, r, cfg.ProgressDetail, cfg.ProgressTitle);
}
run.StepProgress(true, 0, cfg.ProgressDetail, cfg.ProgressTitle);
}
// ===== コピー後検証・履歴ハッシュ =====
static bool VerifyAfterCopy(SyncConfig cfg, FileItem src, string destPath, ZipArchive sharedZip)
{
if (cfg.HashMode == "none") return true;
if (cfg.HashMode == "full" || src.Length <= cfg.PartialBytes * 3)
{
byte[] h1;
if (!string.IsNullOrEmpty(src.ZipEntryName))
{
if (sharedZip != null) h1 = HashAll(sharedZip, src.ZipEntryName);
else h1 = HashAllFromZipPath(cfg.UnzipInputPath ?? src.ZipPath, src.ZipEntryName);
}
else h1 = HashAll(src.AbsolutePath);
byte[] h2 = HashAll(destPath);
return StructuralComparisons.StructuralEqualityComparer.Equals(h1, h2);
}
else
{
byte[] h1;
if (!string.IsNullOrEmpty(src.ZipEntryName))
{
if (sharedZip != null) h1 = HashPartial(sharedZip, src.ZipEntryName, cfg.PartialBytes);
else h1 = HashPartialFromZipPath(cfg.UnzipInputPath ?? src.ZipPath, src.ZipEntryName, cfg.PartialBytes);
}
else h1 = HashPartial(src.AbsolutePath, cfg.PartialBytes);
byte[] h2 = HashPartial(destPath, cfg.PartialBytes);
return StructuralComparisons.StructuralEqualityComparer.Equals(h1, h2);
}
}
static string ComputeHashForHistoryIfNeeded(SyncConfig cfg, FileItem item, ZipArchive sharedZip)
{
if (cfg.HashMode == "none") return "";
byte[] h;
bool full = (cfg.HashMode == "full") || (item.Length <= cfg.PartialBytes * 3);
if (full)
{
if (!string.IsNullOrEmpty(item.ZipEntryName))
{
if (sharedZip != null) h = HashAll(sharedZip, item.ZipEntryName);
else h = HashAllFromZipPath(cfg.UnzipInputPath ?? item.ZipPath, item.ZipEntryName);
}
else h = HashAll(item.AbsolutePath);
}
else
{
if (!string.IsNullOrEmpty(item.ZipEntryName))
{
if (sharedZip != null) h = HashPartial(sharedZip, item.ZipEntryName, cfg.PartialBytes);
else h = HashPartialFromZipPath(cfg.UnzipInputPath ?? item.ZipPath, item.ZipEntryName, cfg.PartialBytes);
}
else h = HashPartial(item.AbsolutePath, cfg.PartialBytes);
}
string hex = BitConverter.ToString(h).Replace("-", "");
string tag = full ? "full" : ("partial" + HumanSize(cfg.PartialBytes));
return tag + ":" + hex;
}
// ==== ハッシュ系 ====
static string HumanSize(long b)
{
if (b % (1024L * 1024 * 1024) == 0) return (b / (1024L * 1024 * 1024)) + "gb";
if (b % (1024L * 1024) == 0) return (b / (1024L * 1024)) + "mb";
if (b % 1024L == 0) return (b / 1024L) + "kb";
return b + "b";
}
static byte[] HashAll(string path)
{
using (var sha = SHA256.Create())
using (var s = File.OpenRead(path))
return sha.ComputeHash(s);
}
static byte[] HashAll(ZipArchiveEntry e)
{
using (var sha = SHA256.Create())
using (var s = e.Open())
return sha.ComputeHash(s);
}
static byte[] HashAll(ZipArchive zip, string entryName)
{
var e = GetEntryCI(zip, entryName);
if (e == null) throw new FileNotFoundException("ZIP entry が見つかりません: " + entryName);
return HashAll(e);
}
static byte[] HashAllFromZipPath(string zipPath, string entryName)
{
using (var zip = ZipFile.OpenRead(zipPath))
return HashAll(zip, entryName);
}
static byte[] HashPartial(string path, int n)
{
using (var sha = SHA256.Create())
using (var stream = File.OpenRead(path))
return HashPartialInner(stream, sha, n);
}
static byte[] HashPartial(ZipArchiveEntry e, int n)
{
using (var sha = SHA256.Create())
using (var stream = e.Open())
return HashPartialInner(stream, sha, n);
}
static byte[] HashPartial(ZipArchive zip, string entryName, int n)
{
var e = GetEntryCI(zip, entryName);
if (e == null) throw new FileNotFoundException("ZIP entry が見つかりません: " + entryName);
return HashPartial(e, n);
}
static byte[] HashPartialFromZipPath(string zipPath, string entryName, int n)
{
using (var zip = ZipFile.OpenRead(zipPath))
return HashPartial(zip, entryName, n);
}
static byte[] HashPartialInner(Stream stream, HashAlgorithm sha, int n)
{
long len = stream.CanSeek ? stream.Length : -1;
if (len >= 0 && len <= n * 3)
{
using (var sha2 = SHA256.Create())
{
if (stream.CanSeek) stream.Seek(0, SeekOrigin.Begin);
return sha2.ComputeHash(stream);
}
}
byte[] buf = new byte[n * 3];
int off = 0;
SeekSafe(stream, 0);
off += stream.Read(buf, off, n);
if (len > n * 2) { SeekSafe(stream, len / 2); off += stream.Read(buf, off, n); }
if (len > n) { SeekSafe(stream, len - n); off += stream.Read(buf, off, n); }
return sha.ComputeHash(buf, 0, off);
}
static void SeekSafe(Stream s, long pos)
{
if (s.CanSeek) { s.Seek(pos, SeekOrigin.Begin); return; }
const int chunk = 64 * 1024;
long remain = pos;
byte[] dump = new byte[chunk];
while (remain > 0)
{
int want = (int)Math.Min(chunk, remain);
int r = s.Read(dump, 0, want);
if (r <= 0) break;
remain -= r;
}
}
static ZipArchiveEntry GetEntryCI(ZipArchive zip, string fullName)
{
var e = zip.GetEntry(fullName);
if (e != null) return e;
return zip.Entries.FirstOrDefault(x => string.Equals(x.FullName, fullName, StringComparison.OrdinalIgnoreCase));
}
// ---- ヘルパー:HH:MM:SS ----
static string HMS(TimeSpan ts)
{
return string.Format("{0:D2}:{1:D2}:{2:D2}", (int)ts.TotalHours, ts.Minutes, ts.Seconds);
}
}
}
・説明書はこちら
Readme.md
# FileSync 実行ガイド(Windows / .NET Framework 4.8)このドキュメントは FileSync.exe の使い方を、サンプルと共に 1 つの Markdown にまとめたものです。
すべてのオプションは--
と-
の どちらでも指定可能(例:--input
/-input
)。
目次
概要
- FileSync.exe は、フォルダ/ファイルのコピー・移動・差分同期・ZIP 作成/解凍を行う CLI ツールです。
- 速度・残り時間・完了サイズなどの 詳細進捗を出しつつ、同一行更新表示にも対応。
- 更新日時とサイズでの判定 → 必要時のみハッシュ の順に比較して高速化。
- 元の更新日時を保持(コピー/解凍/ZIP 生成時に反映)。
- UTF-8 CSV ログにより、次回実行で履歴スキップが可能。
主な機能
- フォルダ/ファイルのコピー・移動(再帰、並列、リトライ)
- フィルタ(拡張子 include/exclude、ファイル名ワイルドカード/正規表現、更新日時)
- 差分同期(出力側にだけ残っているファイルを削除)
- ZIP 作成(圧縮レベル、重複ポリシー、ZIP 内レイアウト)
- ZIP 解凍(共有 ZipArchive 最適化・進捗表示)
- ハッシュ比較(なし/部分/全体、部分は先頭・中間・末尾の N バイトずつ)
- 履歴ログ(UTF-8、最大サイズ、履歴によるスキップ)
- 進捗(速度は MB/s・1 桁小数、完了サイズの小数桁は自動調整、右端に現在処理ファイル/マッピング)
クイックスタート
FileSync.exe --input="D:\in" --output="E:\out" --subdirectory --progress-detail
代表的な使用例
A) フォルダ → フォルダ(コピー)
- 再帰なし
FileSync.exe --input="D:\in" --output="E:\out"
- サブフォルダ含む
FileSync.exe --input="D:\in" --output="E:\out" --subdirectory
B) フォルダ → フォルダ(移動)
FileSync.exe --input="D:\in" --output="E:\out" --subdirectory --move
C) 差分同期
FileSync.exe --input="D:\in" --output="E:\out" --subdirectory --sync-delete
D) フィルタ(拡張子・名前・更新日時)
- 拡張子を 通す(include)
FileSync.exe --input="D:\in" --output="E:\out" --subdirectory --filter-ext=.jpg,.png,.mp4
- 拡張子を 除外
FileSync.exe --input="D:\in" --output="E:\out" --exclude-ext=.tmp,.db
- ファイル名ワイルドカード
FileSync.exe --input="D:\in" --output="E:\out" --name-match="IMG_*.JPG" --name-match="*.CR3"
- 正規表現
FileSync.exe --input="D:\in" --output="E:\out" --name-regex="^ZFC_\d+\.JPG$"
- フォルダ除外(相対フォルダ名)
FileSync.exe --input="D:\in" --output="E:\out" --exclude-folder=cache --exclude-folder=_tmp
- 更新日時で絞る(
YYYY-MM-DD
)
FileSync.exe --input="D:\in" --output="E:\out" --modified-after="2025-01-01" --modified-before="2025-09-01"
E) 並列・リトライ・コピー後検証
FileSync.exe --input="D:\in" --output="E:\out" --subdirectory ^
--parallel=4 --retry=2 --copy-check
F) 進捗表示(詳細・同一行)
- 詳細
FileSync.exe --input="D:\in" --output="E:\out" --subdirectory --progress-detail
- 同一行
FileSync.exe --input="D:\in" --output="E:\out" --subdirectory --progress-detail --progress-singleline
- 強制同一行(リダイレクト環境でも)
FileSync.exe --input="D:\in" --output="E:\out" --subdirectory --progress-detail --progress-singleline --progress-force-inline
G) 履歴ログ(UTF-8 CSV)
- ログ出力(履歴活用)
FileSync.exe --input="D:\in" --output="E:\out" --subdirectory --log="D:\logs\copy_history.csv"
- ログ最大サイズ(超過時はファイルごとに最新のみ保持)
FileSync.exe --input="D:\in" --output="E:\out" --subdirectory --log="D:\logs\copy_history.csv" --log-max-size=50mb
- エラーファイル一覧
FileSync.exe --input="D:\in" --output="E:\out" --subdirectory --error-list="D:\logs\error_files.txt"
H) ZIP 作成
- 新規/追記(既存 ZIP があれば Update)
FileSync.exe --input="D:\in" --zip-output="D:\out\backup.zip" --subdirectory
- 圧縮レベル:
optimal | fastest | none
FileSync.exe --input="D:\in" --zip-output="D:\out\backup.zip" --zip-compression=fastest
-
ZIP 内レイアウト
-
flat
(既定):D:\in\A\B.txt → A/B.txt
-
root
:入力フォルダ名を ZIP 先頭に付与(in/A/B.txt
)
-
FileSync.exe --input="D:\in" --zip-output="D:\out\backup.zip" --subdirectory --zip-layout=root
- 重複ポリシー:
overwrite | skip | seq
FileSync.exe --input="D:\in" --zip-output="D:\out\backup.zip" --zip-duplicate=overwrite
I) ZIP 解凍
FileSync.exe --unzip-input="D:\in\data.zip" --output="D:\out" --progress-detail
- 共有 ZipArchive を使って高速化(並列 1 指定)
FileSync.exe --unzip-input="D:\in\data.zip" --output="D:\out" --parallel=1 --unzip-share-zip --progress-detail --progress-singleline
J) ハッシュ判定
- フルハッシュ
FileSync.exe --input="D:\in" --output="E:\out" --hash-mode=full
- 部分ハッシュ(先頭/中間/末尾の N バイト)
FileSync.exe --input="D:\in" --output="E:\out" --hash-mode=partial --partial-bytes=256kb
- ハッシュ無し(サイズ・更新日時のみ)
FileSync.exe --input="D:\in" --output="E:\out" --hash-mode=none
--partial-bytes
の単位はb|kb|mb|gb
(例:1024
/1024b
/100kb
/1gb
)。未指定はバイト。
K) オールインワン例
FileSync.exe --input="D:\photo" --output="E:\backup\photo" --subdirectory --move ^
--filter-ext=.jpg,.cr3 --name-match="IMG_*.JPG" ^
--parallel=4 --retry=2 --copy-check ^
--hash-mode=partial --partial-bytes=128kb ^
--log="E:\logs\photo_copy.csv" --log-max-size=100mb ^
--progress-detail --progress-singleline
TOML 設定での実行(CLI が優先)
最小例(config.toml
)
# 入出力
input = "D:/in"
output = "E:/out"
subdirectory = true
# 進捗
progress_detail = true
progress_singleline = true
# ハッシュ
hash_mode = "partial"
partial_bytes = "256kb"
# ログ
log = "D:/logs/history.csv"
log_max_size = "50mb"
実行(TOML + CLI で一部上書き)
FileSync.exe --config="D:\cfg\config.toml" --parallel=8 --zip-output="E:\out\backup.zip"
ルール:TOML を読み込んだ後に CLI で上書き(CLI が優先)。
オプション一覧(統一名称)
入出力
-
--input=<dir>
/--output=<dir>
-
--subdirectory
(サブフォルダも処理) -
--move
(コピー後に元を削除)
差分同期
-
--sync-delete
(出力にだけ残ったファイルを削除)
進捗
--progress-detail
-
--progress-singleline
(同一行) -
--progress-force-inline
(リダイレクト環境でも強制)
並列・信頼性
--parallel=<N>
--retry=<N>
-
--copy-check
(コピー後ハッシュ検証) -
--pause-on-error
(失敗時に小休止) -
--dry-run
(試走)
フィルタ
-
--filter-ext=.jpg,.png
(include) -
--exclude-ext=.tmp
(exclude) -
--name-match="*.JPG"
(ワイルドカード・複数可) -
--name-regex="^IMG_\d+\.JPG$"
(正規表現・複数可) -
--exclude-folder=cache
(相対フォルダ名・複数可) -
--modified-after="YYYY-MM-DD"
/--modified-before="YYYY-MM-DD"
ハッシュ
--hash-mode=full|partial|none
-
--partial-bytes=256kb
(b|kb|mb|gb
対応)
ZIP
--zip-output="path.zip"
--zip-compression=optimal|fastest|none
--zip-layout=flat|root
--zip-duplicate=overwrite|skip|seq
UNZIP
--unzip-input="path.zip"
-
--unzip-share-zip
(並列 1 と併用)
ログ・エラー
-
--log="path.csv"
(UTF-8) --log-max-size=50mb
--error-list="errors.txt"
設定ファイル
--config="config.toml"
進捗表示の仕様
-
表示例(単一行/詳細):
進捗: 21/ 21 速度: 34.5 MB/s 残り:00:00:00 完了: 2.939 GB/ 2.939 GB <右側: ファイル・src→dst・マッピング>
-
速度は常に MB/s(小数 1 桁)。
-
完了サイズの小数桁は、右側の表示を優先して 3→2→1→0 と自動調整。
-
右側(ファイル名・
src → dst
・[input → output]
)は 中央省略...
を優先。 -
--progress-singleline
で 同一行上書き、--progress-force-inline
で 強制。 -
最終行は
[DONE] 全成功 完了:YYYY-MM-DD HH:MM:SS 処理時間:HH:MM:SS
の形式で終了時刻・処理時間を表示(エラー時は「エラーあり」)。
ZIP/UNZIP の仕様
-
ZIP 作成:Create/Update に対応。Create モードでは
.Entries
/GetEntry
に触れない安全実装。 -
圧縮レベル:
optimal / fastest / none
。 -
ZIP 内レイアウト:
-
flat
(既定):入力ディレクトリの相対をそのまま -
root
:ZIP 先頭に入力フォルダ名を付与
-
-
重複ポリシー:
overwrite
(置換) /skip
(飛ばす) /seq
(連番付与)。 -
更新日時の保持:ファイル/ディレクトリエントリともに 元の更新日時をエントリに付与。
-
解凍:ファイルの更新日時を復元。ディレクトリは ZIP に dir エントリが無い場合でも、配下ファイルの最大時刻で復元。
-
共有Zip最適化:
--unzip-share-zip
+--parallel=1
で 同一 ZipArchive を共有し高速化。
ハッシュ判定の仕様
- まず サイズと更新日時で判定。
- 必要時のみ ハッシュへ(
none / partial / full
)。 -
partial
は 先頭・中間・末尾の N バイトずつを読み、合算ハッシュ。 -
小さいファイル(
3N
以下)は自動で フルハッシュに切替。 - 履歴ログにハッシュがあれば、次回はそれも活用してスキップ。
履歴・ログの仕様
-
UTF-8 CSV。
--log=<path>
で出力・読み込みを兼用。 -
--log-max-size
を超えた場合、同一ファイル単位で最新のみ残すローテーション。 - 履歴キーは 入力相対パス + 出力相対パス。
- まず 更新日時とサイズで照合し、必要なら ハッシュで最終確認してスキップ。
終了表示・終了コード
-
終了表示:
[DONE] 全成功
または[DONE] エラーあり
(完了日時・処理時間付き) -
終了コード:
-
0
… 成功 -
1
… 一部エラー -
2
… パラメータ不備など開始前エラー
-
よくある質問(FAQ)
-
Q. 速度表示が 0 になる?
A. 極小ファイル連続時や I/O バーストで瞬間的に 0 に見えることがあります。処理自体に問題はありません。 -
Q. 単位指定は?
A.--partial-bytes
や--log-max-size
はb|kb|mb|gb
をサフィックスで指定可(例:256kb
、1gb
)。未指定はバイト。 -
Q. 更新日時が変わってしまう?
A. コピー/解凍/ZIP 生成の各所で 元の更新日時を復元する実装にしています。 -
Q. Create モードの ZIP で例外が出た
A. 修正済み。Create では.Entries
/GetEntry
に触れず作成します。
注意事項
- Windows 向け。.NET Framework 4.8 を対象。
- パスは クォート推奨(スペース・記号対策)。
- 大量ファイル時はログも大きくなりがちです。
--log-max-size
の設定を推奨。 - 解凍の高速化は
--parallel=1 --unzip-share-zip
の併用が効果的。 -
--progress-force-inline
は TTY 判定を無視して「同一行上書き」を強制します(ログに流す用途では非推奨)。
以上
備考
・基本的には、xcopyやrobocopyなどで十分・・・?