Как определить, что директория переименована?

Есть необходимость в создании некого механизма обновления проекта. Для этих целей я хочу сделать следующую логику:

  • Директория A и B (новая версия), обе на одном компьютере, сверяем их различия, создав некую "карту" различий.
  • В карте прописывается путь, размер, хэш и некое действие.
  • Действием является то, что надо сделать с этим файлом/директорией (переименовать, удалить, заменить, распаковать, дописать данные в файл и др.)
  • Клиенту в итоге посылается сгенерированная "карта" и архив с измененными файлами.

То есть, если директория A содержит в себе

- FirstFolder
  - lib1.dll
  - lib2.dll
  - lib3.dll
- SecondFolder
  - SomeLib.dll
  - SomeData.db

а директория B

- FirstFolder
  - lib1.dll
  - lib2.dll
- SecondNewFolder
  - SomeLib.dll

то мы видим, что в B (новая версия) SecondFolder переименовалась в SecondNewFolder и из нее удалился файл SomeData.db, ну и также нет файла lib3.dll из FirstFolder. Грубо говоря, снимок ("карта") будет таким:

- delete "/FirstFolder/lib3.dll"
- rename "/SecondFolder" "/SecondNewFolder"
- delete "/SecondNewFolder/SomeData.db"

Собственно, теперь вопрос: Как определить, что директория SecondFolder была переименована, а не удалена?
Ведь если мы возьмем список всех файлов и директорий, и сверим по наличию, размеру, хэшу, то проверка не найдет директории, она переименованную будет считать за "новую", что приведет к ложному выводу, примерно такому:

- delete "/FirstFolder/lib3.dll"
- delete "/SecondFolder"
- create "/SecondNewFolder"
- unzip  "/SecondNewFolder.zip" "/SecondNewFolder"

А это в свою очередь приведет к повышению размера патча, лишним действиям, долгой установки и так далее, ведь каждая директория может весить по несколько гигабайт, ну и паковать те файлы, что уже есть на компьютере клиента без изменений, ну такое себе занятие.

p.s. Про FileSystemWatcher знаю, но он не подходит по одной простой причине - новая версия собирается несколькими людьми и долгое время, запускать некий процесс, который будет следить за всеми изменениями не очень целесобразно.


Дополнения:

  • Эта утилита создается для игры Skyrim, которую развивают сообществом. Есть автор, который создал свою сборку, со своими модификациями, переводами и прочим. Люди в праве ставить сторонние моды, удалять лишнее и др. Цель - передать людям изменения автора, обновив "ядро", то есть то, что его, не затрагивая изменения пользователей (удалять папки и файлы, которых нет в оригинале нельзя).

  • Разделить файлы на "пользовательские" и "оригинальные" увы, невозможно.

  • Модификации могут быть разные, в их директориях может быть всего 2 файла, а может быть под несколько сотен мелких (текстуры, звуки и др.).

  • В двух разных модах (директориях), могут быть повторяющиеся файлы.

  • Почему бы не считать переименование "новым"? При установки модификации, ее название (и название директории) будет взято с архива, либо с сайта модификаций и иногда оно содержит много лишнего (прим: 8k Texture 2021_RU_89а770407р). Бывает так, что не обращаешь на это внимание и отдаешь с такими названиями людям, а в один день хочешь привести все в порядок, переименовывая 8k Texture 2021_RU_89а770407р в 8k Texture. Да вот только 8к текстуры могут весить 5++ гб, и если программа будет считать это за "новое", то человеку придется качать патч размером в 5++гб, когда надо всего лишь переименовать директорию.


Ответы (1 шт):

Автор решения: aepot

Смотрите, если полный список файлов источника и получателя развернуть в сплошной список, то они все будут уникальные. У каждого уникальный хэш.

Файлы в сплошном списке можно сматчить по хешам+именам между источником и получателем. Далее, вы знаете пути к каждому файлу, можете создать карту, как они переместились, переместить их из одной папки в другую, создать недостающие папки, распаковать то что не хватает, удалить лишнее.

Из выше рассказанной операции можно и выследить переименование каталога, но вам необязательно это делать. Вы можете просто удалить пустые папки после того как разберетесь с файлами.


