11import { execFile } from "child_process" ;
2+ import { promises as fs } from "fs" ;
3+ import * as path from "path" ;
24
35interface GitRunOptions {
46 cwd : string ;
@@ -27,6 +29,63 @@ function runGit({ cwd, gitPath, args }: GitRunOptions): Promise<string> {
2729 } ) ;
2830}
2931
32+ async function ensureGitignore ( cwd : string , ignoreDir ?: string ) : Promise < void > {
33+ if ( ! ignoreDir ) return ;
34+
35+ const gitignorePath = path . join ( cwd , ".gitignore" ) ;
36+ const ignorePattern = ignoreDir . endsWith ( "/" ) ? ignoreDir : `${ ignoreDir } /` ;
37+
38+ let content = "" ;
39+ try {
40+ content = await fs . readFile ( gitignorePath , "utf8" ) ;
41+ } catch ( e ) {
42+ if ( ( e as NodeJS . ErrnoException ) . code !== "ENOENT" ) throw e ;
43+ }
44+
45+ const lines = content . split ( / \r ? \n / ) ;
46+ if ( lines . some ( ( line ) => line . trim ( ) === ignorePattern ) ) return ;
47+
48+ const eol = content . includes ( "\r\n" ) ? "\r\n" : "\n" ;
49+ const prefix = content . length > 0 && ! content . endsWith ( "\n" ) ? eol : "" ;
50+ await fs . writeFile ( gitignorePath , `${ content } ${ prefix } ${ ignorePattern } ${ eol } ` , "utf8" ) ;
51+ }
52+
53+ // Repo state detection
54+ export type RepoState =
55+ | "not-a-repo"
56+ | "empty-repo"
57+ | "local-only"
58+ | "remote-no-upstream"
59+ | "ready" ;
60+
61+ export async function detectRepoState ( cwd : string , gitPath : string ) : Promise < RepoState > {
62+ // Check if it's a git repo
63+ if ( ! ( await isGitRepo ( cwd , gitPath ) ) ) {
64+ return "not-a-repo" ;
65+ }
66+
67+ // Check if there are any commits
68+ try {
69+ await runGit ( { cwd, gitPath, args : [ "rev-parse" , "HEAD" ] } ) ;
70+ } catch {
71+ return "empty-repo" ;
72+ }
73+
74+ // Check if remote is configured
75+ const remoteUrl = await getRemoteUrl ( cwd , gitPath ) ;
76+ if ( ! remoteUrl ) {
77+ return "local-only" ;
78+ }
79+
80+ // Check if upstream is set
81+ try {
82+ await runGit ( { cwd, gitPath, args : [ "rev-parse" , "--abbrev-ref" , "@{u}" ] } ) ;
83+ return "ready" ;
84+ } catch {
85+ return "remote-no-upstream" ;
86+ }
87+ }
88+
3089export async function getChangedFiles ( cwd : string , gitPath : string ) : Promise < string [ ] > {
3190 const stdout = await runGit ( { cwd, gitPath, args : [ "status" , "--porcelain=v1" , "-z" ] } ) ;
3291 if ( ! stdout ) return [ ] ;
@@ -65,11 +124,6 @@ export async function commitAll(cwd: string, gitPath: string, message: string):
65124 }
66125}
67126
68- async function getCurrentBranch ( cwd : string , gitPath : string ) : Promise < string > {
69- const stdout = await runGit ( { cwd, gitPath, args : [ "rev-parse" , "--abbrev-ref" , "HEAD" ] } ) ;
70- return stdout . trim ( ) ;
71- }
72-
73127export async function push ( cwd : string , gitPath : string ) : Promise < void > {
74128 const branch = await getCurrentBranch ( cwd , gitPath ) ;
75129 await runGit ( { cwd, gitPath, args : [ "push" , "-u" , "origin" , branch ] } ) ;
@@ -79,13 +133,32 @@ export interface PullResult {
79133 success : boolean ;
80134 hasConflicts : boolean ;
81135 message : string ;
136+ notReady ?: boolean ;
82137}
83138
84139export async function pull ( cwd : string , gitPath : string ) : Promise < PullResult > {
140+ // Check repo state first
141+ const state = await detectRepoState ( cwd , gitPath ) ;
142+ if ( state !== "ready" ) {
143+ return {
144+ success : false ,
145+ hasConflicts : false ,
146+ message : `Repository not ready: ${ state } ` ,
147+ notReady : true
148+ } ;
149+ }
150+
85151 try {
86- const branch = await getCurrentBranch ( cwd , gitPath ) ;
87- const stdout = await runGit ( { cwd, gitPath, args : [ "pull" , "origin" , branch ] } ) ;
88- return { success : true , hasConflicts : false , message : stdout } ;
152+ // Try to use upstream first
153+ try {
154+ const stdout = await runGit ( { cwd, gitPath, args : [ "pull" ] } ) ;
155+ return { success : true , hasConflicts : false , message : stdout } ;
156+ } catch {
157+ // Fallback to explicit origin/branch
158+ const branch = await getCurrentBranch ( cwd , gitPath ) ;
159+ const stdout = await runGit ( { cwd, gitPath, args : [ "pull" , "origin" , branch ] } ) ;
160+ return { success : true , hasConflicts : false , message : stdout } ;
161+ }
89162 } catch ( e ) {
90163 const msg = ( e as Error ) . message ;
91164 if ( msg . includes ( "CONFLICT" ) || msg . includes ( "Merge conflict" ) ) {
@@ -194,3 +267,126 @@ export async function getFileStatuses(cwd: string, gitPath: string): Promise<Map
194267
195268 return statusMap ;
196269}
270+
271+ // Get remote default branch (main/master)
272+ export async function getRemoteDefaultBranch ( cwd : string , gitPath : string ) : Promise < string | null > {
273+ try {
274+ // Try to get from remote HEAD
275+ const stdout = await runGit ( { cwd, gitPath, args : [ "remote" , "show" , "origin" ] } ) ;
276+ const match = stdout . match ( / H E A D b r a n c h : \s * ( \S + ) / ) ;
277+ if ( match && match [ 1 ] !== "(unknown)" ) return match [ 1 ] ;
278+ } catch {
279+ // Fallback: try common branch names
280+ }
281+
282+ // Try to find main or master in remote refs
283+ try {
284+ const refs = await runGit ( { cwd, gitPath, args : [ "ls-remote" , "--heads" , "origin" ] } ) ;
285+ if ( ! refs . trim ( ) ) return null ; // Empty remote
286+ if ( refs . includes ( "refs/heads/main" ) ) return "main" ;
287+ if ( refs . includes ( "refs/heads/master" ) ) return "master" ;
288+ // Return first branch found
289+ const match = refs . match ( / r e f s \/ h e a d s \/ ( \S + ) / ) ;
290+ if ( match ) return match [ 1 ] ;
291+ } catch {
292+ // Ignore
293+ }
294+
295+ return null ; // Remote is empty or unreachable
296+ }
297+
298+ // Initialize repo with first commit and push to empty remote
299+ export async function initAndPush ( cwd : string , gitPath : string , url : string , branch : string = "main" , ignoreDir ?: string ) : Promise < void > {
300+ // Initialize with branch name
301+ await runGit ( { cwd, gitPath, args : [ "init" , "-b" , branch ] } ) ;
302+
303+ // Ensure .gitignore excludes config dir if specified
304+ await ensureGitignore ( cwd , ignoreDir ) ;
305+
306+ // Add all files
307+ await runGit ( { cwd, gitPath, args : [ "add" , "-A" ] } ) ;
308+
309+ // Create initial commit
310+ await runGit ( { cwd, gitPath, args : [ "commit" , "-m" , "Initial commit" ] } ) ;
311+
312+ // Add remote
313+ await runGit ( { cwd, gitPath, args : [ "remote" , "add" , "origin" , url ] } ) ;
314+
315+ // Push with upstream
316+ await runGit ( { cwd, gitPath, args : [ "push" , "-u" , "origin" , branch ] } ) ;
317+ }
318+
319+ // Connect to existing remote repo (fetch and checkout)
320+ export async function connectToRemote ( cwd : string , gitPath : string , url : string , ignoreDir ?: string ) : Promise < { branch : string } > {
321+ // Initialize if needed
322+ if ( ! ( await isGitRepo ( cwd , gitPath ) ) ) {
323+ await runGit ( { cwd, gitPath, args : [ "init" , "-b" , "main" ] } ) ;
324+ }
325+
326+ // Add remote
327+ const currentUrl = await getRemoteUrl ( cwd , gitPath ) ;
328+ if ( ! currentUrl ) {
329+ await runGit ( { cwd, gitPath, args : [ "remote" , "add" , "origin" , url ] } ) ;
330+ } else if ( currentUrl !== url ) {
331+ await runGit ( { cwd, gitPath, args : [ "remote" , "set-url" , "origin" , url ] } ) ;
332+ }
333+
334+ // Fetch remote
335+ await runGit ( { cwd, gitPath, args : [ "fetch" , "origin" ] } ) ;
336+
337+ // Get remote default branch (null if remote is empty)
338+ const remoteBranch = await getRemoteDefaultBranch ( cwd , gitPath ) ;
339+
340+ if ( ! remoteBranch ) {
341+ // Remote is empty - create initial commit and push
342+ await ensureGitignore ( cwd , ignoreDir ) ;
343+ await runGit ( { cwd, gitPath, args : [ "add" , "-A" ] } ) ;
344+ try {
345+ await runGit ( { cwd, gitPath, args : [ "commit" , "-m" , "Initial commit" ] } ) ;
346+ } catch ( e ) {
347+ // Might fail if no files to commit, that's ok
348+ const msg = ( e as Error ) . message ;
349+ if ( ! msg . includes ( "nothing to commit" ) ) {
350+ throw e ;
351+ }
352+ }
353+ await runGit ( { cwd, gitPath, args : [ "push" , "-u" , "origin" , "main" ] } ) ;
354+ return { branch : "main" } ;
355+ }
356+
357+ // Remote has content - check if we have local commits
358+ let hasLocalCommits = false ;
359+ try {
360+ await runGit ( { cwd, gitPath, args : [ "rev-parse" , "HEAD" ] } ) ;
361+ hasLocalCommits = true ;
362+ } catch {
363+ hasLocalCommits = false ;
364+ }
365+
366+ if ( ! hasLocalCommits ) {
367+ // No local commits: checkout remote branch directly
368+ await runGit ( { cwd, gitPath, args : [ "checkout" , "-b" , remoteBranch , `origin/${ remoteBranch } ` ] } ) ;
369+ } else {
370+ // Has local commits: rename branch and set upstream
371+ try {
372+ await runGit ( { cwd, gitPath, args : [ "branch" , `-M` , remoteBranch ] } ) ;
373+ } catch {
374+ // Branch might already be named correctly
375+ }
376+ await runGit ( { cwd, gitPath, args : [ "branch" , `--set-upstream-to=origin/${ remoteBranch } ` , remoteBranch ] } ) ;
377+ }
378+
379+ return { branch : remoteBranch } ;
380+ }
381+
382+ // Set upstream for current branch
383+ export async function setUpstream ( cwd : string , gitPath : string ) : Promise < void > {
384+ const branch = await getCurrentBranch ( cwd , gitPath ) ;
385+ await runGit ( { cwd, gitPath, args : [ "push" , "-u" , "origin" , branch ] } ) ;
386+ }
387+
388+ // Get current branch name
389+ async function getCurrentBranch ( cwd : string , gitPath : string ) : Promise < string > {
390+ const stdout = await runGit ( { cwd, gitPath, args : [ "rev-parse" , "--abbrev-ref" , "HEAD" ] } ) ;
391+ return stdout . trim ( ) ;
392+ }
0 commit comments