Skip to content

remove simple git#6976

Open
ryancbahan wants to merge 2 commits intomainfrom
remove-simple-git
Open

remove simple git#6976
ryancbahan wants to merge 2 commits intomainfrom
remove-simple-git

Conversation

@ryancbahan
Copy link
Contributor

@ryancbahan ryancbahan commented Mar 11, 2026

WHY are these changes introduced?

This change removes the dependency on the simple-git library and replaces it with direct git command execution using execa. This PR is part of an assertion I believe to be true: with the rise of agentic development and parallel rise of supply chain attacks, external dependencies have become less valuable and higher risk. We should be looking at our dependencies with a critical eye and removing ones that don't have a very clear value proposition.

Using shopify app init locally should trigger code path and verify parity behavior.

Copy link
Contributor Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

@ryancbahan ryancbahan marked this pull request as ready for review March 11, 2026 14:38
@ryancbahan ryancbahan requested a review from a team as a code owner March 11, 2026 14:38
if (progressUpdater) progressUpdater(updateString)
if (!isTerminalInteractive()) {
args.push('-c', 'core.askpass=true')
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-interactive clone: -c core.askpass=true is appended after clone and won’t apply

git -c key=value must come before the subcommand. Appending -c core.askpass=true after clone args results in git clone ... -c core.askpass=true ..., which does not set process config. In CI/non-interactive runs, cloning private repos may prompt for credentials and hang instead of failing fast.

Evidence:

const args = ['clone', '--recurse-submodules']
...
if (!isTerminalInteractive()) {
  args.push('-c', 'core.askpass=true')
}
await execa('git', args)

// git check-ignore exits with code 1 when no files are ignored
if (error instanceof AbortError) return []
throw error
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

checkIfIgnoredInGitRepository: swallows all git failures as “no ignores”

gitCommand wraps any execa error into AbortError. checkIfIgnoredInGitRepository then treats any AbortError as the benign “exit code 1 => no ignored files” case, conflating real failures (not a repo, invalid paths, permissions, etc.) with “no matches” and returning [] silently.

Evidence:

} catch (error) {
  // git check-ignore exits with code 1 when no files are ignored
  if (error instanceof AbortError) return []
  throw error
}

} catch (error) {
if (error instanceof AbortError) return undefined
throw error
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getLatestTag: returns undefined on any git failure due to broad AbortError handling

getLatestTag returns undefined for any AbortError, but gitCommand wraps all git failures as AbortError. This misreports repo errors (not a git repo, permission denied, etc.) as “no tags exist”.

} catch (error) {
if (error instanceof Error && 'exitCode' in error) return false
throw error
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

insideGitDirectory: returns false for any error with an exitCode property (too broad)

The function returns false for any Error with an exitCode field, which can include missing git, permission errors, or other failures that should likely surface rather than being treated as “not a git repo”.

Code:

} catch (error) {
  if (error instanceof Error && 'exitCode' in error) return false
  throw error
}

body: lines[4]!,
author_name: lines[5]!,
author_email: lines[6]!,
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getLatestGitCommit: newline-splitting is brittle; multiline body breaks field mapping

The --format includes %b (body), which can contain arbitrary newlines. Splitting stdout on \n and indexing fixed positions causes mis-assignment of author_name/author_email when the body spans multiple lines.

Evidence:

const format = '%H%n%ai%n%s%n%D%n%b%n%an%n%ae'
const lines = stdout.split('\n')
return { body: lines[4]!, author_name: lines[5]!, author_email: lines[6]! }

@binks-code-reviewer
Copy link

🤖 Code Review · #projects-dev-ai for questions
React with 👍/👎 or reply — all feedback helps improve the agent.

Complete - 5 findings

📋 History

✅ 5 findings

@github-actions
Copy link
Contributor

We detected some changes at packages/*/src and there are no updates in the .changeset.
If the changes are user-facing, run pnpm changeset add to track your changes and include them in the next release CHANGELOG.

Caution

DO NOT create changesets for features which you do not wish to be included in the public changelog of the next CLI release.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Contributor

Differences in type declarations

We detected differences in the type declarations generated by Typescript for this branch compared to the baseline ('main' branch). Please, review them to ensure they are backward-compatible. Here are some important things to keep in mind:

  • Some seemingly private modules might be re-exported through public modules.
  • If the branch is behind main you might see odd diffs, rebase main into this branch.

New type declarations

We found no new type declarations in this PR

Existing type declarations

packages/cli-kit/dist/public/node/git.d.ts
@@ -1,5 +1,13 @@
 import { AbortError } from './error.js';
-import { DefaultLogFields, ListLogLine } from 'simple-git';
+export interface GitLogEntry {
+    hash: string;
+    date: string;
+    message: string;
+    refs: string;
+    body: string;
+    author_name: string;
+    author_email: string;
+}
 /**
  * Initialize a git repository at the given directory.
  *
@@ -64,7 +72,7 @@ export declare function downloadGitRepository(cloneOptions: GitCloneOptions): Pr
  * @param directory - The directory of the git repository.
  * @returns The latest commit of the repository.
  */