Написал небольшой сниппет для примера. Так как с диском работать вы умеете, я буду работать без диска. :)

Вот такая модель данных

public record FileNode
{
    public int Hash { get; init; }
    public string Path { get; init; }
}

Задача привести в порядок диск за минимальное число операций.

static void Main(string[] args)
{
    // то что у нас на диске
    HashSet<FileNode> target = new HashSet<FileNode>
    {
        new FileNode { Hash = 1, Path = "a" },
        new FileNode { Hash = 1, Path = "d/o" },
        new FileNode { Hash = 2, Path = "b" },
        new FileNode { Hash = 3, Path = "a/c" },
        new FileNode { Hash = 4, Path = "a/d" },
        new FileNode { Hash = 5, Path = "e" },
        new FileNode { Hash = 6, Path = "b/f" },
        new FileNode { Hash = 1, Path = "b/a" },
        new FileNode { Hash = 2, Path = "b/b" },
        new FileNode { Hash = 4, Path = "d" },
        new FileNode { Hash = 1, Path = "a" }
    };

    // источник данных, то что должно быть
    HashSet<FileNode> source = new HashSet<FileNode>
    {
        new FileNode { Hash = 1, Path = "a" },
        new FileNode { Hash = 10, Path = "d/o" },
        new FileNode { Hash = 3, Path = "x/c" },
        new FileNode { Hash = 4, Path = "x/d" },
        new FileNode { Hash = 5, Path = "e" },
        new FileNode { Hash = 6, Path = "b/f" },
        new FileNode { Hash = 1, Path = "b/a" },
        new FileNode { Hash = 2, Path = "b/b" },
        new FileNode { Hash = 4, Path = "b/d" },
        new FileNode { Hash = 8, Path = "g" }
    };


    Console.WriteLine("== target ==");
    Print(target);
    Console.WriteLine("== source ==");
    Print(source);

    // сам процесс здесь
    Merge(source, target);

    // и вот что получилось
    Console.WriteLine("== target ==");
    Print(target);

    // проверяем
    Console.WriteLine("== Checking integrity ==");
    Compare(source, target);

    Console.WriteLine("Done.");
    Console.ReadKey();
}

private static void Print(HashSet<FileNode> list)
{
    foreach (FileNode file in list)
        Console.WriteLine(file);
    Console.WriteLine();
}

private static void Compare(HashSet<FileNode> source, HashSet<FileNode> target)
{
    Console.WriteLine($"Diff = {source.Except(target).Count() + target.Except(source).Count()}");
    Console.WriteLine();
}

private static void Merge(HashSet<FileNode> source, HashSet<FileNode> target)
{
    Console.WriteLine("=== Merging ===");
    foreach (FileNode file in source)
    {
        if (target.Contains(file))
        {
            Console.WriteLine($"{file} is OK");
            continue;
        }
        FileNode oldFile = target.FirstOrDefault(x => file.Path == x.Path);
        if (oldFile != null)
        {
            Fetch(file, target);
            continue;
        }
        var locals = target.Where(x => file.Hash == x.Hash);
        FileNode local = locals.FirstOrDefault(x => !source.Contains(x)) ?? locals.FirstOrDefault();
        if (local != null)
        {
            if (!source.Contains(local))
                Move(local, file, target);
            else
                Copy(local, file, target);
        }
        else
            Download(file, target);
    }
    Console.WriteLine("=== Updating ===");
    foreach (FileNode file in target.Where(x => x.Path.StartsWith("updates")).ToArray())
        Update(file, target);
    Console.WriteLine("=== Cleaning up ===");
    foreach (FileNode file in target.Except(source))
        Delete(file, target);
    Console.WriteLine();
}

private static void Download(FileNode file, HashSet<FileNode> target)
{
    Console.WriteLine($"Downloading {file}");
    if (!target.Add(file))
        throw new Exception($"File {file} already exists");
}

private static void Fetch(FileNode file, HashSet<FileNode> target)
{
    FileNode package = file with { Path = "updates/" + file.Path };
    Console.WriteLine($"Fetching {file} to {package}");
    if (!target.Add(package))
        throw new Exception($"File {file} already exists");
}

