LCOV - code coverage report
Current view: top level - src/esys/repo/sharing - materializer_sharing.cpp (source / functions) Hit Total Coverage
Test: esysrepo_coverage.info Lines: 156 176 88.6 %
Date: 2026-11-07 22:18:26 Functions: 18 23 78.3 %

          Line data    Source code
       1             : /*!
       2             :  * \file esys/repo/sharing/materializer_sharing.cpp
       3             :  * \brief Detached linked-worktree materialization (ADR-0041)
       4             :  *
       5             :  * \cond
       6             :  * __legal_b__
       7             :  *
       8             :  * Copyright (c) 2026 Michel Gillet
       9             :  * Distributed under the MIT License.
      10             :  * (See accompanying file LICENSE.txt or
      11             :  * copy at https://opensource.org/licenses/MIT)
      12             :  *
      13             :  * __legal_e__
      14             :  * \endcond
      15             :  *
      16             :  */
      17             : 
      18             : #include "esys/repo/esysrepo_prec.h"
      19             : #include "esys/repo/sharing/materializer.h"
      20             : #include "esys/repo/gitbase.h"
      21             : #include "esys/repo/git/commithash.h"
      22             : #include "esys/repo/git/worktree.h"
      23             : 
      24             : #include <boost/filesystem.hpp>
      25             : 
      26             : #include <algorithm>
      27             : #include <cctype>
      28             : #include <fstream>
      29             : 
      30             : #ifdef _WIN32
      31             : #ifndef WIN32_LEAN_AND_MEAN
      32             : #define WIN32_LEAN_AND_MEAN
      33             : #endif
      34             : #include <windows.h>
      35             : #endif
      36             : 
      37             : namespace esys::repo::sharing
      38             : {
      39             : namespace
      40             : {
      41             : 
      42          54 : boost::filesystem::path abs_norm(const std::string &path)
      43             : {
      44          54 :     boost::filesystem::path p(path);
      45          54 :     if (!p.is_absolute()) p = boost::filesystem::absolute(p);
      46          54 :     return p.lexically_normal();
      47          54 : }
      48             : 
      49          37 : bool path_under(const boost::filesystem::path &child, const boost::filesystem::path &root)
      50             : {
      51          37 :     auto c = child.lexically_normal().generic_string();
      52          37 :     auto r = root.lexically_normal().generic_string();
      53          37 :     if (!r.empty() && r.back() == '/') r.pop_back();
      54          37 :     if (c == r) return true;
      55          37 :     if (r.empty()) return false;
      56          37 :     if (c.size() <= r.size()) return false;
      57          18 :     if (c.compare(0, r.size(), r) != 0) return false;
      58           8 :     return c[r.size()] == '/';
      59          37 : }
      60             : 
      61           4 : bool starts_with_ci(const std::string &s, const std::string &prefix)
      62             : {
      63           4 :     if (s.size() < prefix.size()) return false;
      64          32 :     for (std::size_t i = 0; i < prefix.size(); ++i)
      65             :     {
      66          28 :         if (std::tolower(static_cast<unsigned char>(s[i])) != std::tolower(static_cast<unsigned char>(prefix[i])))
      67             :             return false;
      68             :     }
      69             :     return true;
      70             : }
      71             : 
      72           5 : bool is_reparse_point(const boost::filesystem::path &path)
      73             : {
      74             : #ifdef _WIN32
      75             :     const DWORD attrs = GetFileAttributesW(path.wstring().c_str());
      76             :     if (attrs == INVALID_FILE_ATTRIBUTES) return false;
      77             :     return (attrs & FILE_ATTRIBUTE_REPARSE_POINT) != 0;
      78             : #else
      79           5 :     boost::system::error_code ec;
      80           5 :     return boost::filesystem::is_symlink(boost::filesystem::symlink_status(path, ec));
      81             : #endif
      82             : }
      83             : 
      84           4 : std::string read_gitfile(const boost::filesystem::path &git_file)
      85             : {
      86           4 :     std::ifstream ifs(git_file.string());
      87           4 :     if (!ifs) return "";
      88           4 :     std::string line;
      89           4 :     std::getline(ifs, line);
      90           8 :     return line;
      91           4 : }
      92             : 
      93           6 : bool commit_prefix_equal(const std::string &a, const std::string &b)
      94             : {
      95           6 :     if (a.empty() || b.empty()) return false;
      96           6 :     const std::size_t n = (std::min)(a.size(), b.size());
      97             :     // Accept abbreviated vs full if one is prefix of the other (min 7)
      98           6 :     if (n < 7) return a == b;
      99           6 :     const std::string &shorter = a.size() <= b.size() ? a : b;
     100           6 :     const std::string &longer = a.size() <= b.size() ? b : a;
     101           6 :     return longer.compare(0, shorter.size(), shorter) == 0;
     102             : }
     103             : 
     104             : } // namespace
     105             : 
     106           7 : DestKind classify_destination(const std::string &abs_path)
     107             : {
     108           7 :     boost::filesystem::path path = abs_norm(abs_path);
     109           7 :     boost::system::error_code ec;
     110           7 :     if (!boost::filesystem::exists(path, ec)) return DestKind::Missing;
     111             : 
     112           5 :     if (is_reparse_point(path)) return DestKind::ReparsePoint;
     113             : 
     114           5 :     if (boost::filesystem::is_directory(path, ec))
     115             :     {
     116          10 :         boost::filesystem::path git_path = path / ".git";
     117           5 :         if (boost::filesystem::is_regular_file(git_path, ec))
     118             :         {
     119           4 :             const std::string text = read_gitfile(git_path);
     120           4 :             std::string norm = text;
     121         617 :             for (char &c : norm)
     122         613 :                 if (c == '\\') c = '/';
     123           4 :             if (norm.find("/modules/") != std::string::npos ||
     124           8 :                 (starts_with_ci(norm, "gitdir:") && norm.find("modules") != std::string::npos))
     125             :                 return DestKind::InitializedSubmodule;
     126           4 :             if (norm.find("/worktrees/") != std::string::npos ||
     127           4 :                 (starts_with_ci(norm, "gitdir:") && norm.find("worktrees") != std::string::npos))
     128             :                 return DestKind::LinkedWorktree;
     129           0 :             return DestKind::StandaloneRepo;
     130           4 :         }
     131           1 :         if (boost::filesystem::is_directory(git_path, ec)) return DestKind::StandaloneRepo;
     132             : 
     133           2 :         bool empty = boost::filesystem::directory_iterator(path, ec) == boost::filesystem::directory_iterator();
     134           6 :         if (ec) return DestKind::NonEmptyUnknown;
     135           2 :         return empty ? DestKind::EmptyDir : DestKind::NonEmptyUnknown;
     136           5 :     }
     137             : 
     138             :     return DestKind::NonEmptyUnknown;
     139           7 : }
     140             : 
     141           2 : Materializer::Materializer() = default;
     142             : 
     143           4 : Materializer::~Materializer() = default;
     144             : 
     145           2 : void Materializer::set_git(std::shared_ptr<GitBase> git)
     146             : {
     147           2 :     m_git = std::move(git);
     148           2 : }
     149             : 
     150           0 : std::shared_ptr<GitBase> Materializer::get_git() const
     151             : {
     152           0 :     return m_git;
     153             : }
     154             : 
     155           3 : const std::string &Materializer::get_last_mismatch_warning() const
     156             : {
     157           3 :     return m_last_mismatch_warning;
     158             : }
     159             : 
     160           2 : void Materializer::set_consumer_root(const std::string &path)
     161             : {
     162           4 :     m_consumer_root = abs_norm(path).string();
     163           2 : }
     164             : 
     165           0 : const std::string &Materializer::get_consumer_root() const
     166             : {
     167           0 :     return m_consumer_root;
     168             : }
     169             : 
     170           2 : void Materializer::set_provider_path(const std::string &path)
     171             : {
     172           4 :     m_provider_path = abs_norm(path).string();
     173           2 : }
     174             : 
     175           0 : const std::string &Materializer::get_provider_path() const
     176             : {
     177           0 :     return m_provider_path;
     178             : }
     179             : 
     180           2 : void Materializer::set_forbidden_roots(const std::vector<std::string> &paths)
     181             : {
     182           2 :     m_forbidden_roots.clear();
     183           5 :     for (const auto &p : paths) m_forbidden_roots.push_back(abs_norm(p).string());
     184           2 : }
     185             : 
     186           0 : const std::vector<std::string> &Materializer::get_forbidden_roots() const
     187             : {
     188           0 :     return m_forbidden_roots;
     189             : }
     190             : 
     191           8 : Result Materializer::check_path_safety(const std::string &dest_abs) const
     192             : {
     193           8 :     if (dest_abs.empty()) return ESYSREPO_RESULT(ResultCode::EMPTY_PATH);
     194           8 :     auto dest = abs_norm(dest_abs);
     195             : 
     196           8 :     if (!m_consumer_root.empty())
     197             :     {
     198           8 :         if (!path_under(dest, abs_norm(m_consumer_root)))
     199           1 :             return ESYSREPO_RESULT(ResultCode::SHARING_UNSAFE_PATH, "destination escapes consumer root: " + dest.string());
     200             :     }
     201             : 
     202           7 :     if (!m_provider_path.empty())
     203             :     {
     204           7 :         auto prov = abs_norm(m_provider_path);
     205          14 :         if (path_under(dest, prov) || path_under(prov, dest) || dest == prov)
     206           0 :             return ESYSREPO_RESULT(ResultCode::SHARING_UNSAFE_PATH, "destination overlaps provider path");
     207           7 :     }
     208             : 
     209          14 :     for (const auto &forbidden : m_forbidden_roots)
     210             :     {
     211           8 :         auto f = abs_norm(forbidden);
     212          15 :         if (path_under(dest, f) || path_under(f, dest) || dest == f)
     213           1 :             return ESYSREPO_RESULT(ResultCode::SHARING_UNSAFE_PATH, "destination overlaps forbidden path: " + f.string());
     214           8 :     }
     215             : 
     216           6 :     return ESYSREPO_RESULT(ResultCode::OK);
     217           8 : }
     218             : 
     219           3 : Result Materializer::ensure_parent_dir(const std::string &dest_abs) const
     220             : {
     221           3 :     boost::filesystem::path parent = abs_norm(dest_abs).parent_path();
     222           3 :     if (parent.empty()) return ESYSREPO_RESULT(ResultCode::OK);
     223           3 :     boost::system::error_code ec;
     224           3 :     boost::filesystem::create_directories(parent, ec);
     225           3 :     if (ec) return ESYSREPO_RESULT(ResultCode::FOLDER_CREATION_ERROR, parent.string());
     226           3 :     return ESYSREPO_RESULT(ResultCode::OK);
     227           3 : }
     228             : 
     229           4 : Result Materializer::warn_revision_mismatch(const std::string &required_commit)
     230             : {
     231           4 :     m_last_mismatch_warning.clear();
     232           4 :     if (!m_git) return ESYSREPO_RESULT(ResultCode::GIT_IS_NULLPTR);
     233           4 :     git::CommitHash head;
     234           4 :     Result r = m_git->get_last_commit(head);
     235           4 :     if (r.error()) return ESYSREPO_RESULT(ResultCode::OK); // do not fail materialize on head query
     236           4 :     if (commit_prefix_equal(head.get_hash(), required_commit)) return ESYSREPO_RESULT(ResultCode::OK);
     237             : 
     238           4 :     m_last_mismatch_warning = "shared worktree required commit " + required_commit +
     239           4 :                               " differs from provider HEAD " + head.get_hash();
     240           2 :     warn(m_last_mismatch_warning);
     241           2 :     return ESYSREPO_RESULT(ResultCode::OK);
     242           4 : }
     243             : 
     244           8 : Result Materializer::materialize(const std::string &dest_abs, const std::string &required_commit)
     245             : {
     246           8 :     m_last_mismatch_warning.clear();
     247           8 :     if (!m_git) return ESYSREPO_RESULT(ResultCode::GIT_IS_NULLPTR);
     248           8 :     if (!m_git->is_open()) return ESYSREPO_RESULT(ResultCode::GIT_REPO_NOT_OPEN);
     249           8 :     if (required_commit.empty())
     250           0 :         return ESYSREPO_RESULT(ResultCode::CMD_MISSING_PARAMETERS, "required_commit is empty");
     251             : 
     252           8 :     Result safety = check_path_safety(dest_abs);
     253           8 :     if (safety.error()) return safety;
     254             : 
     255           6 :     auto has = m_git->has_object(required_commit);
     256           6 :     if (has.error()) return ESYSREPO_RESULT(has);
     257           6 :     if (!has.get())
     258           1 :         return ESYSREPO_RESULT(ResultCode::SHARING_COMMIT_MISSING,
     259             :                                "required commit not in provider object store: " + required_commit);
     260             : 
     261           5 :     const DestKind kind = classify_destination(dest_abs);
     262           5 :     switch (kind)
     263             :     {
     264           0 :         case DestKind::InitializedSubmodule:
     265           0 :             return ESYSREPO_RESULT(ResultCode::SHARING_SUBMODULE_INITIALIZED,
     266             :                                    "destination looks like an initialized submodule; deinit/remove before sharing");
     267           0 :         case DestKind::ReparsePoint:
     268           0 :             return ESYSREPO_RESULT(ResultCode::SHARING_UNSAFE_PATH, "destination is a reparse point / symlink");
     269           1 :         case DestKind::StandaloneRepo:
     270           1 :         case DestKind::NonEmptyUnknown:
     271           1 :             return ESYSREPO_RESULT(ResultCode::SHARING_UNSAFE_PATH,
     272             :                                    "destination exists and is not a managed linked worktree");
     273           2 :         case DestKind::LinkedWorktree:
     274           2 :         {
     275             :             // Idempotent if HEAD matches; else remove and recreate
     276           2 :             std::vector<git::Worktree> trees;
     277           2 :             Result lr = m_git->worktree_list(trees);
     278           2 :             if (lr.error()) return lr;
     279           6 :             const auto dest_n = abs_norm(dest_abs).lexically_normal().generic_string();
     280           2 :             std::string listed_head;
     281           4 :             for (const auto &wt : trees)
     282             :             {
     283          12 :                 auto p = abs_norm(wt.get_path()).lexically_normal().generic_string();
     284           4 :                 if (p == dest_n)
     285             :                 {
     286           2 :                     listed_head = wt.get_head();
     287           2 :                     break;
     288             :                 }
     289           4 :             }
     290           2 :             if (!listed_head.empty() && commit_prefix_equal(listed_head, required_commit))
     291             :             {
     292           1 :                 warn_revision_mismatch(required_commit);
     293           1 :                 return ESYSREPO_RESULT(ResultCode::OK);
     294             :             }
     295           1 :             Result rr = m_git->worktree_remove(dest_abs, true);
     296           1 :             if (rr.error()) return rr;
     297           1 :             break;
     298           3 :         }
     299           0 :         case DestKind::EmptyDir:
     300           0 :         {
     301           0 :             boost::system::error_code ec;
     302           0 :             boost::filesystem::remove(abs_norm(dest_abs), ec);
     303           0 :             break;
     304             :         }
     305             :         case DestKind::Missing: break;
     306             :     }
     307             : 
     308           6 :     Result parent = ensure_parent_dir(dest_abs);
     309           3 :     if (parent.error()) return parent;
     310             : 
     311           6 :     Result add = m_git->worktree_add_detach(dest_abs, required_commit);
     312           3 :     if (add.error()) return add;
     313             : 
     314           3 :     warn_revision_mismatch(required_commit);
     315           3 :     return ESYSREPO_RESULT(ResultCode::OK);
     316           8 : }
     317             : 
     318             : } // namespace esys::repo::sharing

Generated by: LCOV version 1.14