Skip to content

Commit e4314b3

Browse files
Tyrie VellaCopilot
andcommitted
tests: add worktree unit and functional tests
Unit tests: WorktreeInfoTests — TryGetWorktreeInfo detection, pipe suffix WorktreeEnlistmentTests — CreateForWorktree path mappings WorktreeCommandParserTests — subcommand and arg extraction Functional tests: WorktreeTests — end-to-end add/list/remove with live GVFS mount GitBlockCommandsTests — update existing test for conditional block Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 9d5c825 commit e4314b3

5 files changed

Lines changed: 615 additions & 1 deletion

File tree

GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/GitBlockCommandsTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ public void GitBlockCommands()
2323
this.CommandBlocked("update-index --skip-worktree");
2424
this.CommandBlocked("update-index --no-skip-worktree");
2525
this.CommandBlocked("update-index --split-index");
26-
this.CommandBlocked("worktree list");
26+
this.CommandNotBlocked("worktree list");
2727
}
2828

2929
private void CommandBlocked(string command)
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
using GVFS.FunctionalTests.Tools;
2+
using GVFS.Tests.Should;
3+
using NUnit.Framework;
4+
using System;
5+
using System.Diagnostics;
6+
using System.IO;
7+
8+
namespace GVFS.FunctionalTests.Tests.EnlistmentPerFixture
9+
{
10+
[TestFixture]
11+
[Category(Categories.GitCommands)]
12+
public class WorktreeTests : TestsWithEnlistmentPerFixture
13+
{
14+
private const string WorktreeBranch = "worktree-test-branch";
15+
16+
[TestCase]
17+
public void WorktreeAddRemoveCycle()
18+
{
19+
string worktreePath = Path.Combine(this.Enlistment.EnlistmentRoot, "test-wt-" + Guid.NewGuid().ToString("N").Substring(0, 8));
20+
21+
try
22+
{
23+
// 1. Create worktree
24+
ProcessResult addResult = GitHelpers.InvokeGitAgainstGVFSRepo(
25+
this.Enlistment.RepoRoot,
26+
$"worktree add -b {WorktreeBranch} \"{worktreePath}\"");
27+
addResult.ExitCode.ShouldEqual(0, $"worktree add failed: {addResult.Errors}");
28+
29+
// 2. Verify directory exists with projected files
30+
Directory.Exists(worktreePath).ShouldBeTrue("Worktree directory should exist");
31+
File.Exists(Path.Combine(worktreePath, "Readme.md")).ShouldBeTrue("Readme.md should be projected");
32+
33+
string readmeContent = File.ReadAllText(Path.Combine(worktreePath, "Readme.md"));
34+
readmeContent.ShouldContain(
35+
expectedSubstrings: new[] { "GVFS" });
36+
37+
// 3. Verify git status is clean
38+
ProcessResult statusResult = GitHelpers.InvokeGitAgainstGVFSRepo(
39+
worktreePath,
40+
"status --porcelain");
41+
statusResult.ExitCode.ShouldEqual(0, $"git status failed: {statusResult.Errors}");
42+
statusResult.Output.Trim().ShouldBeEmpty("Worktree should have clean status");
43+
44+
// 4. Verify worktree list shows both
45+
ProcessResult listResult = GitHelpers.InvokeGitAgainstGVFSRepo(
46+
this.Enlistment.RepoRoot,
47+
"worktree list");
48+
listResult.ExitCode.ShouldEqual(0, $"worktree list failed: {listResult.Errors}");
49+
string listOutput = listResult.Output;
50+
string repoRootGitFormat = this.Enlistment.RepoRoot.Replace('\\', '/');
51+
string worktreePathGitFormat = worktreePath.Replace('\\', '/');
52+
Assert.IsTrue(
53+
listOutput.Contains(repoRootGitFormat),
54+
$"worktree list should contain repo root. Output: {listOutput}");
55+
Assert.IsTrue(
56+
listOutput.Contains(worktreePathGitFormat),
57+
$"worktree list should contain worktree path. Output: {listOutput}");
58+
59+
// 5. Make a change in the worktree, commit on the branch
60+
string testFile = Path.Combine(worktreePath, "worktree-test.txt");
61+
File.WriteAllText(testFile, "created in worktree");
62+
63+
ProcessResult addFile = GitHelpers.InvokeGitAgainstGVFSRepo(
64+
worktreePath, "add worktree-test.txt");
65+
addFile.ExitCode.ShouldEqual(0, $"git add failed: {addFile.Errors}");
66+
67+
ProcessResult commit = GitHelpers.InvokeGitAgainstGVFSRepo(
68+
worktreePath, "commit -m \"test commit from worktree\"");
69+
commit.ExitCode.ShouldEqual(0, $"git commit failed: {commit.Errors}");
70+
71+
// 6. Remove without --force should fail with helpful message
72+
ProcessResult removeNoForce = GitHelpers.InvokeGitAgainstGVFSRepo(
73+
this.Enlistment.RepoRoot,
74+
$"worktree remove \"{worktreePath}\"");
75+
removeNoForce.ExitCode.ShouldNotEqual(0, "worktree remove without --force should fail");
76+
removeNoForce.Errors.ShouldContain(
77+
expectedSubstrings: new[] { "--force" });
78+
79+
// Worktree should still be intact after failed remove
80+
File.Exists(Path.Combine(worktreePath, "Readme.md")).ShouldBeTrue("Files should still be projected after failed remove");
81+
82+
// 6. Remove with --force should succeed
83+
ProcessResult removeResult = GitHelpers.InvokeGitAgainstGVFSRepo(
84+
this.Enlistment.RepoRoot,
85+
$"worktree remove --force \"{worktreePath}\"");
86+
removeResult.ExitCode.ShouldEqual(0, $"worktree remove --force failed: {removeResult.Errors}");
87+
88+
// 7. Verify cleanup
89+
Directory.Exists(worktreePath).ShouldBeFalse("Worktree directory should be deleted");
90+
91+
ProcessResult listAfter = GitHelpers.InvokeGitAgainstGVFSRepo(
92+
this.Enlistment.RepoRoot,
93+
"worktree list");
94+
listAfter.Output.ShouldNotContain(
95+
ignoreCase: false,
96+
unexpectedSubstrings: new[] { worktreePathGitFormat });
97+
98+
// 8. Verify commit from worktree is accessible from main enlistment
99+
ProcessResult logFromMain = GitHelpers.InvokeGitAgainstGVFSRepo(
100+
this.Enlistment.RepoRoot,
101+
$"log -1 --format=%s {WorktreeBranch}");
102+
logFromMain.ExitCode.ShouldEqual(0, $"git log from main failed: {logFromMain.Errors}");
103+
logFromMain.Output.ShouldContain(
104+
expectedSubstrings: new[] { "test commit from worktree" });
105+
}
106+
finally
107+
{
108+
this.ForceCleanupWorktree(worktreePath);
109+
}
110+
}
111+
112+
private void ForceCleanupWorktree(string worktreePath)
113+
{
114+
// Best-effort cleanup for test failure cases
115+
try
116+
{
117+
GitHelpers.InvokeGitAgainstGVFSRepo(
118+
this.Enlistment.RepoRoot,
119+
$"worktree remove --force \"{worktreePath}\"");
120+
}
121+
catch
122+
{
123+
}
124+
125+
if (Directory.Exists(worktreePath))
126+
{
127+
try
128+
{
129+
// Kill any stuck GVFS.Mount for this worktree
130+
foreach (Process p in Process.GetProcessesByName("GVFS.Mount"))
131+
{
132+
try
133+
{
134+
if (p.StartInfo.Arguments?.Contains(worktreePath) == true)
135+
{
136+
p.Kill();
137+
}
138+
}
139+
catch
140+
{
141+
}
142+
}
143+
144+
Directory.Delete(worktreePath, recursive: true);
145+
}
146+
catch
147+
{
148+
}
149+
}
150+
151+
// Clean up branch
152+
try
153+
{
154+
GitHelpers.InvokeGitAgainstGVFSRepo(
155+
this.Enlistment.RepoRoot,
156+
$"branch -D {WorktreeBranch}");
157+
}
158+
catch
159+
{
160+
}
161+
}
162+
}
163+
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
using GVFS.Common;
2+
using GVFS.Tests.Should;
3+
using NUnit.Framework;
4+
5+
namespace GVFS.UnitTests.Common
6+
{
7+
[TestFixture]
8+
public class WorktreeCommandParserTests
9+
{
10+
[TestCase]
11+
public void GetSubcommandReturnsAdd()
12+
{
13+
string[] args = { "post-command", "worktree", "add", "-b", "branch", @"C:\wt" };
14+
WorktreeCommandParser.GetSubcommand(args).ShouldEqual("add");
15+
}
16+
17+
[TestCase]
18+
public void GetSubcommandReturnsRemove()
19+
{
20+
string[] args = { "pre-command", "worktree", "remove", @"C:\wt" };
21+
WorktreeCommandParser.GetSubcommand(args).ShouldEqual("remove");
22+
}
23+
24+
[TestCase]
25+
public void GetSubcommandSkipsLeadingDoubleHyphenArgs()
26+
{
27+
string[] args = { "post-command", "worktree", "--git-pid=1234", "add", @"C:\wt" };
28+
WorktreeCommandParser.GetSubcommand(args).ShouldEqual("add");
29+
}
30+
31+
[TestCase]
32+
public void GetSubcommandReturnsNullWhenNoSubcommand()
33+
{
34+
string[] args = { "post-command", "worktree" };
35+
WorktreeCommandParser.GetSubcommand(args).ShouldBeNull();
36+
}
37+
38+
[TestCase]
39+
public void GetSubcommandNormalizesToLowercase()
40+
{
41+
string[] args = { "post-command", "worktree", "Add" };
42+
WorktreeCommandParser.GetSubcommand(args).ShouldEqual("add");
43+
}
44+
45+
[TestCase]
46+
public void GetPathArgExtractsPathFromAddWithBranch()
47+
{
48+
// git worktree add -b branch C:\worktree
49+
string[] args = { "post-command", "worktree", "add", "-b", "my-branch", @"C:\repos\wt", "--git-pid=123", "--exit_code=0" };
50+
WorktreeCommandParser.GetPathArg(args).ShouldEqual(@"C:\repos\wt");
51+
}
52+
53+
[TestCase]
54+
public void GetPathArgExtractsPathFromAddWithoutBranch()
55+
{
56+
// git worktree add C:\worktree
57+
string[] args = { "post-command", "worktree", "add", @"C:\repos\wt", "--git-pid=123", "--exit_code=0" };
58+
WorktreeCommandParser.GetPathArg(args).ShouldEqual(@"C:\repos\wt");
59+
}
60+
61+
[TestCase]
62+
public void GetPathArgExtractsPathFromRemove()
63+
{
64+
string[] args = { "pre-command", "worktree", "remove", @"C:\repos\wt", "--git-pid=456" };
65+
WorktreeCommandParser.GetPathArg(args).ShouldEqual(@"C:\repos\wt");
66+
}
67+
68+
[TestCase]
69+
public void GetPathArgExtractsPathFromRemoveWithForce()
70+
{
71+
string[] args = { "pre-command", "worktree", "remove", "--force", @"C:\repos\wt" };
72+
WorktreeCommandParser.GetPathArg(args).ShouldEqual(@"C:\repos\wt");
73+
}
74+
75+
[TestCase]
76+
public void GetPathArgSkipsBranchNameAfterDashB()
77+
{
78+
// -b takes a value — the path is the arg AFTER the branch name
79+
string[] args = { "post-command", "worktree", "add", "-b", "feature", @"C:\repos\feature" };
80+
WorktreeCommandParser.GetPathArg(args).ShouldEqual(@"C:\repos\feature");
81+
}
82+
83+
[TestCase]
84+
public void GetPathArgSkipsBranchNameAfterDashCapitalB()
85+
{
86+
string[] args = { "post-command", "worktree", "add", "-B", "feature", @"C:\repos\feature" };
87+
WorktreeCommandParser.GetPathArg(args).ShouldEqual(@"C:\repos\feature");
88+
}
89+
90+
[TestCase]
91+
public void GetPathArgSkipsAllOptionFlags()
92+
{
93+
// -f, -d, -q, --detach, --checkout, --lock, --no-checkout
94+
string[] args = { "post-command", "worktree", "add", "-f", "--no-checkout", "--lock", "--reason", "testing", @"C:\repos\wt" };
95+
WorktreeCommandParser.GetPathArg(args).ShouldEqual(@"C:\repos\wt");
96+
}
97+
98+
[TestCase]
99+
public void GetPathArgHandlesSeparator()
100+
{
101+
// After --, everything is positional
102+
string[] args = { "post-command", "worktree", "add", "--", @"C:\repos\wt" };
103+
WorktreeCommandParser.GetPathArg(args).ShouldEqual(@"C:\repos\wt");
104+
}
105+
106+
[TestCase]
107+
public void GetPathArgSkipsGitPidAndExitCode()
108+
{
109+
string[] args = { "post-command", "worktree", "add", @"C:\wt", "--git-pid=99", "--exit_code=0" };
110+
WorktreeCommandParser.GetPathArg(args).ShouldEqual(@"C:\wt");
111+
}
112+
113+
[TestCase]
114+
public void GetPathArgReturnsNullWhenNoPath()
115+
{
116+
string[] args = { "post-command", "worktree", "list" };
117+
WorktreeCommandParser.GetPathArg(args).ShouldBeNull();
118+
}
119+
120+
[TestCase]
121+
public void GetPositionalArgReturnsSecondPositional()
122+
{
123+
// git worktree move <worktree> <new-path>
124+
string[] args = { "post-command", "worktree", "move", @"C:\old", @"C:\new" };
125+
WorktreeCommandParser.GetPositionalArg(args, 0).ShouldEqual(@"C:\old");
126+
WorktreeCommandParser.GetPositionalArg(args, 1).ShouldEqual(@"C:\new");
127+
}
128+
129+
[TestCase]
130+
public void GetPositionalArgReturnsNullForOutOfRangeIndex()
131+
{
132+
string[] args = { "post-command", "worktree", "remove", @"C:\wt" };
133+
WorktreeCommandParser.GetPositionalArg(args, 1).ShouldBeNull();
134+
}
135+
136+
[TestCase]
137+
public void GetPathArgHandlesShortArgs()
138+
{
139+
// Ensure single-char flags without values are skipped
140+
string[] args = { "post-command", "worktree", "add", "-f", "-q", @"C:\repos\wt" };
141+
WorktreeCommandParser.GetPathArg(args).ShouldEqual(@"C:\repos\wt");
142+
}
143+
}
144+
}

0 commit comments

Comments
 (0)