From 799fedfbb70b84caab0fa1befce8aeb815eaa93b Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:33:20 -0400 Subject: [PATCH 01/15] Keep a user's submodule and signing config out of worktree prep A fetch of a head that moves a submodule fails without any user config too: git fetches the submodule from its own remote by default, and a review launch has no business reaching it Co-Authored-By: Claude Opus 5.5 --- .../Services/WorktreeManager.cs | 25 +++--- .../Services/WorktreeManagerTests.cs | 38 ++++++++ .../WorktreeManagerUserGitConfigTests.cs | 87 +++++++++++++++++++ 3 files changed, 140 insertions(+), 10 deletions(-) create mode 100644 test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs diff --git a/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs b/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs index c4514b9b1d..8d899b7f4f 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, @@ -448,15 +448,15 @@ async Task BuildStandaloneSnapshotAsync(string repoPath, string wo 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")], + [.. noHooks, new("user.email", "daemon@kcap.local"), new("user.name", "kcap"), new("commit.gpgsign", "false")], commitArgs); return new WorktreeInfo(worktreePath, "", repoPath, IsStandalone: true); @@ -501,6 +501,12 @@ await RunGit(worktreePath, GitTimeout, /// (see ProveConfigTransportIfCarryingAsync), so nothing here has to remember to. static GitConfigOverride[] NoBranchHooks() => [new("core.hooksPath", NoHooksPath)]; + /// 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 +551,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 +1026,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(); diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerTests.cs index eb3796881c..74ef7314c4 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerTests.cs @@ -68,6 +68,44 @@ 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() { + 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; + sub.Dispose(); + + 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 0000000000..762d5e69d5 --- /dev/null +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs @@ -0,0 +1,87 @@ +using Capacitor.Cli.Daemon.Services; +using Capacitor.Tests.Helpers.Guards; +using Microsoft.Extensions.Logging.Abstractions; + +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"); + } +} From f1aa99d8917738c4a7691fd742b64d237db22385 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:23:08 -0400 Subject: [PATCH 02/15] Keep a standalone snapshot's line endings out of the user's conversion Co-Authored-By: Claude Opus 5.5 --- .../Services/WorktreeManager.cs | 15 ++++++++++++--- .../WorktreeManagerUserGitConfigTests.cs | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs b/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs index 8d899b7f4f..e8dcfa3897 100644 --- a/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs +++ b/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs @@ -435,15 +435,18 @@ async Task BuildStandaloneSnapshotAsync(string repoPath, string wo StripWorkspaceMcpConfig(worktreePath); var noHooks = NoBranchHooks(); await RunGit(worktreePath, GitTimeout, time, noHooks, "init"); + // Kept in the snapshot's own config too, so the agent's later diffs compare the bytes that were copied. + foreach (var setting in VerbatimLineEndings()) + 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, [.. noHooks, .. VerbatimLineEndings(), .. standaloneOverrides], "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, .. VerbatimLineEndings(), .. standaloneOverrides], "diff", "--cached", "--quiet"); if (staged.ExitCode is not (0 or 1)) throw new InvalidOperationException( @@ -456,7 +459,8 @@ async Task BuildStandaloneSnapshotAsync(string repoPath, string wo : new[] { "commit", "-m", "Initial snapshot" }; await RunGit(worktreePath, GitTimeout, time, - [.. noHooks, new("user.email", "daemon@kcap.local"), new("user.name", "kcap"), new("commit.gpgsign", "false")], + [.. noHooks, .. VerbatimLineEndings(), + new("user.email", "daemon@kcap.local"), new("user.name", "kcap"), new("commit.gpgsign", "false")], commitArgs); return new WorktreeInfo(worktreePath, "", repoPath, IsStandalone: true); @@ -504,6 +508,11 @@ await RunGit(worktreePath, GitTimeout, /// 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. + /// 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. + static GitConfigOverride[] VerbatimLineEndings() => + [new("core.autocrlf", "false"), new("core.safecrlf", "false")]; + static GitConfigOverride[] NoSubmoduleRecursion() => [new("submodule.recurse", "false"), new("fetch.recurseSubmodules", "false")]; diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs index 762d5e69d5..4f2b2edb11 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs @@ -84,4 +84,22 @@ public async Task A_standalone_snapshot_is_built_when_the_user_signs_every_commi 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 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"); + } } From c0a34875bc8f73b3f0e7040900c49025cb91b688 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:05:35 -0400 Subject: [PATCH 03/15] Keep a user's own attributes file out of a standalone snapshot The value travels through the environment transport, which git does not unescape, so the empty file's path is written with forward slashes Co-Authored-By: Claude Opus 5.5 --- .../Services/WorktreeManager.cs | 31 +++++++++++++------ .../WorktreeManagerUserGitConfigTests.cs | 19 ++++++++++++ 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs b/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs index e8dcfa3897..1da8d3033e 100644 --- a/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs +++ b/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs @@ -435,18 +435,19 @@ async Task BuildStandaloneSnapshotAsync(string repoPath, string wo StripWorkspaceMcpConfig(worktreePath); var noHooks = NoBranchHooks(); await RunGit(worktreePath, GitTimeout, time, noHooks, "init"); + var verbatim = VerbatimLineEndings(CreateEmptyAttributesFile(worktreePath)); // Kept in the snapshot's own config too, so the agent's later diffs compare the bytes that were copied. - foreach (var setting in VerbatimLineEndings()) + 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, .. VerbatimLineEndings(), .. standaloneOverrides], "add", "-A"); + await RunGit(worktreePath, GitTimeout, time, [.. noHooks, .. verbatim, .. standaloneOverrides], "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, .. VerbatimLineEndings(), .. standaloneOverrides], + [.. noHooks, .. verbatim, .. standaloneOverrides], "diff", "--cached", "--quiet"); if (staged.ExitCode is not (0 or 1)) throw new InvalidOperationException( @@ -459,7 +460,7 @@ async Task BuildStandaloneSnapshotAsync(string repoPath, string wo : new[] { "commit", "-m", "Initial snapshot" }; await RunGit(worktreePath, GitTimeout, time, - [.. noHooks, .. VerbatimLineEndings(), + [.. noHooks, .. verbatim, new("user.email", "daemon@kcap.local"), new("user.name", "kcap"), new("commit.gpgsign", "false")], commitArgs); @@ -505,14 +506,26 @@ 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 an + /// empty one, so only the project's .gitattributes decides a file's conversion. + static GitConfigOverride[] VerbatimLineEndings(string emptyAttributesFile) => + [new("core.autocrlf", "false"), new("core.safecrlf", "false"), new("core.attributesFile", emptyAttributesFile)]; + + /// An empty attributes file inside the snapshot's 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. + static string CreateEmptyAttributesFile(string worktreePath) { + var info = Path.Combine(worktreePath, ".git", "info"); + Directory.CreateDirectory(info); + var path = Path.Combine(info, "kcap-no-user-attributes"); + File.WriteAllBytes(path, []); + + return Path.GetFullPath(path).Replace('\\', '/'); + } + /// 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. - /// 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. - static GitConfigOverride[] VerbatimLineEndings() => - [new("core.autocrlf", "false"), new("core.safecrlf", "false")]; - static GitConfigOverride[] NoSubmoduleRecursion() => [new("submodule.recurse", "false"), new("fetch.recurseSubmodules", "false")]; diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs index 4f2b2edb11..e5e2286f61 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs @@ -85,6 +85,25 @@ public async Task A_standalone_snapshot_is_built_when_the_user_signs_every_commi 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"); + await Assert.That(snapshot.Do("check-attr", "text", "crlf.txt").Text).IsEqualTo("crlf.txt: text: unspecified"); + } + /// 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] From 0fe6b03de53485f7c09a896d9259b5f318b2e373 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:20:31 -0400 Subject: [PATCH 04/15] Keep the machine's attributes file out of a standalone snapshot No config replaces the system attributes file, only the environment variable, so the snapshot's git runs can now carry environment as well as config Co-Authored-By: Claude Opus 5.5 --- .../Services/WorktreeManager.cs | 46 +++++++++++++------ .../WorktreeManagerUserGitConfigTests.cs | 25 ++++++++++ 2 files changed, 58 insertions(+), 13 deletions(-) diff --git a/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs b/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs index 1da8d3033e..fe36315993 100644 --- a/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs +++ b/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs @@ -441,13 +441,14 @@ async Task BuildStandaloneSnapshotAsync(string repoPath, string wo 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, .. verbatim, .. 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, .. verbatim, .. standaloneOverrides], + [.. noHooks, .. verbatim, .. standaloneOverrides], NoSystemAttributes, "diff", "--cached", "--quiet"); if (staged.ExitCode is not (0 or 1)) throw new InvalidOperationException( @@ -458,11 +459,10 @@ async Task BuildStandaloneSnapshotAsync(string repoPath, string wo var commitArgs = staged.ExitCode == 0 ? new[] { "commit", "--allow-empty", "-m", "Initial snapshot" } : new[] { "commit", "-m", "Initial snapshot" }; - await RunGit(worktreePath, GitTimeout, - time, + await RunGit(worktreePath, GitTimeout, time, sourceReadOnly: false, [.. noHooks, .. verbatim, new("user.email", "daemon@kcap.local"), new("user.name", "kcap"), new("commit.gpgsign", "false")], - commitArgs); + NoSystemAttributes, commitArgs); return new WorktreeInfo(worktreePath, "", repoPath, IsStandalone: true); } @@ -512,6 +512,10 @@ await RunGit(worktreePath, GitTimeout, static GitConfigOverride[] VerbatimLineEndings(string emptyAttributesFile) => [new("core.autocrlf", "false"), new("core.safecrlf", "false"), new("core.attributesFile", emptyAttributesFile)]; + /// 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")]; + /// An empty attributes file inside the snapshot's 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. static string CreateEmptyAttributesFile(string worktreePath) { @@ -1756,8 +1760,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) { @@ -1818,10 +1822,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)}"); @@ -1854,12 +1863,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); } /// @@ -1888,10 +1902,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/WorktreeManagerUserGitConfigTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs index e5e2286f61..7605f753c9 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs @@ -1,6 +1,7 @@ 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; @@ -104,6 +105,30 @@ public async Task A_standalone_snapshot_keeps_crlf_files_under_the_users_own_att await Assert.That(snapshot.Do("check-attr", "text", "crlf.txt").Text).IsEqualTo("crlf.txt: text: unspecified"); } + /// 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] From d3a01193f16aa779ec37ef3ee4de13624ddae7a5 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:26:14 -0400 Subject: [PATCH 05/15] Unset text in the snapshot's attributes file for later git commands The machine's attributes still apply once GIT_ATTR_NOSYSTEM is gone; the snapshot's file outranks them and is itself outranked by the project's .gitattributes, unlike info/attributes Co-Authored-By: Claude Opus 5.5 --- .../Services/WorktreeManager.cs | 23 +++++++++++-------- .../WorktreeManagerUserGitConfigTests.cs | 6 ++++- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs b/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs index fe36315993..2af2218eb1 100644 --- a/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs +++ b/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs @@ -435,7 +435,7 @@ async Task BuildStandaloneSnapshotAsync(string repoPath, string wo StripWorkspaceMcpConfig(worktreePath); var noHooks = NoBranchHooks(); await RunGit(worktreePath, GitTimeout, time, noHooks, "init"); - var verbatim = VerbatimLineEndings(CreateEmptyAttributesFile(worktreePath)); + var verbatim = VerbatimLineEndings(CreateSnapshotAttributesFile(worktreePath)); // 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); @@ -507,22 +507,25 @@ await RunGit(worktreePath, GitTimeout, time, sourceReadOnly: false, 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 an - /// empty one, so only the project's .gitattributes decides a file's conversion. - static GitConfigOverride[] VerbatimLineEndings(string emptyAttributesFile) => - [new("core.autocrlf", "false"), new("core.safecrlf", "false"), new("core.attributesFile", emptyAttributesFile)]; + /// them, and its strict form refuses the commit outright. A user's own attributes file is replaced by the + /// snapshot's, 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")]; - /// An empty attributes file inside the snapshot's 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. - static string CreateEmptyAttributesFile(string worktreePath) { + /// 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. It unsets text for + /// every path. 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) { var info = Path.Combine(worktreePath, ".git", "info"); Directory.CreateDirectory(info); - var path = Path.Combine(info, "kcap-no-user-attributes"); - File.WriteAllBytes(path, []); + var path = Path.Combine(info, "kcap-snapshot-attributes"); + File.WriteAllText(path, "* -text\n"); return Path.GetFullPath(path).Replace('\\', '/'); } diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs index 7605f753c9..2940fec697 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs @@ -102,7 +102,11 @@ public async Task A_standalone_snapshot_keeps_crlf_files_under_the_users_own_att 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"); - await Assert.That(snapshot.Do("check-attr", "text", "crlf.txt").Text).IsEqualTo("crlf.txt: text: unspecified"); + // 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, so a machine-wide `* text` cannot reach these files. + await Assert.That(snapshot.Do("check-attr", "text", "crlf.txt").Text).IsEqualTo("crlf.txt: text: unset"); + 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 machine's attributes file lives at a path compiled into git, so no test can stage one. This pins From 9a34e1645d0561ae9720d204a2d6a13b60faa36e Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:19:35 -0400 Subject: [PATCH 06/15] Reset text to unspecified in the snapshot's attributes, not unset An unset text marks every path binary, which voided a project rule that sets only eol; !text still outranks the machine's attributes Co-Authored-By: Claude Opus 5.5 --- .../Services/WorktreeManager.cs | 11 +++++----- .../WorktreeManagerUserGitConfigTests.cs | 21 +++++++++++++++++-- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs b/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs index 2af2218eb1..fd9a21e752 100644 --- a/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs +++ b/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs @@ -517,15 +517,16 @@ static GitConfigOverride[] VerbatimLineEndings(string attributesFile) => 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. It unsets text for - /// every path. 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. + /// config value read from the environment is not unescaped, so it carries no backslash. Its !text resets + /// text to unspecified for every path: -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) { var info = Path.Combine(worktreePath, ".git", "info"); Directory.CreateDirectory(info); var path = Path.Combine(info, "kcap-snapshot-attributes"); - File.WriteAllText(path, "* -text\n"); + File.WriteAllText(path, "* !text\n"); return Path.GetFullPath(path).Replace('\\', '/'); } diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs index 2940fec697..c051ab41ad 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs @@ -103,12 +103,29 @@ public async Task A_standalone_snapshot_keeps_crlf_files_under_the_users_own_att 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, so a machine-wide `* text` cannot reach these files. - await Assert.That(snapshot.Do("check-attr", "text", "crlf.txt").Text).IsEqualTo("crlf.txt: text: unset"); + // 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(""); } + /// 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. From a0e0100a2f114ae0c9ce18dbecef0eb697940cad Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:52:37 -0400 Subject: [PATCH 07/15] Reset eol as well as text in the snapshot's attributes Git resolves each attribute on its own, so a machine-wide eol=crlf got past a reset of text alone Co-Authored-By: Claude Opus 5.5 --- .../Services/WorktreeManager.cs | 8 +++---- .../WorktreeManagerUserGitConfigTests.cs | 21 +++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs b/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs index fd9a21e752..17b882e976 100644 --- a/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs +++ b/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs @@ -517,16 +517,16 @@ static GitConfigOverride[] VerbatimLineEndings(string attributesFile) => 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 resets - /// text to unspecified for every path: -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 + /// 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) { var info = Path.Combine(worktreePath, ".git", "info"); Directory.CreateDirectory(info); var path = Path.Combine(info, "kcap-snapshot-attributes"); - File.WriteAllText(path, "* !text\n"); + File.WriteAllText(path, "* !text !eol\n"); return Path.GetFullPath(path).Replace('\\', '/'); } diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs index c051ab41ad..cea6d7ebb4 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs @@ -110,6 +110,27 @@ public async Task A_standalone_snapshot_keeps_crlf_files_under_the_users_own_att await Assert.That(snapshot.Do("status", "--porcelain").Text).IsEqualTo(""); } + /// + /// 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] From 14b2b7d1e838be739c7b51716b63f9f8c70b180b Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:37:31 -0400 Subject: [PATCH 08/15] Rewrap the snapshot attributes file's summary Co-Authored-By: Claude Opus 5.5 --- src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs b/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs index 17b882e976..5d0b8dbf97 100644 --- a/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs +++ b/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs @@ -519,9 +519,10 @@ static GitConfigOverride[] VerbatimLineEndings(string attributesFile) => /// 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. + /// -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) { var info = Path.Combine(worktreePath, ".git", "info"); Directory.CreateDirectory(info); From 6bd17801910f2c3c45ed14975bec9ae6f32b1e43 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:20:14 -0400 Subject: [PATCH 09/15] Keep a user's own attribute rules in a standalone snapshot The snapshot's attributes file replaces the user's for the agent's later commands, so an LFS filter there would otherwise stop applying. Co-Authored-By: Claude Opus 5.5 --- .../Services/WorktreeManager.cs | 25 ++++++++++++++++--- .../WorktreeManagerUserGitConfigTests.cs | 18 +++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs b/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs index 5d0b8dbf97..db11362387 100644 --- a/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs +++ b/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs @@ -435,7 +435,8 @@ async Task BuildStandaloneSnapshotAsync(string repoPath, string wo StripWorkspaceMcpConfig(worktreePath); var noHooks = NoBranchHooks(); await RunGit(worktreePath, GitTimeout, time, noHooks, "init"); - var verbatim = VerbatimLineEndings(CreateSnapshotAttributesFile(worktreePath)); + 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); @@ -508,7 +509,8 @@ await RunGit(worktreePath, GitTimeout, time, sourceReadOnly: false, /// 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, so only the project's .gitattributes decides a file's conversion. + /// 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)]; @@ -523,15 +525,30 @@ static GitConfigOverride[] VerbatimLineEndings(string attributesFile) => /// 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) { + 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"); - File.WriteAllText(path, "* !text !eol\n"); + // 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, else the default git reports for it. A git older than + /// 2.42 cannot report the default, and the snapshot then keeps no rules of the user's + static async Task UserAttributesFileAsync(string worktreePath, TimeProvider time) { + var configured = await RunGitCaptureResult(worktreePath, GitTimeout, time, sourceReadOnly: false, [], + "config", "--path", "--get", "core.attributesFile"); + if (configured.ExitCode == 0 && configured.Stdout.Trim() is { Length: > 0 } named) return named; + + var fallback = await RunGitCaptureResult(worktreePath, GitTimeout, time, sourceReadOnly: false, [], "var", "GIT_ATTR_GLOBAL"); + + return fallback.ExitCode == 0 && fallback.Stdout.Trim() is { Length: > 0 } path ? path : 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. diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs index cea6d7ebb4..a56974c9e6 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs @@ -110,6 +110,24 @@ public async Task A_standalone_snapshot_keeps_crlf_files_under_the_users_own_att 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"); + } + /// /// 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 From db388ab6466cef8681535a9b1fc91509c2035cee Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:29:41 -0400 Subject: [PATCH 10/15] Find a user's attributes file the way git does on every version Co-Authored-By: Claude Opus 5.5 --- .../Services/WorktreeManager.cs | 16 +++++++++++----- .../WorktreeManagerUserGitConfigTests.cs | 12 ++++++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs b/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs index db11362387..9a7b823beb 100644 --- a/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs +++ b/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs @@ -537,16 +537,22 @@ static string CreateSnapshotAttributesFile(string worktreePath, string? userAttr return Path.GetFullPath(path).Replace('\\', '/'); } - /// The attributes file the user's git config names, else the default git reports for it. A git older than - /// 2.42 cannot report the default, and the snapshot then keeps no rules of the user's + /// 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 HOME 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"); - if (configured.ExitCode == 0 && configured.Stdout.Trim() is { Length: > 0 } named) return named; + if (configured.ExitCode == 0 && configured.Stdout.Trim() is { Length: > 0 } named) return Path.Combine(worktreePath, named); - var fallback = await RunGitCaptureResult(worktreePath, GitTimeout, time, sourceReadOnly: false, [], "var", "GIT_ATTR_GLOBAL"); + var reported = await RunGitCaptureResult(worktreePath, GitTimeout, time, sourceReadOnly: false, [], "var", "GIT_ATTR_GLOBAL"); + if (reported.ExitCode == 0 && reported.Stdout.Trim() is { Length: > 0 } path) return path; - return fallback.ExitCode == 0 && fallback.Stdout.Trim() is { Length: > 0 } path ? path : null; + if (Environment.GetEnvironmentVariable("XDG_CONFIG_HOME") is { Length: > 0 } xdg) return Path.Combine(xdg, "git", "attributes"); + + return (Environment.GetEnvironmentVariable("HOME") ?? Environment.GetEnvironmentVariable("USERPROFILE")) is { Length: > 0 } home + ? Path.Combine(home, ".config", "git", "attributes") + : null; } /// A linked worktree's submodule has no gitdir of its own, so a checkout that recursed into it diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs index a56974c9e6..8c2e6f6b79 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs @@ -128,6 +128,18 @@ await Assert.That(snapshot.Do("check-attr", "text", "eol", "--", "notes.txt").Te .IsEqualTo("notes.txt: text: unspecified\nnotes.txt: eol: 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 From e85941356f0eeca80fe355a401605234d50591a8 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:32:13 -0400 Subject: [PATCH 11/15] Rebuild git's home the way git for Windows does Co-Authored-By: Claude Opus 5.5 --- .../Services/WorktreeManager.cs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs b/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs index 9a7b823beb..ef14079748 100644 --- a/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs +++ b/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs @@ -539,7 +539,7 @@ static string CreateSnapshotAttributesFile(string worktreePath, string? userAttr /// 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 HOME rather than the daemon's resolved one + /// 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"); @@ -550,9 +550,18 @@ static string CreateSnapshotAttributesFile(string worktreePath, string? userAttr if (Environment.GetEnvironmentVariable("XDG_CONFIG_HOME") is { Length: > 0 } xdg) return Path.Combine(xdg, "git", "attributes"); - return (Environment.GetEnvironmentVariable("HOME") ?? Environment.GetEnvironmentVariable("USERPROFILE")) is { Length: > 0 } home - ? Path.Combine(home, ".config", "git", "attributes") - : null; + return GitHome() is { } home ? Path.Combine(home, ".config", "git", "attributes") : null; + } + + /// 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 From f95df371fd2991ee4542d21618965f2ddc2b2f8f Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:20:05 -0400 Subject: [PATCH 12/15] Read a user's attributes path as git prints it, and honour an empty one Co-Authored-By: Claude Opus 5.5 --- .../Services/WorktreeManager.cs | 7 +++++-- .../Services/WorktreeManagerTests.cs | 2 ++ .../Services/WorktreeManagerUserGitConfigTests.cs | 14 ++++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs b/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs index ef14079748..60ad5384d2 100644 --- a/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs +++ b/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs @@ -543,10 +543,13 @@ static string CreateSnapshotAttributesFile(string worktreePath, string? userAttr static async Task UserAttributesFileAsync(string worktreePath, TimeProvider time) { var configured = await RunGitCaptureResult(worktreePath, GitTimeout, time, sourceReadOnly: false, [], "config", "--path", "--get", "core.attributesFile"); - if (configured.ExitCode == 0 && configured.Stdout.Trim() is { Length: > 0 } named) return Path.Combine(worktreePath, named); + // Only git's line ending is trimmed: a path may begin or end with a space. An explicitly empty value turns the + // user's attributes off, so the default is not consulted. + if (configured.ExitCode == 0) + return configured.Stdout.TrimEnd('\r', '\n') 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 && reported.Stdout.Trim() is { Length: > 0 } path) return path; + if (reported.ExitCode == 0 && reported.Stdout.TrimEnd('\r', '\n') is { Length: > 0 } path) return path; if (Environment.GetEnvironmentVariable("XDG_CONFIG_HOME") is { Length: > 0 } xdg) return Path.Combine(xdg, "git", "attributes"); diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerTests.cs index 74ef7314c4..2b16d3b738 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerTests.cs @@ -95,6 +95,8 @@ public async Task CreateAsync_fetches_a_head_that_moves_a_submodule_whose_remote up.Do("update-ref", "refs/pull/7/head", up.Head); var head = up.Head; sub.Dispose(); + // A recursing fetch could otherwise reach the moved commit, and the HEAD assertion would prove nothing. + await Assert.That(Directory.Exists(sub.Path)).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"); diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs index 8c2e6f6b79..995981ace4 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerUserGitConfigTests.cs @@ -128,6 +128,20 @@ await Assert.That(snapshot.Do("check-attr", "text", "eol", "--", "notes.txt").Te .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() { From 7685f7163239ec8186479e044331a33125828cfa Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:25:37 -0400 Subject: [PATCH 13/15] Strip only the line terminator git ends its output with Co-Authored-By: Claude Opus 5.5 --- .../Services/WorktreeManager.cs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs b/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs index 60ad5384d2..f3a01ad9ec 100644 --- a/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs +++ b/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs @@ -543,19 +543,25 @@ static string CreateSnapshotAttributesFile(string worktreePath, string? userAttr static async Task UserAttributesFileAsync(string worktreePath, TimeProvider time) { var configured = await RunGitCaptureResult(worktreePath, GitTimeout, time, sourceReadOnly: false, [], "config", "--path", "--get", "core.attributesFile"); - // Only git's line ending is trimmed: a path may begin or end with a space. An explicitly empty value turns the - // user's attributes off, so the default is not consulted. + // An explicitly empty value turns the user's attributes off, so the default is not consulted. if (configured.ExitCode == 0) - return configured.Stdout.TrimEnd('\r', '\n') is { Length: > 0 } named ? Path.Combine(worktreePath, named) : null; + 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 && reported.Stdout.TrimEnd('\r', '\n') is { Length: > 0 } path) return path; + 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 one line terminator it ends with; a path's own trailing characters stay + static string OutputLine(string stdout) { + var line = stdout.EndsWith('\n') ? stdout[..^1] : stdout; + + return line.EndsWith('\r') ? line[..^1] : line; + } + /// The home git uses: HOME, else on Windows HOMEDRIVE plus HOMEPATH, else /// USERPROFILE static string? GitHome() { From 5bd9bb8f9f05e63d2d993e2df0797ea2bfcd2c5d Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:34:08 -0400 Subject: [PATCH 14/15] Strip only the LF git ends its output with Co-Authored-By: Claude Opus 5.5 --- src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs b/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs index f3a01ad9ec..813891d9c5 100644 --- a/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs +++ b/src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs @@ -555,12 +555,8 @@ static string CreateSnapshotAttributesFile(string worktreePath, string? userAttr return GitHome() is { } home ? Path.Combine(home, ".config", "git", "attributes") : null; } - /// Git's output without the one line terminator it ends with; a path's own trailing characters stay - static string OutputLine(string stdout) { - var line = stdout.EndsWith('\n') ? stdout[..^1] : stdout; - - return line.EndsWith('\r') ? line[..^1] : line; - } + /// 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 From a3a8eb6b670bf18a1f71557e257a6b8da07e3821 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:17:41 -0400 Subject: [PATCH 15/15] Make the submodule remote unreachable by repointing it, not deleting it Windows keeps git's read-only object files, so a best-effort delete left the remote in place. Co-Authored-By: Claude Opus 5.5 --- .../Services/WorktreeManagerTests.cs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerTests.cs index 2b16d3b738..113d87e434 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/WorktreeManagerTests.cs @@ -75,7 +75,7 @@ public async Task CreateAsync_WithBaseRef_WorktreeHeadMatchesFetchedCommit() { /// [Test] public async Task CreateAsync_fetches_a_head_that_moves_a_submodule_whose_remote_is_unreachable() { - var sub = GitRepo.CreateWithCommit("sub"); + 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"); @@ -94,9 +94,12 @@ public async Task CreateAsync_fetches_a_head_that_moves_a_submodule_whose_remote up.Commit("move the submodule"); up.Do("update-ref", "refs/pull/7/head", up.Head); var head = up.Head; - sub.Dispose(); - // A recursing fetch could otherwise reach the moved commit, and the HEAD assertion would prove nothing. - await Assert.That(Directory.Exists(sub.Path)).IsFalse(); + // 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");