diff --git a/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs b/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs index c4514b9b1..813891d9c 100644 --- a/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs +++ b/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs @@ -279,7 +279,7 @@ await WithWorktreeMetadataGate(repoPath, time, () => // --no-auto-maintenance: that flag needs a newer git than the override transport does. await RunGit(repoPath, FetchTimeout, time, - [new("maintenance.auto", "false"), new("gc.auto", "0")], + [new("maintenance.auto", "false"), new("gc.auto", "0"), .. NoSubmoduleRecursion()], "fetch", "origin", $"{baseRef}:{fetchedRef}"); await WithWorktreeMetadataGate(repoPath, time, () => RunGit(repoPath, GitTimeout, time, noHooks, @@ -435,29 +435,35 @@ async Task BuildStandaloneSnapshotAsync(string repoPath, string wo StripWorkspaceMcpConfig(worktreePath); var noHooks = NoBranchHooks(); await RunGit(worktreePath, GitTimeout, time, noHooks, "init"); + var userAttributes = await UserAttributesFileAsync(worktreePath, time); + var verbatim = VerbatimLineEndings(CreateSnapshotAttributesFile(worktreePath, userAttributes)); + // Kept in the snapshot's own config too, so the agent's later diffs compare the bytes that were copied. + foreach (var setting in verbatim) + await RunGit(worktreePath, GitTimeout, time, noHooks, "config", setting.Key, setting.Value); // Inventoried here: `add -A` is where this snapshot's filters resolve. var standaloneOverrides = await FilterOverridesForAsync(worktreePath); - await RunGit(worktreePath, GitTimeout, time, [.. noHooks, .. standaloneOverrides], "add", "-A"); + await RunGit(worktreePath, GitTimeout, time, sourceReadOnly: false, [.. noHooks, .. verbatim, .. standaloneOverrides], + NoSystemAttributes, "add", "-A"); // Exit 0 = empty index (greenfield: nothing to copy). Exit 1 = staged content. // Any other code is a real git failure — without this check, commit --allow-empty would // hide it behind a successful empty tree. var staged = await RunGitCaptureResult( worktreePath, GitTimeout, time, sourceReadOnly: false, - [.. noHooks, .. standaloneOverrides], + [.. noHooks, .. verbatim, .. standaloneOverrides], NoSystemAttributes, "diff", "--cached", "--quiet"); if (staged.ExitCode is not (0 or 1)) throw new InvalidOperationException( $"git diff --cached --quiet failed: {GitFailureDetail(staged.Stderr, staged.Stdout)}"); - // Bookkeeping commit. Identity is explicit: a host with no user.email fails with - // "Author identity unknown". An empty folder is a valid start — commit an empty tree - // so the agent has a real HEAD to build on. + // Bookkeeping commit, with its own identity and unsigned: an unattended daemon cannot drive the + // user's signing program. An empty folder is a valid start — commit an empty tree so the agent + // has a real HEAD to build on. var commitArgs = staged.ExitCode == 0 ? new[] { "commit", "--allow-empty", "-m", "Initial snapshot" } : new[] { "commit", "-m", "Initial snapshot" }; - await RunGit(worktreePath, GitTimeout, - time, - [.. noHooks, new("user.email", "daemon@kcap.local"), new("user.name", "kcap")], - commitArgs); + await RunGit(worktreePath, GitTimeout, time, sourceReadOnly: false, + [.. noHooks, .. verbatim, + new("user.email", "daemon@kcap.local"), new("user.name", "kcap"), new("commit.gpgsign", "false")], + NoSystemAttributes, commitArgs); return new WorktreeInfo(worktreePath, "", repoPath, IsStandalone: true); } @@ -501,6 +507,74 @@ await RunGit(worktreePath, GitTimeout, /// (see ProveConfigTransportIfCarryingAsync), so nothing here has to remember to. static GitConfigOverride[] NoBranchHooks() => [new("core.hooksPath", NoHooksPath)]; + /// A standalone snapshot records the bytes it copied: a user's line-ending conversion would rewrite + /// them, and its strict form refuses the commit outright. A user's own attributes file is replaced by the + /// snapshot's, which keeps its rules but resets their line endings, so only the project's .gitattributes + /// decides a file's conversion. + static GitConfigOverride[] VerbatimLineEndings(string attributesFile) => + [new("core.autocrlf", "false"), new("core.safecrlf", "false"), new("core.attributesFile", attributesFile)]; + + /// The machine's attributes file is not the project's either, and no config replaces it, so the snapshot's + /// add, diff and commit skip it. + static readonly KeyValuePair[] NoSystemAttributes = [new("GIT_ATTR_NOSYSTEM", "1")]; + + /// The snapshot's attributes file, inside its git dir, as a path git reads the same on every platform: a + /// config value read from the environment is not unescaped, so it carries no backslash. Its !text !eol + /// resets both to unspecified for every path; git resolves each attribute on its own, so both are named. + /// -text would mark every path binary and void a project rule that sets only eol. Git ranks the file + /// above the machine's attributes and below the project's .gitattributes, so the agent's later commands, + /// which skip neither, still convert only what the project asks for. info/attributes would not do: it + /// outranks the project's own file. + static string CreateSnapshotAttributesFile(string worktreePath, string? userAttributesFile) { + var info = Path.Combine(worktreePath, ".git", "info"); + Directory.CreateDirectory(info); + var path = Path.Combine(info, "kcap-snapshot-attributes"); + // The user's own rules come first, so the agent's later commands keep them (an LFS filter, a diff driver), and + // the reset after them wins for text and eol, a later line in one file outranking an earlier one. + var user = userAttributesFile is not null && File.Exists(userAttributesFile) ? File.ReadAllText(userAttributesFile) : ""; + File.WriteAllText(path, (user.Length > 0 && !user.EndsWith('\n') ? user + "\n" : user) + "* !text !eol\n"); + + return Path.GetFullPath(path).Replace('\\', '/'); + } + + /// The attributes file the user's git config names, relative to the snapshot as git reads it, else git's + /// default. Git 2.42 and later report that default; for an older git it is rebuilt from the same environment git + /// reads, which is why the home here is git's rather than the daemon's resolved one + static async Task UserAttributesFileAsync(string worktreePath, TimeProvider time) { + var configured = await RunGitCaptureResult(worktreePath, GitTimeout, time, sourceReadOnly: false, [], + "config", "--path", "--get", "core.attributesFile"); + // An explicitly empty value turns the user's attributes off, so the default is not consulted. + if (configured.ExitCode == 0) + return OutputLine(configured.Stdout) is { Length: > 0 } named ? Path.Combine(worktreePath, named) : null; + + var reported = await RunGitCaptureResult(worktreePath, GitTimeout, time, sourceReadOnly: false, [], "var", "GIT_ATTR_GLOBAL"); + if (reported.ExitCode == 0 && OutputLine(reported.Stdout) is { Length: > 0 } path) return path; + + if (Environment.GetEnvironmentVariable("XDG_CONFIG_HOME") is { Length: > 0 } xdg) return Path.Combine(xdg, "git", "attributes"); + + return GitHome() is { } home ? Path.Combine(home, ".config", "git", "attributes") : null; + } + + /// Git's output without the LF it ends with, on every platform; a path's own trailing characters stay + static string OutputLine(string stdout) => stdout.EndsWith('\n') ? stdout[..^1] : stdout; + + /// The home git uses: HOME, else on Windows HOMEDRIVE plus HOMEPATH, else + /// USERPROFILE + static string? GitHome() { + if (Environment.GetEnvironmentVariable("HOME") is { Length: > 0 } home) return home; + if (!OperatingSystem.IsWindows()) return null; + if (Environment.GetEnvironmentVariable("HOMEDRIVE") is { Length: > 0 } drive + && Environment.GetEnvironmentVariable("HOMEPATH") is { Length: > 0 } homePath) return drive + homePath; + + return Environment.GetEnvironmentVariable("USERPROFILE") is { Length: > 0 } profile ? profile : null; + } + + /// A linked worktree's submodule has no gitdir of its own, so a checkout that recursed into it + /// fails outright; and a fetch that recursed would reach each submodule's own remote. Neither may + /// depend on the user's git config. + static GitConfigOverride[] NoSubmoduleRecursion() => + [new("submodule.recurse", "false"), new("fetch.recurseSubmodules", "false")]; + /// /// Neutralizes the tree, and UNDOES the creation if that fails. /// @@ -545,7 +619,8 @@ async Task CheckoutInTargetContextAsync(string worktreePath) { // pathspec matches nothing. This is the step that materialises the tree, and therefore the step // the overrides have to guard. await RunGit(worktreePath, GitTimeout, time, - [.. noHooks, .. overrides, .. ParallelCheckoutFor(OperatingSystem.IsWindows())], "reset", "--hard", "HEAD"); + [.. noHooks, .. NoSubmoduleRecursion(), .. overrides, .. ParallelCheckoutFor(OperatingSystem.IsWindows())], + "reset", "--hard", "HEAD"); } /// Windows only: its per-file write cost makes a serial checkout most of a launch's wait (7.5k files, @@ -1019,12 +1094,10 @@ await SyncFromSourceCoreAsync( throw new InvalidOperationException("borrowed_snapshot_sparse_checkout_unsupported"); await RunGit(parent, GitTimeout, time, "clone", "--no-hardlinks", "--no-checkout", "--", bundle, destination); - // Same guard as `worktree add`: this checkout materialises branch content, so a relative - // core.hooksPath would run the branch's post-checkout here too. Missed when the guard was - // added — it went on the worktree paths only. + // Same guards as the worktree checkout: this one materialises branch content too. await RunGit(destination, GitTimeout, time, - [.. NoBranchHooks(), .. await FilterOverridesForAsync(destination)], + [.. NoBranchHooks(), .. NoSubmoduleRecursion(), .. await FilterOverridesForAsync(destination)], "checkout", "--detach", "HEAD"); var clonedHead = (await RunGitCapture(destination, GitTimeout, time, false, "rev-parse", "HEAD")).Trim(); if (!string.Equals(sourceHead, clonedHead, StringComparison.Ordinal)) throw new SourceChangedException(); @@ -1729,8 +1802,8 @@ static bool IsActive(string candidate, string[] activePaths) { static readonly TimeSpan FetchTimeout = TimeSpan.FromMinutes(2); /// How long to wait for a timed-out git process to actually die after being killed, - /// before giving up and unwinding anyway. See the timeout path in - /// for why the wait matters. + /// before giving up and unwinding anyway. The wait is why the capture path's timeout cannot let a + /// killed git outlive the metadata gate its caller holds. static readonly TimeSpan KillGrace = TimeSpan.FromSeconds(5); static async Task IsGitRepoWithCommits(string path, TimeProvider time) { @@ -1791,10 +1864,15 @@ static Task RunGit( params string[] args) => RunGit(cwd, timeout, time, sourceReadOnly: false, config, args); + static Task RunGit( + string cwd, TimeSpan timeout, TimeProvider time, bool sourceReadOnly, GitConfigOverride[] config, + params string[] args) => + RunGit(cwd, timeout, time, sourceReadOnly, config, [], args); + static async Task RunGit( string cwd, TimeSpan timeout, TimeProvider time, bool sourceReadOnly, GitConfigOverride[] config, - params string[] args) { - var result = await RunGitCaptureResult(cwd, timeout, time, sourceReadOnly, config, args); + KeyValuePair[] environment, params string[] args) { + var result = await RunGitCaptureResult(cwd, timeout, time, sourceReadOnly, config, environment, args); if (result.ExitCode != 0) throw new InvalidOperationException( $"git {string.Join(' ', args)} failed: {GitFailureDetail(result.Stderr, result.Stdout)}"); @@ -1827,12 +1905,17 @@ static async Task GitConfigBoolAsync(string cwd, string key, TimeProvider return string.Equals(result.Stdout.Trim(), "true", StringComparison.OrdinalIgnoreCase); } - internal static async Task<(int ExitCode, string Stdout, string Stderr)> RunGitCaptureResult( + internal static Task<(int ExitCode, string Stdout, string Stderr)> RunGitCaptureResult( string cwd, TimeSpan timeout, TimeProvider time, bool sourceReadOnly, GitConfigOverride[] config, - params string[] args) { + params string[] args) => + RunGitCaptureResult(cwd, timeout, time, sourceReadOnly, config, [], args); + + static async Task<(int ExitCode, string Stdout, string Stderr)> RunGitCaptureResult( + string cwd, TimeSpan timeout, TimeProvider time, bool sourceReadOnly, GitConfigOverride[] config, + KeyValuePair[] environment, params string[] args) { await ProveConfigTransportIfCarryingAsync(cwd, time, sourceReadOnly, config); - return await RunGitCaptureResultUnproven(cwd, timeout, time, sourceReadOnly, config, args); + return await RunGitCaptureResultUnproven(cwd, timeout, time, sourceReadOnly, config, environment, args); } /// @@ -1861,10 +1944,16 @@ static Task ProveConfigTransportIfCarryingAsync( /// Runs git WITHOUT proving the transport first. Only the probe itself may use this — it is /// what the gate above is measuring, so routing it through the gate would deadlock on the /// non-reentrant semaphore. + static Task<(int ExitCode, string Stdout, string Stderr)> RunGitCaptureResultUnproven( + string cwd, TimeSpan timeout, TimeProvider time, bool sourceReadOnly, GitConfigOverride[] config, + params string[] args) => + RunGitCaptureResultUnproven(cwd, timeout, time, sourceReadOnly, config, [], args); + static async Task<(int ExitCode, string Stdout, string Stderr)> RunGitCaptureResultUnproven( string cwd, TimeSpan timeout, TimeProvider time, bool sourceReadOnly, GitConfigOverride[] config, - params string[] args) { + KeyValuePair[] environment, params string[] args) { var psi = NewGitPsi(cwd, args, sourceReadOnly, config); + foreach (var (key, value) in environment) psi.Environment[key] = value; using var proc = Process.Start(psi)!; using var cts = new CancellationTokenSource(timeout, time); diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerTests.cs index eb3796881..113d87e43 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerTests.cs @@ -68,6 +68,49 @@ public async Task CreateAsync_WithBaseRef_WorktreeHeadMatchesFetchedCommit() { } } + /// + /// Fetching a head that moves a submodule makes git fetch the submodule too, from the submodule's own + /// remote. That remote is the user's business, not the review's: unreachable here, it must not fail + /// the launch. + /// + [Test] + public async Task CreateAsync_fetches_a_head_that_moves_a_submodule_whose_remote_is_unreachable() { + using var sub = GitRepo.CreateWithCommit("sub"); + using var up = GitRepo.CreateWithCommit("upstream"); + up.Do("-c", "protocol.file.allow=always", "submodule", "add", sub, "vendored"); + up.Commit("add submodule"); + up.Config("uploadpack.allowAnySHA1InWant", "true"); + + using var cloneDir = new TempDir("clone"); + var clone = up.Clone(cloneDir.PathTo("repo")); + clone.Do("-c", "protocol.file.allow=always", "submodule", "update", "--init"); + + sub.CreateFile("moved.txt", "moved"); + sub.CommitAll("moved"); + var vendored = GitRepo.At(up.PathTo("vendored")); + vendored.Do("fetch", "-q", "origin"); + vendored.Do("checkout", "-q", sub.Head); + up.Add("vendored"); + up.Commit("move the submodule"); + up.Do("update-ref", "refs/pull/7/head", up.Head); + var head = up.Head; + // The clone's submodule remote must not exist: a recursing fetch that could reach the moved commit would make + // the HEAD assertion prove nothing. Repointed rather than deleted, since Windows keeps git's read-only files. + var missing = cloneDir.PathTo("no-such-remote"); + GitRepo.At(clone.PathTo("vendored")).Do("remote", "set-url", "origin", missing); + clone.Do("config", "submodule.vendored.url", missing); + await Assert.That(Directory.Exists(missing)).IsFalse(); + + var manager = new WorktreeManager(new DaemonConfig(), NullLogger.Instance, NoSnapshotBarrier.Instance, TimeProvider.System); + var worktree = await manager.CreateAsync(clone, name: "review-pr-7", baseRef: "refs/pull/7/head"); + + try { + await Assert.That(GitRepo.At(worktree.Path).Head).IsEqualTo(head); + } finally { + await WorktreeManager.RemoveAsync(worktree, TimeProvider.System); + } + } + [Test] public async Task CreateAsync_with_a_local_only_sha_checks_it_out_without_a_remote() { using var repo = GitRepo.Create("local-only"); diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs new file mode 100644 index 000000000..995981ace --- /dev/null +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs @@ -0,0 +1,235 @@ +using Capacitor.Cli.Daemon.Services; +using Capacitor.Tests.Helpers.Guards; +using Microsoft.Extensions.Logging.Abstractions; +using TUnit.Core.Enums; + +namespace Capacitor.Cli.Daemon.Tests.Unit.Services; + +/// +/// What a user's own global git config can switch on must not decide whether a launch's workspace +/// can be built. Bare [NotInParallel]: the global config is the process's GIT_CONFIG_GLOBAL. +/// +[NotInParallel] +public class WorktreeManagerUserGitConfigTests { + [TempDir] public required TempDir Tmp { get; init; } + + /// The suite's hermetic global config plus the user's settings under test. + EnvScope UserGlobalConfig(string settings) { + var path = Tmp.CreateFile("user.gitconfig", File.ReadAllText(GitConfigGlobalSetup.PinnedGlobalConfig) + "\n" + settings); + + return EnvScope.Exclusive("GIT_CONFIG_GLOBAL", path); + } + + static WorktreeManager NewManager(string? root = null) => + new(root is null ? new DaemonConfig() : new DaemonConfig { WorktreeRoot = root }, + NullLogger.Instance, NoSnapshotBarrier.Instance, TimeProvider.System); + + /// A superproject with one checked-out submodule, as a user's repository has it. + static (GitRepo Super, GitRepo Sub) SuperprojectWithSubmodule() { + var sub = GitRepo.CreateWithCommit("sub"); + var super = GitRepo.CreateWithCommit("super"); + + super.Do("-c", "protocol.file.allow=always", "submodule", "add", sub, "vendored"); + super.Commit("add submodule"); + + return (super, sub); + } + + /// A linked worktree's submodule has no gitdir of its own, so a reset that recursed into it + /// would fail the whole checkout. + [Test] + public async Task A_worktree_is_built_in_a_repo_with_a_submodule_when_the_user_recurses_into_submodules() { + var (super, sub) = SuperprojectWithSubmodule(); + using var _s = super; + using var _b = sub; + using var config = UserGlobalConfig("[submodule]\n recurse = true\n"); + + var worktree = await NewManager().CreateAsync(super, name: "agent-recurse"); + + try { + await Assert.That(GitRepo.At(worktree.Path).Head).IsEqualTo(super.Head); + await Assert.That(File.Exists(Path.Combine(worktree.Path, ".gitmodules"))).IsTrue(); + } finally { + await WorktreeManager.RemoveAsync(worktree, TimeProvider.System); + } + } + + /// With every submodule active, the borrowed snapshot's fresh clone has submodules a recursing + /// checkout would try to populate, and none of them has a repository yet. + [Test] + public async Task A_borrowed_snapshot_is_built_when_the_user_makes_every_submodule_active_and_recursive() { + var (super, sub) = SuperprojectWithSubmodule(); + using var _s = super; + using var _b = sub; + using var config = UserGlobalConfig("[submodule]\n recurse = true\n active = .\n"); + + var snapshot = await NewManager(Tmp.PathTo("root")).CreateBorrowedSnapshotAsync(super, "review", CancellationToken.None); + + try { + await Assert.That(File.Exists(Path.Combine(snapshot.Path, ".gitmodules"))).IsTrue(); + } finally { + await WorktreeManager.RemoveAsync(snapshot, TimeProvider.System); + } + } + + /// A user who signs every commit would have the daemon's bookkeeping commit signed too, by a + /// program an unattended daemon cannot drive. + [Test] + public async Task A_standalone_snapshot_is_built_when_the_user_signs_every_commit() { + var source = Tmp.CreateDir("proj"); + source.CreateFile("README.md", "hello"); + using var config = UserGlobalConfig($"[commit]\n gpgsign = true\n[gpg]\n program = {Tmp.PathTo("no-such-gpg").Replace('\\', '/')}\n"); + + var worktree = await NewManager().CreateAsync(source); + + await Assert.That(worktree.IsStandalone).IsTrue(); + await Assert.That(GitRepo.At(worktree.Path).Try("ls-tree", "--name-only", "HEAD").Text).Contains("README.md"); + } + + /// A user's own attributes file is not the project's: its * text=auto would normalise a copied CRLF + /// file in the snapshot, while the project's .gitattributes still applies. + [Test] + public async Task A_standalone_snapshot_keeps_crlf_files_under_the_users_own_attributes_file() { + var source = Tmp.CreateDir("proj"); + source.CreateFile("crlf.txt", "one\r\ntwo\r\n"); + source.CreateFile("project.txt", "one\r\ntwo\r\n"); + source.CreateFile(".gitattributes", "project.txt text eol=lf\n"); + var attributes = Tmp.CreateFile("user.gitattributes", "* text=auto\n"); + using var config = UserGlobalConfig($"[core]\n attributesFile = {attributes.Replace('\\', '/')}\n"); + + var worktree = await NewManager().CreateAsync(source); + + var snapshot = GitRepo.At(worktree.Path); + await Assert.That(snapshot.Do("cat-file", "blob", "HEAD:crlf.txt").StdOut).IsEqualTo("one\r\ntwo\r\n"); + await Assert.That(snapshot.Do("cat-file", "blob", "HEAD:project.txt").StdOut).IsEqualTo("one\ntwo\n"); + // The agent's later git commands do not carry GIT_ATTR_NOSYSTEM. The snapshot's attributes file sits above the + // machine's in git's precedence and below the project's, and its `!text` resets text to unspecified there, so a + // machine-wide `* text` cannot reach these files. + await Assert.That(snapshot.Do("check-attr", "text", "crlf.txt").Text).IsEqualTo("crlf.txt: text: unspecified"); + await Assert.That(snapshot.Do("check-attr", "text", "project.txt").Text).IsEqualTo("project.txt: text: set"); + await Assert.That(snapshot.Do("status", "--porcelain").Text).IsEqualTo(""); + } + + /// The snapshot's attributes file replaces the user's for the agent's later commands, so it keeps the user's + /// own rules, such as an LFS filter, and resets only their line endings + [Test] + public async Task A_standalone_snapshot_keeps_the_users_own_attribute_rules() { + var source = Tmp.CreateDir("proj"); + source.CreateFile("README.md", "hello"); + var attributes = Tmp.CreateFile("user.gitattributes", "*.psd filter=lfs diff=lfs merge=lfs -text\n*.txt eol=crlf"); + using var config = UserGlobalConfig($"[core]\n attributesFile = {attributes.Replace('\\', '/')}\n"); + + var worktree = await NewManager().CreateAsync(source); + + var snapshot = GitRepo.At(worktree.Path); + await Assert.That(snapshot.Do("check-attr", "filter", "diff", "--", "art.psd").Text) + .IsEqualTo("art.psd: filter: lfs\nart.psd: diff: lfs"); + await Assert.That(snapshot.Do("check-attr", "text", "eol", "--", "notes.txt").Text) + .IsEqualTo("notes.txt: text: unspecified\nnotes.txt: eol: unspecified"); + } + + /// An empty core.attributesFile turns the user's attributes off, so git's default file is not copied + [Test] + public async Task A_standalone_snapshot_copies_no_rules_when_the_user_turns_attributes_off() { + var source = Tmp.CreateDir("proj"); + source.CreateFile("README.md", "hello"); + Tmp.CreateFile("xdg/git/attributes", "*.psd filter=lfs\n"); + using var xdg = EnvScope.Exclusive("XDG_CONFIG_HOME", Tmp.PathTo("xdg")); + using var config = UserGlobalConfig("[core]\n attributesFile =\n"); + + var worktree = await NewManager().CreateAsync(source); + + await Assert.That(GitRepo.At(worktree.Path).Do("check-attr", "filter", "--", "art.psd").Text).IsEqualTo("art.psd: filter: unspecified"); + } + + /// A relative core.attributesFile names a file in the repository git runs in, the snapshot here + [Test] + public async Task A_standalone_snapshot_keeps_rules_from_a_relative_attributes_file() { + var source = Tmp.CreateDir("proj"); + source.CreateFile(".attrs", "*.psd filter=lfs\n"); + using var config = UserGlobalConfig("[core]\n attributesFile = .attrs\n"); + + var worktree = await NewManager().CreateAsync(source); + + await Assert.That(GitRepo.At(worktree.Path).Do("check-attr", "filter", "--", "art.psd").Text).IsEqualTo("art.psd: filter: lfs"); + } + + /// + /// The machine's attributes file sits below the snapshot's, and git resolves each attribute on its own, so the + /// snapshot's file must reset eol as well as text: a machine-wide * eol=crlf would otherwise + /// still convert every file. The machine's file cannot be staged, so the same ranking is built in a scratch + /// repository, where a subdirectory's attributes outrank the root's. + /// + [Test] + public async Task The_snapshot_attributes_reset_a_lower_ranked_text_and_eol() { + var source = Tmp.CreateDir("proj"); + source.CreateFile("README.md", "hello"); + var worktree = await NewManager().CreateAsync(source); + var rules = File.ReadAllText(GitRepo.At(worktree.Path).Do("config", "--local", "core.attributesFile").Text); + + using var scratch = GitRepo.CreateWithCommit("ranking"); + scratch.CreateFile(".gitattributes", "* text eol=crlf\n"); + scratch.CreateFile(Path.Combine("snapshot", ".gitattributes"), rules); + + await Assert.That(scratch.Do("check-attr", "text", "eol", "--", "snapshot/file.txt").Text) + .IsEqualTo("snapshot/file.txt: text: unspecified\nsnapshot/file.txt: eol: unspecified"); + } + + /// A project rule that sets only eol relies on text being unspecified, so the snapshot's own + /// attributes must not unset it: a checkout then writes the line ending the project asks for. + [Test] + public async Task A_standalone_snapshot_honours_a_project_rule_that_sets_only_eol() { + var source = Tmp.CreateDir("proj"); + source.CreateFile("eol.txt", "one\ntwo\n"); + source.CreateFile(".gitattributes", "eol.txt eol=crlf\n"); + + var worktree = await NewManager().CreateAsync(source); + + var snapshot = GitRepo.At(worktree.Path); + File.Delete(Path.Combine(worktree.Path, "eol.txt")); + snapshot.Do("checkout", "--", "eol.txt"); + await Assert.That(File.ReadAllText(Path.Combine(worktree.Path, "eol.txt"))).IsEqualTo("one\r\ntwo\r\n"); + } + + /// The machine's attributes file lives at a path compiled into git, so no test can stage one. This pins + /// instead that the snapshot's add, diff and commit run with GIT_ATTR_NOSYSTEM, read from a recorder in front + /// of the real git. The init before them carries no such variable, which proves the recorder reads it. + [Test, ExcludeOn(OS.Windows)] + public async Task A_standalone_snapshot_skips_the_machines_attributes_file() { + var source = Tmp.CreateDir("proj"); + source.CreateFile("README.md", "hello"); + var inherited = Environment.GetEnvironmentVariable("PATH") ?? ""; + var realGit = inherited.Split(Path.PathSeparator).Select(dir => Path.Combine(dir, "git")).First(File.Exists); + var log = Tmp.PathTo("git.log"); + var bin = Tmp.CreateDir("bin"); + bin.CreateExecutable("git", + $"#!/bin/sh\nprintf '%s %s\\n' \"${{GIT_ATTR_NOSYSTEM:-unset}}\" \"$1\" >> '{log}'\nexec '{realGit}' \"$@\"\n"); + using var path = EnvScope.Exclusive("PATH", $"{bin}{Path.PathSeparator}{inherited}"); + + await NewManager().CreateAsync(source); + + var calls = File.ReadAllLines(log); + await Assert.That(calls).Contains("unset init"); + await Assert.That(calls).Contains("1 add"); + await Assert.That(calls).Contains("1 diff"); + await Assert.That(calls).Contains("1 commit"); + } + + /// A user who converts line endings strictly would have the snapshot refuse an LF file, and an agent + /// later diffing it would see every file changed. The snapshot keeps the bytes it copied. + [Test] + public async Task A_standalone_snapshot_keeps_lf_files_when_the_user_converts_line_endings_strictly() { + var source = Tmp.CreateDir("proj"); + source.CreateFile("lf.txt", "one\ntwo\n"); + using var config = UserGlobalConfig("[core]\n autocrlf = true\n safecrlf = true\n"); + + var worktree = await NewManager().CreateAsync(source); + + var snapshot = GitRepo.At(worktree.Path); + await Assert.That(snapshot.Try("ls-tree", "--name-only", "HEAD").Text).Contains("lf.txt"); + await Assert.That(snapshot.Do("cat-file", "blob", "HEAD:lf.txt").StdOut).IsEqualTo("one\ntwo\n"); + // The snapshot's own config outranks the user's, so the agent's later checkouts and diffs keep LF too. + await Assert.That(snapshot.Do("config", "--local", "core.autocrlf").Text).IsEqualTo("false"); + await Assert.That(snapshot.Do("config", "--local", "core.safecrlf").Text).IsEqualTo("false"); + } +}