private static void Update(FileNode file, HashSet<FileNode> target)
{
    FileNode newFile = file with { Path = file.Path.Substring("updates/".Length) };
    FileNode oldFile = target.FirstOrDefault(x => x.Path == newFile.Path);
    Console.WriteLine($"Updating {oldFile} from {file}");
    Move(file, newFile, target, true);
    Delete(oldFile, target, true);
}

private static void Copy(FileNode file, FileNode copy, HashSet<FileNode> target)
{
    Console.WriteLine($"Copying {file} from storage to '{copy.Path}'");

    if (!target.Add(copy))
        throw new Exception($"File {copy} already exists");
}

private static void Move(FileNode file, FileNode copy, HashSet<FileNode> target, bool silent = false)
{
    if (!silent)
        Console.WriteLine($"Moving {file} from storage to '{copy.Path}'");
    if (!target.Add(copy))
        throw new Exception($"File {copy} already exists");
    Delete(file, target, true);
}

private static void Delete(FileNode file, HashSet<FileNode> target, bool silent = false)
{
    if (!silent)
        Console.WriteLine($"Deleting {file}");
    if (!target.Remove(file))
        throw new Exception($"File {file} not found");
}

Вывод в консоль

== target ==
FileNode { Hash = 1, Path = a }
FileNode { Hash = 1, Path = d/o }
FileNode { Hash = 2, Path = b }
FileNode { Hash = 3, Path = a/c }
FileNode { Hash = 4, Path = a/d }
FileNode { Hash = 5, Path = e }
FileNode { Hash = 6, Path = b/f }
FileNode { Hash = 1, Path = b/a }
FileNode { Hash = 2, Path = b/b }
FileNode { Hash = 4, Path = d }

== source ==
FileNode { Hash = 1, Path = a }
FileNode { Hash = 10, Path = d/o }
FileNode { Hash = 3, Path = x/c }
FileNode { Hash = 4, Path = x/d }
FileNode { Hash = 5, Path = e }
FileNode { Hash = 6, Path = b/f }
FileNode { Hash = 1, Path = b/a }
FileNode { Hash = 2, Path = b/b }
FileNode { Hash = 4, Path = b/d }
FileNode { Hash = 8, Path = g }

=== Merging ===
FileNode { Hash = 1, Path = a } is OK
Fetching FileNode { Hash = 10, Path = d/o } to FileNode { Hash = 10, Path = updates/d/o }
Moving FileNode { Hash = 3, Path = a/c } from storage to 'x/c'
Moving FileNode { Hash = 4, Path = a/d } from storage to 'x/d'
FileNode { Hash = 5, Path = e } is OK
FileNode { Hash = 6, Path = b/f } is OK
FileNode { Hash = 1, Path = b/a } is OK
FileNode { Hash = 2, Path = b/b } is OK
Moving FileNode { Hash = 4, Path = d } from storage to 'b/d'
Downloading FileNode { Hash = 8, Path = g }
=== Updating ===
Updating FileNode { Hash = 1, Path = d/o } from FileNode { Hash = 10, Path = updates/d/o }
=== Cleaning up ===
Deleting FileNode { Hash = 2, Path = b }

== target ==
FileNode { Hash = 1, Path = a }
FileNode { Hash = 4, Path = x/d }
FileNode { Hash = 4, Path = b/d }
FileNode { Hash = 5, Path = e }
FileNode { Hash = 6, Path = b/f }
FileNode { Hash = 1, Path = b/a }
FileNode { Hash = 2, Path = b/b }
FileNode { Hash = 8, Path = g }
FileNode { Hash = 3, Path = x/c }
FileNode { Hash = 10, Path = d/o }

== Checking integrity ==
Diff = 0

Done.

Обратите внимание, что есть файлы с одинаковым хэшом, но разными именами.

Я думаю, что реальное обновление файла на диске (распаковку) лучше начинать когда все остальные операции мержа завершены. Потому что обновление перезапишет возможно нужный где-то старый файл. Но закачку архива для обновления можно начинать сразу. Собственно, так и реализовал.

Я понимаю, что это не строгая логика. В условиях многопоточки многое может поменяться. Я просто хотел показать идею.

Еще можно написать работу с каталогами

var directories = target.Select(x => Path.GetDirectoryName(x)).Where(x => x.Length > 0).Distinct();

...но я не стал усложнять

→ Ссылка