-export declare function getLatestGitCommit(directory?: string): Promise<DefaultLogFields & ListLogLine>;
+export declare function getLatestGitCommit(directory?: string): Promise<GitLogEntry>;
 /**
  * Add all files to the git index from the given directory.
  *

@github-actions
Copy link
Contributor

Coverage report

St.
Category Percentage Covered / Total
🟡 Statements 78.83% 14519/18417
🟡 Branches 73.11% 7212/9864
🟡 Functions 79.1% 3693/4669
🟡 Lines 79.17% 13723/17333

Test suite run success

3812 tests passing in 1456 suites.

Report generated by 🧪jest coverage report action from 0b402f3

Copy link
Contributor

@dmerand dmerand left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm aligned with this approach. AI pair review found some bugs worth double-checking.


Review assisted by pair-review


// Then
}).rejects.toThrowError(/Couldn't obtain the most recent tag of the repository http:\/\/repoUrl/)
}).rejects.toThrowError()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Is there a reason we can't add an error message to this check?

} catch (err) {
if (err instanceof Error) {
const abortError = new AbortError(err.message)
abortError.stack = err.stack
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐛 Bug: The gitCommand helper wraps all errors in AbortError but only preserves the message and stack properties. The exitCode property from execa errors is lost, making it impossible for calling code to distinguish between different types of git failures. This is the root cause of several error handling issues throughout the file: getLatestTag, checkIfIgnoredInGitRepository, and other functions need to check specific exit codes to differentiate expected failures (like 'no tags found') from actual errors (permission denied, git not installed).

Suggestion: Preserve the exitCode property when wrapping errors:

Suggested change
abortError.stack = err.stack
const abortError = new AbortError(err.message)
abortError.stack = err.stack
if ('exitCode' in err) {
Object.assign(abortError, {exitCode: err.exitCode})
}
throw abortError

return stdout.split('\n').filter(Boolean)
} catch (error) {
// git check-ignore exits with code 1 when no files are ignored
if (error instanceof AbortError) return []
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐛 Bug: The function catches any AbortError and returns an empty array, assuming it means 'no files are ignored'. However, since gitCommand wraps ALL errors in AbortError (including permission errors, invalid directories, or git not being installed), this will silently hide real failures. The comment at line 72 indicates the intent is to handle exit code 1 specifically, but the implementation doesn't check for this.

Suggestion: Check for the specific exit code to distinguish 'no files ignored' from actual errors. Note: This requires fixing the gitCommand helper to preserve exitCode first:

Suggested change
if (error instanceof AbortError) return []
if (error instanceof AbortError && 'exitCode' in error && error.exitCode === 1) return []

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Ref Line 152) 🐛 Bug: The progressUpdater parameter is still declared in the GitCloneOptions interface but is no longer used in the implementation (removed at line 160). This is a silent breaking change - callers can pass this parameter expecting progress updates, but it will be ignored without any error or warning. While no code in the codebase currently uses this parameter, it's still part of the public API contract.

Suggestion: Either remove the parameter from the interface to accurately reflect the implementation, or add a deprecation notice:

Suggested change
/** @deprecated Progress updates are no longer supported after removing simple-git dependency */
progressUpdater?: (statusString: string) => void

Alternatively, implement progress tracking using execa's stdio events, or remove it entirely with a migration note.

)
}
return logs.latest
const lines = stdout.split('\n')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐛 Bug: The getLatestGitCommit function splits git log output on newlines, but the commit body (%b in the format string) can contain multiple lines. This will cause the parser to incorrectly assign array indices, breaking extraction of author_name and author_email. For example, if a commit body has 3 lines, lines[5] will be the second line of the body instead of the author name.

Suggestion: Use a null byte delimiter that won't appear in commit messages:

Suggested change
const lines = stdout.split('\n')
const format = '%H%x00%ai%x00%s%x00%D%x00%b%x00%an%x00%ae'
const stdout = await gitCommand(['log', '-1', `--format=${format}`], directory)
if (!stdout.trim()) {
throw new AbortError(
'Must have at least one commit to run command',
outputContent`Run ${outputToken.genericShellCommand(
"git commit -m 'Initial commit'",
)} to create your first commit.`,
)
}
const lines = stdout.split('\x00')

await execa('git', ['rev-parse', '--git-dir'], {cwd: directory})
return true
} catch (error) {
if (error instanceof Error && 'exitCode' in error) return false
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐛 Bug: The function returns false for any error with an exitCode property, but should specifically check for exit code 128 which indicates 'not a git directory'. Other exit codes might indicate actual errors that should be thrown rather than silently treated as 'not a git repo'.

Suggestion: Check specifically for exit code 128:

Suggested change
if (error instanceof Error && 'exitCode' in error) return false
if (error instanceof Error && 'exitCode' in error && error.exitCode === 128) return false

const stdout = await gitCommand(['describe', '--tags', '--abbrev=0'], directory)
return stdout.trim() || undefined
} catch (error) {
if (error instanceof AbortError) return undefined
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐛 Bug: This function catches any AbortError and returns undefined, assuming it means 'no tags exist'. However, this will also hide real errors like permission issues, invalid directories, or git not being installed. The test at line 391 specifically creates an error with exitCode 128 and message 'fatal: No names found', but the implementation doesn't check for these specific conditions - it returns undefined for ANY AbortError.

Suggestion: Check for the specific error condition. Note: This requires fixing the gitCommand helper to preserve exitCode first:

Suggested change
if (error instanceof AbortError) return undefined
if (error instanceof AbortError && ('exitCode' in error && error.exitCode === 128 || error.message.includes('No names found'))) return undefined

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants