Skip to content

Commit adcc397

Browse files
PureWeenCopilot
andauthored
fix: use .git/info/exclude instead of .gitignore for worktree exclusions (#434)
## Problem The nested worktree strategy creates `.polypilot/worktrees/` inside the target repo, then modifies `.gitignore` to hide it. But the `.gitignore` change itself shows up as an unstaged modification — which CI flags and confuses users. ## Fix Replace `.gitignore` modification with `.git/info/exclude` — a local-only exclusion file that: - Works identically to `.gitignore` for pattern matching - Is **never tracked by git** (lives inside `.git/`) - Doesn't pollute the repo's working tree - Also handles worktree repos where `.git` is a pointer file (resolves the real gitdir) ## Tests Updated all 4 existing tests + added a new test for the worktree gitdir pointer case. All 5 pass. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent aa5c050 commit adcc397

4 files changed

Lines changed: 216 additions & 33 deletions

File tree

PolyPilot.Tests/RepoManagerTests.cs

Lines changed: 119 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -527,86 +527,184 @@ private static bool PathsEqual(string left, string right)
527527
return string.Equals(l, r, StringComparison.OrdinalIgnoreCase);
528528
}
529529

530-
#region EnsureGitIgnoreEntry Tests
530+
#region EnsureGitExcludeEntry Tests
531531

