11using System ;
2+ using System . Collections . Generic ;
23using System . Diagnostics ;
34using System . Threading . Tasks ;
45
@@ -28,7 +29,142 @@ public static class GitService
2829 }
2930 }
3031
32+ /// <summary>
33+ /// Returns the canonical "repo identity" path — the parent of the shared .git
34+ /// directory (`git rev-parse --git-common-dir`). This is identical for every
35+ /// worktree of the same repo, so it's safe to use as a sibling-detection key.
36+ /// (`--show-toplevel` would return each worktree's own folder, missing siblings.)
37+ /// Returns null if folderPath isn't inside a repo. Forward slashes throughout
38+ /// for stable string comparison on Windows.
39+ /// </summary>
40+ public static async Task < string ? > GetRepoRootAsync ( string folderPath )
41+ {
42+ if ( string . IsNullOrWhiteSpace ( folderPath ) || ! System . IO . Directory . Exists ( folderPath ) )
43+ return null ;
44+ try
45+ {
46+ string ? commonDir = await RunGitAsync ( folderPath , "rev-parse --git-common-dir" ) ;
47+ if ( string . IsNullOrWhiteSpace ( commonDir ) ) return null ;
48+ string trimmed = commonDir . Trim ( ) ;
49+
50+ // git may return a path relative to the cwd (e.g. ".git" for a plain repo)
51+ // or an absolute path (e.g. "C:/repo/.git" when called from a worktree).
52+ // Resolve to an absolute path either way.
53+ string absolute = System . IO . Path . IsPathRooted ( trimmed )
54+ ? trimmed
55+ : System . IO . Path . GetFullPath ( trimmed , folderPath ) ;
56+
57+ // Strip the trailing ".git" segment to get the repo's working tree root.
58+ string normalized = absolute . Replace ( '\\ ' , '/' ) . TrimEnd ( '/' ) ;
59+ if ( normalized . EndsWith ( "/.git" , StringComparison . OrdinalIgnoreCase ) )
60+ normalized = normalized [ ..^ "/.git" . Length ] ;
61+ else if ( normalized . EndsWith ( ".git" , StringComparison . OrdinalIgnoreCase )
62+ && ! normalized . EndsWith ( "/.git" , StringComparison . OrdinalIgnoreCase ) )
63+ normalized = normalized [ ..^ ".git" . Length ] . TrimEnd ( '/' ) ;
64+
65+ return string . IsNullOrEmpty ( normalized ) ? null : normalized ;
66+ }
67+ catch { return null ; }
68+ }
69+
70+ /// <summary>Describes one git worktree as reported by `git worktree list --porcelain`.</summary>
71+ public record WorktreeInfo ( string Path , string ? Branch , bool IsBare , bool IsDetached , bool IsLocked , bool IsPrunable ) ;
72+
73+ /// <summary>
74+ /// Returns all worktrees (including the main one) of the repo containing
75+ /// <paramref name="folderPath"/>. Empty if the folder isn't in a repo.
76+ /// </summary>
77+ public static async Task < IReadOnlyList < WorktreeInfo > > ListWorktreesAsync ( string folderPath )
78+ {
79+ if ( string . IsNullOrWhiteSpace ( folderPath ) || ! System . IO . Directory . Exists ( folderPath ) )
80+ return Array . Empty < WorktreeInfo > ( ) ;
81+ try
82+ {
83+ string ? raw = await RunGitAsync ( folderPath , "worktree list --porcelain" ) ;
84+ if ( string . IsNullOrWhiteSpace ( raw ) ) return Array . Empty < WorktreeInfo > ( ) ;
85+
86+ // Output is blank-line separated stanzas:
87+ // worktree /path
88+ // HEAD <sha>
89+ // branch refs/heads/<name> (or "detached", "bare")
90+ // locked [reason]
91+ // prunable [reason]
92+ var results = new List < WorktreeInfo > ( ) ;
93+ string ? path = null ;
94+ string ? branch = null ;
95+ bool isBare = false , isDetached = false , isLocked = false , isPrunable = false ;
96+ void Flush ( )
97+ {
98+ if ( ! string . IsNullOrEmpty ( path ) )
99+ results . Add ( new WorktreeInfo ( path , branch , isBare , isDetached , isLocked , isPrunable ) ) ;
100+ path = null ; branch = null ; isBare = false ; isDetached = false ; isLocked = false ; isPrunable = false ;
101+ }
102+ foreach ( var line in raw . Replace ( "\r " , "" ) . Split ( '\n ' ) )
103+ {
104+ if ( string . IsNullOrEmpty ( line ) ) { Flush ( ) ; continue ; }
105+ if ( line . StartsWith ( "worktree " ) ) path = line . Substring ( "worktree " . Length ) . Trim ( ) ;
106+ else if ( line . StartsWith ( "branch " ) ) branch = line . Substring ( "branch " . Length ) . Trim ( )
107+ . Replace ( "refs/heads/" , "" , StringComparison . Ordinal ) ;
108+ else if ( line == "bare" ) isBare = true ;
109+ else if ( line == "detached" ) isDetached = true ;
110+ else if ( line . StartsWith ( "locked" ) ) isLocked = true ;
111+ else if ( line . StartsWith ( "prunable" ) ) isPrunable = true ;
112+ }
113+ Flush ( ) ;
114+ return results ;
115+ }
116+ catch { return Array . Empty < WorktreeInfo > ( ) ; }
117+ }
118+
119+ /// <summary>Returns local branch names in the repo, oldest-first by git's default order.</summary>
120+ public static async Task < IReadOnlyList < string > > ListBranchesAsync ( string folderPath )
121+ {
122+ if ( string . IsNullOrWhiteSpace ( folderPath ) || ! System . IO . Directory . Exists ( folderPath ) )
123+ return Array . Empty < string > ( ) ;
124+ try
125+ {
126+ string ? raw = await RunGitAsync ( folderPath , "for-each-ref --format=%(refname:short) refs/heads" ) ;
127+ if ( string . IsNullOrWhiteSpace ( raw ) ) return Array . Empty < string > ( ) ;
128+ var lines = raw . Replace ( "\r " , "" ) . Split ( '\n ' , StringSplitOptions . RemoveEmptyEntries ) ;
129+ return lines ;
130+ }
131+ catch { return Array . Empty < string > ( ) ; }
132+ }
133+
134+ /// <summary>
135+ /// Runs `git worktree add` either with a new branch (-b) or pointing at an
136+ /// existing ref. Returns (success, errorOutput).
137+ /// </summary>
138+ public static async Task < ( bool ok , string error ) > CreateWorktreeAsync (
139+ string repoRoot , string targetPath , string branchOrRef , bool createBranch )
140+ {
141+ if ( string . IsNullOrWhiteSpace ( repoRoot ) || ! System . IO . Directory . Exists ( repoRoot ) )
142+ return ( false , "Repo root does not exist." ) ;
143+ if ( string . IsNullOrWhiteSpace ( targetPath ) )
144+ return ( false , "Worktree path is required." ) ;
145+ if ( string . IsNullOrWhiteSpace ( branchOrRef ) )
146+ return ( false , "Branch is required." ) ;
147+
148+ string args = createBranch
149+ ? $ "worktree add -b \" { branchOrRef } \" \" { targetPath } \" "
150+ : $ "worktree add \" { targetPath } \" \" { branchOrRef } \" ";
151+
152+ var ( output , stderr , exit ) = await RunGitFullAsync ( repoRoot , args , timeoutMs : 30_000 ) ;
153+ if ( exit == 0 ) return ( true , "" ) ;
154+ string err = string . IsNullOrWhiteSpace ( stderr )
155+ ? ( string . IsNullOrWhiteSpace ( output ) ? "git worktree add failed." : output )
156+ : stderr ;
157+ return ( false , err . Trim ( ) ) ;
158+ }
159+
31160 private static async Task < string ? > RunGitAsync ( string workingDir , string arguments )
161+ {
162+ var ( stdout , _, exit ) = await RunGitFullAsync ( workingDir , arguments , timeoutMs : 3000 ) ;
163+ return exit == 0 ? stdout : null ;
164+ }
165+
166+ private static async Task < ( string stdout , string stderr , int exit ) > RunGitFullAsync (
167+ string workingDir , string arguments , int timeoutMs )
32168 {
33169 var psi = new ProcessStartInfo ( "git" )
34170 {
@@ -40,19 +176,21 @@ public static class GitService
40176 } ;
41177
42178 using var process = Process . Start ( psi ) ;
43- if ( process is null )
44- return null ;
179+ if ( process is null ) return ( "" , "" , - 1 ) ;
45180
46- var outputTask = process . StandardOutput . ReadToEndAsync ( ) ;
47- var completed = await Task . WhenAny ( outputTask , Task . Delay ( 3000 ) ) ;
181+ var outTask = process . StandardOutput . ReadToEndAsync ( ) ;
182+ var errTask = process . StandardError . ReadToEndAsync ( ) ;
183+ var bothTask = Task . WhenAll ( outTask , errTask ) ;
184+ var completed = await Task . WhenAny ( bothTask , Task . Delay ( timeoutMs ) ) ;
48185
49- if ( completed != outputTask )
186+ if ( completed != bothTask )
50187 {
51188 try { process . Kill ( ) ; } catch { }
52- return null ;
53189 }
190+ try { await process . WaitForExitAsync ( ) ; } catch { }
54191
55- await process . WaitForExitAsync ( ) ;
56- return process . ExitCode == 0 ? await outputTask : null ;
192+ string stdout = outTask . IsCompletedSuccessfully ? outTask . Result : "" ;
193+ string stderr = errTask . IsCompletedSuccessfully ? errTask . Result : "" ;
194+ return ( stdout , stderr , process . HasExited ? process . ExitCode : - 1 ) ;
57195 }
58196}
0 commit comments