Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/libexpr-tests/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ sources = files(
'search-path.cc',
'tecnix-dependency-tracking.cc',
'tecnix-eval-cache-row.cc',
'tecnix-git-status.cc',
'tecnix-manifest-fingerprint.cc',
'tecnix-worldtree.cc',
'tectonix.cc',
Expand Down
151 changes: 151 additions & 0 deletions src/libexpr-tests/tecnix-git-status.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
#include <string>
#include <string_view>
#include <vector>

#include <gtest/gtest.h>

#include "nix/expr/tecnix/source-deps.hh"

namespace nix {

namespace {

using namespace std::string_literals;

constexpr auto headOid = "d00491fd7e5bb6fa28c517a0bb32b8b506539d4d";
constexpr auto indexOid = "0cfbf08886fca9a91cb753ec8734c84fcbe52c9f";
constexpr auto zeroOid = "0000000000000000000000000000000000000000";

std::string
ordinary(std::string_view xy, std::string_view modes, std::string_view hH, std::string_view hI, std::string_view path)
{
return "1 "s + std::string(xy) + " N... " + std::string(modes) + " " + std::string(hH) + " " + std::string(hI) + " "
+ std::string(path) + '\0';
}

GitStatusEntry staged(std::string path, uint32_t mode = 0100644)
{
return {.path = std::move(path), .index = GitIndexBlob{.oid = indexOid, .mode = mode}};
}

GitStatusEntry onDisk(std::string path)
{
return {.path = std::move(path)};
}

} // namespace

TEST(TecnixGitStatus, StagedOnlyChangeIsServedFromTheIndex)
{
// What `git status` reports for a cleanly merged, skip-worktree file of an
// uncommitted merge in a sparse checkout: staged, absent from disk.
auto output = ordinary("M.", "100644 100644 100644", headOid, indexOid, "system/tectonix/default.nix");
EXPECT_EQ(parseGitPorcelainV2ZStatus(output), std::vector{staged("system/tectonix/default.nix")});
}

TEST(TecnixGitStatus, WorktreeChangesStayOnDisk)
{
auto output = ordinary(".M", "100644 100644 100644", headOid, headOid, "a.nix")
+ ordinary("MM", "100644 100644 100644", headOid, indexOid, "b.nix")
+ ordinary("AD", "000000 100644 000000", zeroOid, indexOid, "c.nix");
EXPECT_EQ(parseGitPorcelainV2ZStatus(output), (std::vector{onDisk("a.nix"), onDisk("b.nix"), onDisk("c.nix")}));
}

TEST(TecnixGitStatus, StagedAdditionIsServedFromTheIndex)
{
auto output = ordinary("A.", "000000 100644 100644", zeroOid, indexOid, "new.nix");
EXPECT_EQ(parseGitPorcelainV2ZStatus(output), std::vector{staged("new.nix")});
}

TEST(TecnixGitStatus, StagedDeletionHasNoIndexBlob)
{
auto output = ordinary("D.", "100644 000000 000000", headOid, zeroOid, "gone.nix");
EXPECT_EQ(parseGitPorcelainV2ZStatus(output), std::vector{onDisk("gone.nix")});
}

TEST(TecnixGitStatus, IndexModeIsKept)
{
auto output = ordinary("M.", "100644 100755 100755", headOid, indexOid, "bin/run")
+ ordinary("A.", "000000 120000 120000", zeroOid, indexOid, "link");
EXPECT_EQ(parseGitPorcelainV2ZStatus(output), (std::vector{staged("bin/run", 0100755), staged("link", 0120000)}));
}

TEST(TecnixGitStatus, SubmoduleStaysOnDisk)
{
auto output = "1 M. SC.. 160000 160000 160000 "s + headOid + " " + indexOid + " vendor/sub" + '\0';
EXPECT_EQ(parseGitPorcelainV2ZStatus(output), std::vector{onDisk("vendor/sub")});
}

TEST(TecnixGitStatus, RenameKeepsBothNames)
{
auto output = "2 R. N... 100644 100644 100644 "s + headOid + " " + indexOid + " R100 new name.nix" + '\0'
+ "old name.nix" + '\0';
EXPECT_EQ(parseGitPorcelainV2ZStatus(output), (std::vector{staged("new name.nix"), onDisk("old name.nix")}));
}

TEST(TecnixGitStatus, CopySourceIsNotDirty)
{
auto output =
"2 C. N... 100644 100644 100644 "s + headOid + " " + indexOid + " C100 copy.nix" + '\0' + "source.nix" + '\0';
EXPECT_EQ(parseGitPorcelainV2ZStatus(output), std::vector{staged("copy.nix")});
}

TEST(TecnixGitStatus, WorktreeCopySourceIsNotDirty)
{
auto output =
"2 .C N... 100644 100644 100644 "s + headOid + " " + headOid + " C100 copy.nix" + '\0' + "source.nix" + '\0';
EXPECT_EQ(parseGitPorcelainV2ZStatus(output), std::vector{onDisk("copy.nix")});
}

TEST(TecnixGitStatus, UnmergedAndUntrackedStayOnDisk)
{
auto output = "u UU N... 100644 100644 100644 100644 "s + headOid + " " + indexOid + " " + headOid
+ " conflicted.nix" + '\0' + "? untracked dir/file.nix" + '\0';
EXPECT_EQ(
parseGitPorcelainV2ZStatus(output), (std::vector{onDisk("conflicted.nix"), onDisk("untracked dir/file.nix")}));
}

TEST(TecnixGitStatus, PathsWithSpacesAreKeptWhole)
{
auto output = ordinary("M.", "100644 100644 100644", headOid, indexOid, "dir with spaces/a b.nix");
EXPECT_EQ(parseGitPorcelainV2ZStatus(output), std::vector{staged("dir with spaces/a b.nix")});
}

TEST(TecnixGitStatus, HeadersAreIgnored)
{
auto output = "# branch.oid "s + headOid + '\0' + "# stash 2" + '\0'
+ ordinary("M.", "100644 100644 100644", headOid, indexOid, "a.nix");
EXPECT_EQ(parseGitPorcelainV2ZStatus(output), std::vector{staged("a.nix")});
}

TEST(TecnixGitStatus, TruncatedRecordIsRejected)
{
auto output = ordinary("M.", "100644 100644 100644", headOid, indexOid, "a.nix") + "1 M. N... 100644";
EXPECT_THROW(parseGitPorcelainV2ZStatus(output), Error);
}

TEST(TecnixGitStatus, MalformedRecordIsRejected)
{
EXPECT_THROW(parseGitPorcelainV2ZStatus("1 M. N... 100644 100644"s + '\0'), Error);
EXPECT_THROW(parseGitPorcelainV2ZStatus("u UU N... 100644"s + '\0'), Error);
}

TEST(TecnixGitStatus, RenameMissingItsOriginalPathIsRejected)
{
auto output = "2 R. N... 100644 100644 100644 "s + headOid + " " + indexOid + " R100 new.nix" + '\0';
EXPECT_THROW(parseGitPorcelainV2ZStatus(output), Error);
}

TEST(TecnixGitStatus, PorcelainV1IsRejected)
{
for (auto record : {"M a.nix", " M a.nix", "MM a.nix", "A a.nix", "D a.nix", "UU a.nix", "?? a.nix"})
EXPECT_THROW(parseGitPorcelainV2ZStatus(std::string(record) + '\0'), Error) << record;
EXPECT_THROW(parseGitPorcelainV2ZStatus("R new.nix"s + '\0' + "old.nix" + '\0'), Error);
}

TEST(TecnixGitStatus, EmptyStatusHasNoEntries)
{
EXPECT_EQ(parseGitPorcelainV2ZStatus(""), std::vector<GitStatusEntry>{});
}

} // namespace nix
39 changes: 38 additions & 1 deletion src/libexpr/include/nix/expr/tecnix/source-deps.hh
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

#include <cstddef>
#include <cstdint>
#include <optional>
#include <span>
#include <string>
#include <string_view>
Expand Down Expand Up @@ -46,7 +47,43 @@ void mergeUnpublishedTrackedSourceDepsFrame(TrackedSourceDepsFrame & frame);
[[gnu::always_inline]] inline void
forceValueTracked(EvalState & state, Value & v, PosIdx pos, TrackingContext & trackingCtx);

std::vector<std::string> parseGitPorcelainZDirtyPaths(std::string_view output);
/** The staged blob of a dirty path, per `git status --porcelain=v2`. */
struct GitIndexBlob
{
/** Hex object id of the blob in the index. */
std::string oid;
/** Git file mode of the index entry: 0100644, 0100755 or 0120000. */
uint32_t mode;

bool operator==(const GitIndexBlob &) const = default;
};

/** One path `git status` reports as differing from HEAD. */
struct GitStatusEntry
{
std::string path;

/**
* Set when the working tree has no change of its own against the index,
* so the index entry is the authoritative content of `path`. The file
* need not be on disk: git stages, but never writes, the cleanly merged
* files of an uncommitted merge that lie outside a sparse checkout
* (skip-worktree entries).
*/
std::optional<GitIndexBlob> index = std::nullopt;

bool operator==(const GitStatusEntry &) const = default;
};

/**
* Parse `git status --porcelain=v2 -z` output. Every changed, renamed (both
* names), copied (the copy only), unmerged or untracked path yields an
* entry. `index` is set only for ordinary, renamed and copied entries whose
* worktree column is `.` and whose index entry is a regular file or symlink.
* Throws on any record it does not recognise, including porcelain v1 output,
* rather than reporting fewer dirty paths.
*/
std::vector<GitStatusEntry> parseGitPorcelainV2ZStatus(std::string_view output);

/**
* A stack-resident accumulator for one bracketed region of evaluation: the
Expand Down
Loading
Loading