532532
[Fact]
533-
public void EnsureGitIgnoreEntry_CreatesGitIgnoreIfMissing()
533+
public void EnsureGitExcludeEntry_CreatesExcludeIfMissing()
534534
{
535535
var tmpDir = Directory.CreateTempSubdirectory("polypilot-test-").FullName;
536536
try
537537
{
538-
var method = typeof(RepoManager).GetMethod("EnsureGitIgnoreEntry",
538+
// Create a .git/info directory to simulate a real repo
539+
var infoDir = Path.Combine(tmpDir, ".git", "info");
540+
Directory.CreateDirectory(infoDir);
541+
542+
var method = typeof(RepoManager).GetMethod("EnsureGitExcludeEntry",
539543
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!;
540544
method.Invoke(null, [tmpDir, ".polypilot/"]);
541545

542-
var gitignorePath = Path.Combine(tmpDir, ".gitignore");
543-
Assert.True(File.Exists(gitignorePath));
544-
var content = File.ReadAllText(gitignorePath);
546+
var excludePath = Path.Combine(infoDir, "exclude");
547+
Assert.True(File.Exists(excludePath));
548+
var content = File.ReadAllText(excludePath);
545549
Assert.Contains(".polypilot/", content);
546550
}
547551
finally { ForceDeleteDirectory(tmpDir); }
548552
}
549553

550554
[Fact]
551-
public void EnsureGitIgnoreEntry_AppendsIfNotPresent()
555+
public void EnsureGitExcludeEntry_AppendsIfNotPresent()
552556
{
553557
var tmpDir = Directory.CreateTempSubdirectory("polypilot-test-").FullName;
554558
try
555559
{
556-
var gitignorePath = Path.Combine(tmpDir, ".gitignore");
557-
File.WriteAllText(gitignorePath, "*.user\nbin/\n");
560+
var infoDir = Path.Combine(tmpDir, ".git", "info");
561+
Directory.CreateDirectory(infoDir);
562+
var excludePath = Path.Combine(infoDir, "exclude");
563+
File.WriteAllText(excludePath, "*.user\nbin/\n");
558564

559-
var method = typeof(RepoManager).GetMethod("EnsureGitIgnoreEntry",
565+
var method = typeof(RepoManager).GetMethod("EnsureGitExcludeEntry",
560566
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!;
561567
method.Invoke(null, [tmpDir, ".polypilot/"]);
562568

563-
var content = File.ReadAllText(gitignorePath);
569+
var content = File.ReadAllText(excludePath);
564570
Assert.Contains(".polypilot/", content);
565571
Assert.Contains("*.user", content); // existing content preserved
566572
}
567573
finally { ForceDeleteDirectory(tmpDir); }
568574
}
569575

570576
[Fact]
571-
public void EnsureGitIgnoreEntry_IdempotentIfAlreadyPresent()
577+
public void EnsureGitExcludeEntry_IdempotentIfAlreadyPresent()
572578
{
573579
var tmpDir = Directory.CreateTempSubdirectory("polypilot-test-").FullName;
574580
try
575581
{
576-
var gitignorePath = Path.Combine(tmpDir, ".gitignore");
577-
File.WriteAllText(gitignorePath, ".polypilot/\n");
582+
var infoDir = Path.Combine(tmpDir, ".git", "info");
583+
Directory.CreateDirectory(infoDir);
584+
var excludePath = Path.Combine(infoDir, "exclude");
585+
File.WriteAllText(excludePath, ".polypilot/\n");
578586

579-
var method = typeof(RepoManager).GetMethod("EnsureGitIgnoreEntry",
587+
var method = typeof(RepoManager).GetMethod("EnsureGitExcludeEntry",
580588
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!;
581589
method.Invoke(null, [tmpDir, ".polypilot/"]);
582590
method.Invoke(null, [tmpDir, ".polypilot/"]); // call twice
583591

584-
var lines = File.ReadAllLines(gitignorePath);
592+
var lines = File.ReadAllLines(excludePath);
585593
Assert.Equal(1, lines.Count(l => l.Trim() == ".polypilot/")); // only one entry
586594
}
587595
finally { ForceDeleteDirectory(tmpDir); }
588596
}
589597

590598
[Fact]
591-
public void EnsureGitIgnoreEntry_MatchesWithoutTrailingSlash()
599+
public void EnsureGitExcludeEntry_MatchesWithoutTrailingSlash()
592600
{
593601
var tmpDir = Directory.CreateTempSubdirectory("polypilot-test-").FullName;
594602
try
595603
{
596-
var gitignorePath = Path.Combine(tmpDir, ".gitignore");
597-
File.WriteAllText(gitignorePath, ".polypilot\n"); // no trailing slash variant
604+
var infoDir = Path.Combine(tmpDir, ".git", "info");
605+
Directory.CreateDirectory(infoDir);
606+
var excludePath = Path.Combine(infoDir, "exclude");
607+
File.WriteAllText(excludePath, ".polypilot\n"); // no trailing slash variant
598608

599-
var method = typeof(RepoManager).GetMethod("EnsureGitIgnoreEntry",
609+
var method = typeof(RepoManager).GetMethod("EnsureGitExcludeEntry",
600610
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!;
601611
method.Invoke(null, [tmpDir, ".polypilot/"]);
602612

603-
var content = File.ReadAllText(gitignorePath);
613+
var content = File.ReadAllText(excludePath);
604614
// Should NOT add a duplicate (already covered by ".polypilot" line)
605615
Assert.DoesNotContain(".polypilot/", content);
606616
}
607617
finally { ForceDeleteDirectory(tmpDir); }
608618
}
609619

620+
[Fact]
621+
public void EnsureGitExcludeEntry_HandlesWorktreeGitdirPointer()
622+
{
623+
var tmpDir = Directory.CreateTempSubdirectory("polypilot-test-").FullName;
624+
try
625+
{
626+
// Simulate a worktree where .git is a file pointing to the real gitdir
627+
var realGitDir = Path.Combine(tmpDir, "real-gitdir");
628+
Directory.CreateDirectory(Path.Combine(realGitDir, "info"));
629+
File.WriteAllText(Path.Combine(tmpDir, ".git"), $"gitdir: {realGitDir}\n");
630+
631+
var method = typeof(RepoManager).GetMethod("EnsureGitExcludeEntry",
632+
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!;
633+
method.Invoke(null, [tmpDir, ".polypilot/"]);
634+
635+
var excludePath = Path.Combine(realGitDir, "info", "exclude");
636+
Assert.True(File.Exists(excludePath));
637+
var content = File.ReadAllText(excludePath);
638+
Assert.Contains(".polypilot/", content);
639+
}
640+
finally { ForceDeleteDirectory(tmpDir); }
641+
}
642+
643+
[Fact]
644+
public void EnsureGitExcludeEntry_HandlesRelativeGitdirPointer()
645+
{
646+
var tmpDir = Directory.CreateTempSubdirectory("polypilot-test-").FullName;
647+
try
648+
{
649+
// Simulate a worktree with a relative gitdir pointer (e.g., ../.git/worktrees/name)
650+
var bareGitDir = Path.Combine(tmpDir, "bare-repo.git");
651+
var worktreeGitDir = Path.Combine(bareGitDir, "worktrees", "my-branch");
652+
Directory.CreateDirectory(Path.Combine(worktreeGitDir, "info"));
653+
654+
var worktreeDir = Path.Combine(tmpDir, "my-worktree");
655+
Directory.CreateDirectory(worktreeDir);
656+
// Write a relative gitdir pointer
657+
var relativePath = Path.GetRelativePath(worktreeDir, worktreeGitDir);
658+
File.WriteAllText(Path.Combine(worktreeDir, ".git"), $"gitdir: {relativePath}\n");
659+
660+
var method = typeof(RepoManager).GetMethod("EnsureGitExcludeEntry",
661+
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!;
662+
method.Invoke(null, [worktreeDir, ".polypilot/"]);
663+
664+
var excludePath = Path.Combine(worktreeGitDir, "info", "exclude");
665+
Assert.True(File.Exists(excludePath));
666+
var content = File.ReadAllText(excludePath);
667+
Assert.Contains(".polypilot/", content);
668+
}
669+
finally { ForceDeleteDirectory(tmpDir); }
670+
}
671+
672+
[Fact]
673+
public void EnsureGitExcludeEntry_NoGitDirectory_NoOp()
674+
{
675+
var tmpDir = Directory.CreateTempSubdirectory("polypilot-test-").FullName;
676+
try
677+
{
678+
// No .git file or directory — should be a no-op, not create spurious directories
679+
var method = typeof(RepoManager).GetMethod("EnsureGitExcludeEntry",
680+
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!;
681+
method.Invoke(null, [tmpDir, ".polypilot/"]);
682+
683+
Assert.False(Directory.Exists(Path.Combine(tmpDir, ".git")));
684+
Assert.False(File.Exists(Path.Combine(tmpDir, ".git", "info", "exclude")));
685+
}
686+
finally { ForceDeleteDirectory(tmpDir); }
687+
}
688+
689+
[Fact]
690+
public void EnsureGitExcludeEntry_MalformedGitFile_NoOp()
691+
{
692+
var tmpDir = Directory.CreateTempSubdirectory("polypilot-test-").FullName;
693+
try
694+
{
695+
// .git is a file but doesn't contain gitdir: prefix — should be a no-op
696+
File.WriteAllText(Path.Combine(tmpDir, ".git"), "this is not a valid gitdir pointer\n");
697+
698+
var method = typeof(RepoManager).GetMethod("EnsureGitExcludeEntry",
699+
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!;
700+
method.Invoke(null, [tmpDir, ".polypilot/"]);
701+
702+
// Should not have created any info/exclude anywhere
703+
Assert.False(Directory.Exists(Path.Combine(tmpDir, ".git", "info")));
704+
}
705+
finally { ForceDeleteDirectory(tmpDir); }
706+
}
707+
610708
#endregion
611709

612710
#region Nested Worktree Path Traversal Tests

PolyPilot/Services/CopilotService.Utilities.cs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,30 @@ public partial class CopilotService
2525
var d = new DirectoryInfo(dir);
2626
while (d != null)
2727
{
28-
var head = Path.Combine(d.FullName, ".git", "HEAD");
28+
var dotGitPath = Path.Combine(d.FullName, ".git");
29+
30+
// Normal repo: .git is a directory containing HEAD
31+
var head = Path.Combine(dotGitPath, "HEAD");
2932
if (File.Exists(head)) return head;
33+
34+
// Worktree: .git is a file containing "gitdir: /path/to/real/gitdir"
35+
if (File.Exists(dotGitPath))
36+
{
37+
try
38+
{
39+
var firstLine = File.ReadLines(dotGitPath).FirstOrDefault()?.Trim();
40+
if (firstLine != null && firstLine.StartsWith("gitdir:", StringComparison.OrdinalIgnoreCase))
41+
{
42+
var gitdir = firstLine["gitdir:".Length..].Trim();
43+
if (!Path.IsPathRooted(gitdir))
44+
gitdir = Path.GetFullPath(Path.Combine(d.FullName, gitdir));
45+
var worktreeHead = Path.Combine(gitdir, "HEAD");
46+
if (File.Exists(worktreeHead)) return worktreeHead;
47+
}
48+
}
49+
catch { /* fall through to parent */ }
50+
}
51+
3052
d = d.Parent;
3153
}
3254
return null;

PolyPilot/Services/CopilotService.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2647,6 +2647,18 @@ public async Task<AgentSessionInfo> CreateSessionWithWorktreeAsync(
26472647
{
26482648
var branch = branchName ?? $"session-{DateTime.Now:yyyyMMdd-HHmmss}";
26492649
var remoteName = sessionName ?? branch;
2650+
// Use repo short name instead of auto-generated session timestamp
2651+
if (sessionName == null && branch.StartsWith("session-", StringComparison.Ordinal))
2652+
{
2653+
var repoInfo = _repoManager.Repositories.FirstOrDefault(r => r.Id == repoId);
2654+
if (repoInfo != null)
2655+
{
2656+
var shortName = repoInfo.Name.Contains('/')
2657+
? repoInfo.Name[(repoInfo.Name.LastIndexOf('/') + 1)..]
2658+
: repoInfo.Name;
2659+
remoteName = shortName;
2660+
}
2661+
}
26502662

26512663
// Optimistic local add so the UI shows the session immediately
26522664
var remoteInfo = new AgentSessionInfo { Name = remoteName, Model = model ?? DefaultModel };
@@ -2703,7 +2715,21 @@ await _bridgeClient.CreateSessionWithWorktreeAsync(new CreateSessionWithWorktree
27032715
wt = await _repoManager.CreateWorktreeAsync(repoId, branch, null, localPath: localPath, ct: ct);
27042716
}
27052717

2718+
// Derive a friendly display name: prefer explicit sessionName, then branch name,
2719+
// but fall back to repo short name when the branch is an auto-generated session timestamp.
27062720
var name = sessionName ?? wt.Branch;
2721+
if (sessionName == null && wt.Branch.StartsWith("session-", StringComparison.Ordinal))
2722+
{
2723+
var repoInfo = _repoManager.Repositories.FirstOrDefault(r => r.Id == wt.RepoId);
2724+
if (repoInfo != null)
2725+
{
2726+
// Use last segment of repo name (e.g., "dotnet/maui" → "maui")
2727+
var shortName = repoInfo.Name.Contains('/')
2728+
? repoInfo.Name[(repoInfo.Name.LastIndexOf('/') + 1)..]
2729+
: repoInfo.Name;
2730+
name = shortName;
2731+
}
2732+
}
27072733

27082734
// Ensure unique session name
27092735
if (_sessions.ContainsKey(name))

PolyPilot/Services/RepoManager.cs

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -695,7 +695,7 @@ public virtual async Task<WorktreeInfo> CreateWorktreeAsync(string repoId, strin
695695
// Nested strategy: place worktree inside the user's repo at .polypilot/worktrees/{branch}/
696696
var repoWorktreesDir = Path.Combine(Path.GetFullPath(localPath), ".polypilot", "worktrees");
697697
Directory.CreateDirectory(repoWorktreesDir);
698-
EnsureGitIgnoreEntry(localPath, ".polypilot/");
698+
EnsureGitExcludeEntry(localPath, ".polypilot/");
699699
worktreePath = Path.Combine(repoWorktreesDir, branchName);
700700

701701
// Guard against path traversal: branch names with ".." or leading "/" could escape
@@ -747,32 +747,69 @@ public virtual async Task<WorktreeInfo> CreateWorktreeAsync(string repoId, strin
747747

748748
/// <summary>
749749
/// Ensures that <paramref name="entry"/> (e.g. <c>.polypilot/</c>) is present in the
750-
/// <c>.gitignore</c> file inside <paramref name="repoPath"/>. Creates <c>.gitignore</c>
751-
/// if it does not exist. No-op if the entry is already present.
750+
/// <c>.git/info/exclude</c> file inside <paramref name="repoPath"/>. This is a local-only
751+
/// exclusion that is never tracked by git, unlike <c>.gitignore</c>.
752+
/// Creates the file if it does not exist. No-op if the entry is already present.
752753
/// </summary>
753-
private static void EnsureGitIgnoreEntry(string repoPath, string entry)
754+
private static void EnsureGitExcludeEntry(string repoPath, string entry)
754755
{
755756
try
756757
{
757-
var gitignorePath = Path.Combine(repoPath, ".gitignore");
758-
var lines = File.Exists(gitignorePath)
759-
? File.ReadAllLines(gitignorePath)
758+
var dotGitPath = Path.Combine(repoPath, ".git");
759+
760+
// Early return if no .git file or directory exists (not a valid repo)
761+
if (!Directory.Exists(dotGitPath) && !File.Exists(dotGitPath))
762+
return;
763+
764+
var excludePath = Path.Combine(dotGitPath, "info", "exclude");
765+
var infoDir = Path.GetDirectoryName(excludePath)!;
766+
if (!Directory.Exists(infoDir))
767+
{
768+
// If .git is a file (worktree), resolve the real gitdir
769+
if (File.Exists(dotGitPath))
770+
{
771+
var firstLine = File.ReadLines(dotGitPath).FirstOrDefault()?.Trim();
772+
if (firstLine != null && firstLine.StartsWith("gitdir:", StringComparison.OrdinalIgnoreCase))
773+
{
774+
var gitdir = firstLine["gitdir:".Length..].Trim();
775+
if (!Path.IsPathRooted(gitdir))
776+
gitdir = Path.GetFullPath(Path.Combine(repoPath, gitdir));
777+
var resolvedGitdir = Path.GetFullPath(gitdir);
778+
779+
// Validate the resolved gitdir is within the repo or a legitimate .git/worktrees/ subtree
780+
var repoFull = Path.GetFullPath(repoPath) + Path.DirectorySeparatorChar;
781+
if (!resolvedGitdir.StartsWith(repoFull, StringComparison.Ordinal)
782+
&& !resolvedGitdir.Contains(Path.Combine(".git", "worktrees"), StringComparison.Ordinal))
783+
return;
784+
785+
excludePath = Path.Combine(resolvedGitdir, "info", "exclude");
786+
infoDir = Path.GetDirectoryName(excludePath)!;
787+
}
788+
else
789+
{
790+
// Malformed .git file — cannot resolve gitdir
791+
return;
792+
}
793+
}
794+
Directory.CreateDirectory(infoDir);
795+
}
796+
797+
var lines = File.Exists(excludePath)
798+
? File.ReadAllLines(excludePath)
760799
: [];
761800

762-
// Check if any existing line matches (exact or without trailing slash variant)
763801
var entryTrimmed = entry.TrimEnd('/');
764802
if (lines.Any(l => l.Trim() == entry || l.Trim() == entryTrimmed || l.Trim() == $"/{entry}" || l.Trim() == $"/{entryTrimmed}"))
765803
return;
766804

767-
// Append with a leading newline if file doesn't end with one
768-
using var sw = new StreamWriter(gitignorePath, append: true);
805+
using var sw = new StreamWriter(excludePath, append: true);
769806
if (lines.Length > 0 && !string.IsNullOrEmpty(lines[^1]))
770807
sw.WriteLine();
771808
sw.WriteLine(entry);
772809
}
773810
catch (Exception ex)
774811
{
775-
Console.WriteLine($"[RepoManager] Failed to update .gitignore: {ex.Message}");
812+
Console.WriteLine($"[RepoManager] Failed to update .git/info/exclude: {ex.Message}");
776813
}
777814
}
778815

0 commit comments

Comments
 (0)