git-chopstick-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/examples/get-status.ts +84 -0
- package/package.json +20 -0
- package/src/git/add.ts +16 -0
- package/src/git/apply.ts +154 -0
- package/src/git/authentication.ts +19 -0
- package/src/git/branch.ts +206 -0
- package/src/git/checkout-index.ts +40 -0
- package/src/git/checkout.ts +235 -0
- package/src/git/cherry-pick.ts +504 -0
- package/src/git/clean.ts +9 -0
- package/src/git/clone.ts +86 -0
- package/src/git/coerce-to-buffer.ts +4 -0
- package/src/git/coerce-to-string.ts +4 -0
- package/src/git/commit.ts +136 -0
- package/src/git/config.ts +392 -0
- package/src/git/core.ts +625 -0
- package/src/git/create-tail-stream.ts +36 -0
- package/src/git/credential.ts +83 -0
- package/src/git/description.ts +33 -0
- package/src/git/diff-check.ts +27 -0
- package/src/git/diff-index.ts +116 -0
- package/src/git/diff.ts +880 -0
- package/src/git/environment.ts +116 -0
- package/src/git/exec.ts +285 -0
- package/src/git/fetch.ts +141 -0
- package/src/git/for-each-ref.ts +160 -0
- package/src/git/format-patch.ts +17 -0
- package/src/git/git-delimiter-parser.ts +95 -0
- package/src/git/gitignore.ts +157 -0
- package/src/git/index.ts +36 -0
- package/src/git/init.ts +11 -0
- package/src/git/interpret-trailers.ts +176 -0
- package/src/git/lfs.ts +100 -0
- package/src/git/log.ts +376 -0
- package/src/git/merge-tree.ts +42 -0
- package/src/git/merge.ts +154 -0
- package/src/git/multi-operation-terminal-output.ts +68 -0
- package/src/git/pull.ts +130 -0
- package/src/git/push-terminal-chunk.ts +41 -0
- package/src/git/push.ts +119 -0
- package/src/git/rebase.ts +627 -0
- package/src/git/reflog.ts +127 -0
- package/src/git/refs.ts +63 -0
- package/src/git/remote.ts +143 -0
- package/src/git/reorder.ts +153 -0
- package/src/git/reset.ts +101 -0
- package/src/git/rev-list.ts +201 -0
- package/src/git/rev-parse.ts +92 -0
- package/src/git/revert.ts +55 -0
- package/src/git/rm.ts +31 -0
- package/src/git/show.ts +88 -0
- package/src/git/spawn.ts +38 -0
- package/src/git/squash.ts +173 -0
- package/src/git/stage.ts +97 -0
- package/src/git/stash.ts +302 -0
- package/src/git/status.ts +502 -0
- package/src/git/submodule.ts +212 -0
- package/src/git/tag.ts +134 -0
- package/src/git/update-index.ts +169 -0
- package/src/git/update-ref.ts +50 -0
- package/src/git/var.ts +42 -0
- package/src/git/worktree-include.ts +146 -0
- package/src/git/worktree.ts +219 -0
- package/src/lib/api.ts +7 -0
- package/src/lib/diff-parser.ts +249 -0
- package/src/lib/directory-exists.ts +10 -0
- package/src/lib/errno-exception.ts +12 -0
- package/src/lib/fatal-error.ts +23 -0
- package/src/lib/feature-flag.ts +29 -0
- package/src/lib/file-system.ts +7 -0
- package/src/lib/get-old-path.ts +11 -0
- package/src/lib/git/environment.ts +14 -0
- package/src/lib/git-perf.ts +3 -0
- package/src/lib/helpers/default-branch.ts +3 -0
- package/src/lib/helpers/path.ts +5 -0
- package/src/lib/hooks/with-hooks-env.ts +7 -0
- package/src/lib/merge.ts +3 -0
- package/src/lib/noop.ts +1 -0
- package/src/lib/patch-formatter.ts +18 -0
- package/src/lib/path-exists.ts +7 -0
- package/src/lib/progress/from-process.ts +10 -0
- package/src/lib/progress/index.ts +43 -0
- package/src/lib/progress/revert.ts +17 -0
- package/src/lib/rebase.ts +3 -0
- package/src/lib/remove-remote-prefix.ts +4 -0
- package/src/lib/resolve-git-proxy.ts +3 -0
- package/src/lib/round.ts +4 -0
- package/src/lib/split-buffer.ts +14 -0
- package/src/lib/status-parser.ts +188 -0
- package/src/lib/stores/helpers/find-default-remote.ts +3 -0
- package/src/lib/trampoline/trampoline-environment.ts +8 -0
- package/src/models/branch.ts +78 -0
- package/src/models/cherry-pick.ts +12 -0
- package/src/models/clone-options.ts +6 -0
- package/src/models/commit-identity.ts +35 -0
- package/src/models/commit.ts +44 -0
- package/src/models/computed-action.ts +6 -0
- package/src/models/diff/diff-data.ts +78 -0
- package/src/models/diff/diff-line.ts +36 -0
- package/src/models/diff/diff-selection.ts +165 -0
- package/src/models/diff/image-diff.ts +6 -0
- package/src/models/diff/image.ts +8 -0
- package/src/models/diff/index.ts +6 -0
- package/src/models/diff/raw-diff.ts +41 -0
- package/src/models/git-author.ts +16 -0
- package/src/models/manual-conflict-resolution.ts +4 -0
- package/src/models/merge.ts +6 -0
- package/src/models/multi-commit-operation.ts +6 -0
- package/src/models/progress.ts +67 -0
- package/src/models/rebase.ts +20 -0
- package/src/models/remote.ts +10 -0
- package/src/models/repository.ts +16 -0
- package/src/models/stash-entry.ts +25 -0
- package/src/models/status.ts +275 -0
- package/src/models/submodule.ts +13 -0
- package/src/models/worktree.ts +11 -0
- package/tsconfig.json +17 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 git-chopstick
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# git-chopstick-core
|
|
2
|
+
|
|
3
|
+
A standalone Git backend library extracted from [GitHub Desktop](https://github.com/desktop/desktop). Provides TypeScript-first wrappers around the Git CLI for repository operations.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **60+ Git command wrappers** — status, diff, log, branch, commit, merge, rebase, stash, worktree, fetch, push, pull, cherry-pick, and more
|
|
8
|
+
- **Full dugite replacement** — `exec.ts` uses direct `child_process.spawn('git', ...)` instead of the dugite npm package
|
|
9
|
+
- **Pure CLI-based** — works with the user's installed Git, no native bindings
|
|
10
|
+
- **TypeScript-first** — complete type definitions for all Git models and operations
|
|
11
|
+
- **Zero Electron dependencies** — can be used in Node.js or Bun
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
### As a local dependency
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
import { Repository } from 'git-chopstick-core/src/models/repository.js'
|
|
19
|
+
import { getStatus } from 'git-chopstick-core/src/git/status.js'
|
|
20
|
+
|
|
21
|
+
const repo = new Repository('/path/to/repo', 1)
|
|
22
|
+
const status = await getStatus(repo)
|
|
23
|
+
|
|
24
|
+
console.log(`Branch: ${status.currentBranch}`)
|
|
25
|
+
console.log(`Changed files: ${status.workingDirectory.files.length}`)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Install via `file:` dependency in your `package.json`:
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"git-chopstick-core": "file:../path/to/git-core"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### From within this repo
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npx tsx examples/get-status.ts /path/to/repo
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Architecture
|
|
44
|
+
|
|
45
|
+
The library is structured into three layers:
|
|
46
|
+
|
|
47
|
+
| Layer | Directory | Purpose |
|
|
48
|
+
|-------|-----------|---------|
|
|
49
|
+
| **Git commands** | `src/git/` | 61 files wrapping individual `git` subcommands — `status.ts`, `log.ts`, `diff.ts`, `branch.ts`, `merge.ts`, etc. |
|
|
50
|
+
| **Domain models** | `src/models/` | 19 type definition files — `Commit`, `Branch`, `Repository`, `IStatusResult`, diff types, etc. |
|
|
51
|
+
| **Utilities** | `src/lib/` | 25 files — diff parser, status parser, progress reporting stubs, trampoline, hooks, etc. |
|
|
52
|
+
|
|
53
|
+
### Key file: `src/git/exec.ts`
|
|
54
|
+
|
|
55
|
+
This is the core dugite replacement (~250 lines). It implements:
|
|
56
|
+
- `exec()` — spawns `git` via `child_process.spawn`, returns `{ stdout, stderr, exitCode }`
|
|
57
|
+
- `spawnGit()` — stream-based variant for long-running operations
|
|
58
|
+
- `GitError` enum — 50+ typed Git error codes
|
|
59
|
+
- `parseError()` — maps stderr output to typed errors
|
|
60
|
+
|
|
61
|
+
## Dependencies
|
|
62
|
+
|
|
63
|
+
| Package | Purpose |
|
|
64
|
+
|---------|---------|
|
|
65
|
+
| `byline` | Line-by-line stream reading |
|
|
66
|
+
| `ignore` | `.gitignore` pattern matching |
|
|
67
|
+
| `memoize-one` | Memoization for remote fetching |
|
|
68
|
+
|
|
69
|
+
## License
|
|
70
|
+
|
|
71
|
+
MIT — extracted from [GitHub Desktop](https://github.com/desktop/desktop) (MIT licensed).
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Example: Open a repo and show its current status.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* bun examples/get-status.ts /path/to/some/repo
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Repository } from '../src/models/repository'
|
|
9
|
+
import { getStatus } from '../src/git/status'
|
|
10
|
+
|
|
11
|
+
async function main() {
|
|
12
|
+
const repoPath = process.argv[2]
|
|
13
|
+
if (!repoPath) {
|
|
14
|
+
console.error('Usage: bun examples/get-status.ts <path-to-repo>')
|
|
15
|
+
process.exit(1)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const repo = new Repository(repoPath, 1)
|
|
19
|
+
|
|
20
|
+
console.log(`\n📂 Repository: ${repo.name}`)
|
|
21
|
+
console.log(` Path: ${repo.path}\n`)
|
|
22
|
+
|
|
23
|
+
const status = await getStatus(repo)
|
|
24
|
+
|
|
25
|
+
if (!status) {
|
|
26
|
+
console.log('❌ Could not read repository status (not a git repo?)')
|
|
27
|
+
process.exit(1)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Branch info
|
|
31
|
+
console.log(`🌿 Branch: ${status.currentBranch ?? '(detached HEAD)'}`)
|
|
32
|
+
if (status.currentUpstreamBranch) {
|
|
33
|
+
console.log(` Upstream: ${status.currentUpstreamBranch}`)
|
|
34
|
+
}
|
|
35
|
+
if (status.branchAheadBehind) {
|
|
36
|
+
const { ahead, behind } = status.branchAheadBehind
|
|
37
|
+
console.log(` Ahead: ${ahead} | Behind: ${behind}`)
|
|
38
|
+
}
|
|
39
|
+
console.log(` Tip: ${status.currentTip?.slice(0, 7) ?? 'N/A'}`)
|
|
40
|
+
|
|
41
|
+
// Conflicted files
|
|
42
|
+
if (status.doConflictedFilesExist) {
|
|
43
|
+
console.log(`\n⚠️ Conflicts detected!`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Working directory changes
|
|
47
|
+
const files = status.workingDirectory.files
|
|
48
|
+
console.log(`\n📝 Changed files: ${files.length}`)
|
|
49
|
+
|
|
50
|
+
// Group by status kind
|
|
51
|
+
const byKind = new Map<string, string[]>()
|
|
52
|
+
for (const file of files) {
|
|
53
|
+
const kind = file.status.kind
|
|
54
|
+
const list = byKind.get(kind) ?? []
|
|
55
|
+
list.push(file.status.kind === 'Renamed' || file.status.kind === 'Copied'
|
|
56
|
+
? `${file.path} (was: ${file.status.oldPath})`
|
|
57
|
+
: file.path)
|
|
58
|
+
byKind.set(kind, list)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const [kind, paths] of byKind) {
|
|
62
|
+
console.log(` ${kind}:`)
|
|
63
|
+
for (const p of paths) {
|
|
64
|
+
console.log(` - ${p}`)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Merge/rebase state
|
|
69
|
+
if (status.mergeHeadFound) {
|
|
70
|
+
console.log(`\n🔀 Merge in progress`)
|
|
71
|
+
}
|
|
72
|
+
if (status.rebaseInternalState) {
|
|
73
|
+
console.log(`\n🔄 Rebase in progress`)
|
|
74
|
+
console.log(` Target: ${status.rebaseInternalState.targetBranch}`)
|
|
75
|
+
}
|
|
76
|
+
if (status.isCherryPickingHeadFound) {
|
|
77
|
+
console.log(`\n🍒 Cherry-pick in progress`)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
main().catch(err => {
|
|
82
|
+
console.error('Error:', err)
|
|
83
|
+
process.exit(1)
|
|
84
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "git-chopstick-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Git backend library extracted from GitHub Desktop",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"typecheck": "tsc --noEmit"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"byline": "^5.0.0",
|
|
12
|
+
"ignore": "^7.0.5",
|
|
13
|
+
"memoize-one": "^6.0.0"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/byline": "^4.2.36",
|
|
17
|
+
"@types/node": "^22.19.20",
|
|
18
|
+
"typescript": "^5.9.3"
|
|
19
|
+
}
|
|
20
|
+
}
|
package/src/git/add.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { git } from './core'
|
|
2
|
+
import { Repository } from '../models/repository'
|
|
3
|
+
import { WorkingDirectoryFileChange } from '../models/status'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Add a conflicted file to the index.
|
|
7
|
+
*
|
|
8
|
+
* Typically done after having resolved conflicts either manually
|
|
9
|
+
* or through checkout --theirs/--ours.
|
|
10
|
+
*/
|
|
11
|
+
export async function addConflictedFile(
|
|
12
|
+
repository: Repository,
|
|
13
|
+
file: WorkingDirectoryFileChange
|
|
14
|
+
) {
|
|
15
|
+
await git(['add', '--', file.path], repository.path, 'addConflictedFile')
|
|
16
|
+
}
|
package/src/git/apply.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { GitError as DugiteError } from './exec'
|
|
2
|
+
import { git } from './core'
|
|
3
|
+
import {
|
|
4
|
+
WorkingDirectoryFileChange,
|
|
5
|
+
AppFileStatusKind,
|
|
6
|
+
} from '../models/status'
|
|
7
|
+
import { DiffType, ITextDiff, DiffSelection } from '../models/diff'
|
|
8
|
+
import { Repository } from '../models/repository'
|
|
9
|
+
import { getWorkingDirectoryDiff } from './diff'
|
|
10
|
+
import { formatPatch, formatPatchToDiscardChanges } from '../lib/patch-formatter'
|
|
11
|
+
import { assertNever } from '../lib/fatal-error'
|
|
12
|
+
|
|
13
|
+
export async function applyPatchToIndex(
|
|
14
|
+
repository: Repository,
|
|
15
|
+
file: WorkingDirectoryFileChange
|
|
16
|
+
): Promise<void> {
|
|
17
|
+
// If the file was a rename we have to recreate that rename since we've
|
|
18
|
+
// just blown away the index. Think of this block of weird looking commands
|
|
19
|
+
// as running `git mv`.
|
|
20
|
+
if (file.status.kind === AppFileStatusKind.Renamed) {
|
|
21
|
+
// Make sure the index knows of the removed file. We could use
|
|
22
|
+
// update-index --force-remove here but we're not since it's
|
|
23
|
+
// possible that someone staged a rename and then recreated the
|
|
24
|
+
// original file and we don't have any guarantees for in which order
|
|
25
|
+
// partial stages vs full-file stages happen. By using git add the
|
|
26
|
+
// worst that could happen is that we re-stage a file already staged
|
|
27
|
+
// by updateIndex.
|
|
28
|
+
await git(
|
|
29
|
+
['add', '--update', '--', file.status.oldPath],
|
|
30
|
+
repository.path,
|
|
31
|
+
'applyPatchToIndex'
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
// Figure out the blob oid of the removed file
|
|
35
|
+
// <mode> SP <type> SP <object> TAB <file>
|
|
36
|
+
const oldFile = await git(
|
|
37
|
+
['ls-tree', 'HEAD', '--', file.status.oldPath],
|
|
38
|
+
repository.path,
|
|
39
|
+
'applyPatchToIndex'
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
const [info] = oldFile.stdout.split('\t', 1)
|
|
43
|
+
const [mode, , oid] = info.split(' ', 3)
|
|
44
|
+
|
|
45
|
+
// Add the old file blob to the index under the new name
|
|
46
|
+
await git(
|
|
47
|
+
['update-index', '--add', '--cacheinfo', mode, oid, file.path],
|
|
48
|
+
repository.path,
|
|
49
|
+
'applyPatchToIndex'
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const applyArgs: string[] = [
|
|
54
|
+
'apply',
|
|
55
|
+
'--cached',
|
|
56
|
+
'--unidiff-zero',
|
|
57
|
+
'--whitespace=nowarn',
|
|
58
|
+
'-',
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
const diff = await getWorkingDirectoryDiff(repository, file)
|
|
62
|
+
|
|
63
|
+
if (diff.kind !== DiffType.Text && diff.kind !== DiffType.LargeText) {
|
|
64
|
+
const { kind } = diff
|
|
65
|
+
switch (diff.kind) {
|
|
66
|
+
case DiffType.Binary:
|
|
67
|
+
case DiffType.Submodule:
|
|
68
|
+
case DiffType.Image:
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Can't create partial commit in binary file: ${file.path}`
|
|
71
|
+
)
|
|
72
|
+
case DiffType.Unrenderable:
|
|
73
|
+
throw new Error(
|
|
74
|
+
`File diff is too large to generate a partial commit: ${file.path}`
|
|
75
|
+
)
|
|
76
|
+
default:
|
|
77
|
+
assertNever(diff, `Unknown diff kind: ${kind}`)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const patch = await formatPatch(repository, file, file.selection)
|
|
82
|
+
await git(applyArgs, repository.path, 'applyPatchToIndex', { stdin: patch })
|
|
83
|
+
|
|
84
|
+
return Promise.resolve()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Test a patch to see if it will apply cleanly.
|
|
89
|
+
*
|
|
90
|
+
* @param workTree work tree (which should be checked out to a specific commit)
|
|
91
|
+
* @param patch a Git patch (or patch series) to try applying
|
|
92
|
+
* @returns whether the patch applies cleanly
|
|
93
|
+
*
|
|
94
|
+
* See `formatPatch` to generate a patch series from existing Git commits
|
|
95
|
+
*/
|
|
96
|
+
export async function checkPatch(
|
|
97
|
+
workTree: { path: string },
|
|
98
|
+
patch: string
|
|
99
|
+
): Promise<boolean> {
|
|
100
|
+
const result = await git(
|
|
101
|
+
['apply', '--check', '-'],
|
|
102
|
+
workTree.path,
|
|
103
|
+
'checkPatch',
|
|
104
|
+
{
|
|
105
|
+
stdin: patch,
|
|
106
|
+
encoding: 'utf8' as any,
|
|
107
|
+
expectedErrors: new Set<DugiteError>([DugiteError.PatchDoesNotApply]),
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if (result.gitError === DugiteError.PatchDoesNotApply) {
|
|
112
|
+
// other errors will be thrown if encountered, so this is fine for now
|
|
113
|
+
return false
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return true
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Discards the local changes for the specified file based on the passed diff
|
|
121
|
+
* and a selection of lines from it.
|
|
122
|
+
*
|
|
123
|
+
* When passed an empty selection, this method won't do anything. When passed a
|
|
124
|
+
* full selection, all changes from the file will be discarded.
|
|
125
|
+
*
|
|
126
|
+
* @param repository The repository in which to update the working directory
|
|
127
|
+
* with information from the index
|
|
128
|
+
*
|
|
129
|
+
* @param filePath The relative path in the working directory of the file to use
|
|
130
|
+
*
|
|
131
|
+
* @param diff The diff containing the file local changes
|
|
132
|
+
*
|
|
133
|
+
* @param selection The selection of changes from the diff to discard
|
|
134
|
+
*/
|
|
135
|
+
export async function discardChangesFromSelection(
|
|
136
|
+
repository: Repository,
|
|
137
|
+
filePath: string,
|
|
138
|
+
diff: ITextDiff,
|
|
139
|
+
selection: DiffSelection
|
|
140
|
+
) {
|
|
141
|
+
const file = new WorkingDirectoryFileChange(filePath, { kind: AppFileStatusKind.Modified }, selection)
|
|
142
|
+
const patch = await formatPatchToDiscardChanges(repository, file)
|
|
143
|
+
|
|
144
|
+
if (patch === null || patch === '') {
|
|
145
|
+
// When the patch is null we don't need to apply it since it will be a noop.
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const args = ['apply', '--unidiff-zero', '--whitespace=nowarn', '-']
|
|
150
|
+
|
|
151
|
+
await git(args, repository.path, 'discardChangesFromSelection', {
|
|
152
|
+
stdin: patch,
|
|
153
|
+
})
|
|
154
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { GitError as DugiteError } from './exec'
|
|
2
|
+
|
|
3
|
+
/** Get the environment for authenticating remote operations. */
|
|
4
|
+
export function envForAuthentication(): Record<string, string | undefined> {
|
|
5
|
+
return {
|
|
6
|
+
// supported since Git 2.3, this is used to ensure we never interactively prompt
|
|
7
|
+
// for credentials - even as a fallback
|
|
8
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
9
|
+
GIT_TRACE: localStorage.getItem('git-trace') || '0',
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The set of errors which fit under the "authentication failed" umbrella. */
|
|
14
|
+
export const AuthenticationErrors: ReadonlySet<DugiteError> = new Set([
|
|
15
|
+
DugiteError.HTTPSAuthenticationFailed,
|
|
16
|
+
DugiteError.SSHAuthenticationFailed,
|
|
17
|
+
DugiteError.HTTPSRepositoryNotFound,
|
|
18
|
+
DugiteError.SSHRepositoryNotFound,
|
|
19
|
+
])
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { git, isGitError } from './core'
|
|
2
|
+
import { Repository } from '../models/repository'
|
|
3
|
+
import { Branch } from '../models/branch'
|
|
4
|
+
import { formatAsLocalRef } from './refs'
|
|
5
|
+
import { deleteRef } from './update-ref'
|
|
6
|
+
import { GitError as DugiteError } from './exec'
|
|
7
|
+
import { envForRemoteOperation } from './environment'
|
|
8
|
+
import { createForEachRefParser } from './git-delimiter-parser'
|
|
9
|
+
import { IRemote } from '../models/remote'
|
|
10
|
+
import { coerceToString } from './coerce-to-string'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Create a new branch from the given start point.
|
|
14
|
+
*
|
|
15
|
+
* @param repository - The repository in which to create the new branch
|
|
16
|
+
* @param name - The name of the new branch
|
|
17
|
+
* @param startPoint - A committish string that the new branch should be based
|
|
18
|
+
* on, or undefined if the branch should be created based
|
|
19
|
+
* off of the current state of HEAD
|
|
20
|
+
*/
|
|
21
|
+
export async function createBranch(
|
|
22
|
+
repository: Repository,
|
|
23
|
+
name: string,
|
|
24
|
+
startPoint: string | null,
|
|
25
|
+
noTrack?: boolean
|
|
26
|
+
): Promise<void> {
|
|
27
|
+
const args =
|
|
28
|
+
startPoint !== null ? ['branch', name, startPoint] : ['branch', name]
|
|
29
|
+
|
|
30
|
+
// if we're branching directly from a remote branch, we don't want to track it
|
|
31
|
+
// tracking it will make the rest of desktop think we want to push to that
|
|
32
|
+
// remote branch's upstream (which would likely be the upstream of the fork)
|
|
33
|
+
if (noTrack) {
|
|
34
|
+
args.push('--no-track')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
await git(args, repository.path, 'createBranch')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const getBranchNames = ({ path }: Repository): Promise<string[]> => {
|
|
41
|
+
const parser = createForEachRefParser({ name: '%(refname:short)' })
|
|
42
|
+
return git(['branch', ...parser.formatArgs], path, 'getBranchNames').then(x =>
|
|
43
|
+
parser.parse(x.stdout).map(b => b.name)
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Rename the given branch to a new name. */
|
|
48
|
+
export async function renameBranch(
|
|
49
|
+
repository: Repository,
|
|
50
|
+
branch: Branch,
|
|
51
|
+
newName: string,
|
|
52
|
+
force?: boolean
|
|
53
|
+
): Promise<void> {
|
|
54
|
+
try {
|
|
55
|
+
await git(
|
|
56
|
+
['branch', force ? '-M' : '-m', branch.nameWithoutRemote, newName],
|
|
57
|
+
repository.path,
|
|
58
|
+
'renameBranch'
|
|
59
|
+
)
|
|
60
|
+
} catch (error) {
|
|
61
|
+
// If we failed to rename and the branch name only differs by case, we
|
|
62
|
+
// we'll try again with the -M flag to force the rename. See
|
|
63
|
+
// https://github.com/desktop/desktop/issues/21320
|
|
64
|
+
if (
|
|
65
|
+
// Only retry if the caller hasn't explicitly asked us to force the rename
|
|
66
|
+
force === undefined &&
|
|
67
|
+
isGitError(error) &&
|
|
68
|
+
error.result.gitError === DugiteError.BranchAlreadyExists
|
|
69
|
+
) {
|
|
70
|
+
const stderr = coerceToString(error.result.stderr)
|
|
71
|
+
const m = /fatal: a branch named '(.+?)' already exists/.exec(stderr)
|
|
72
|
+
|
|
73
|
+
if (m && m[1].toLowerCase() === newName.toLowerCase()) {
|
|
74
|
+
// At this point we're almost certain that we are dealing with a
|
|
75
|
+
// case-only rename on a case insensitive filesystem, but we can't
|
|
76
|
+
// be 100% sure, NTFS can be configured to be case sensitive and macOS
|
|
77
|
+
// might have case sensitive file systems mounted so we have to list
|
|
78
|
+
// all branches and check the names.
|
|
79
|
+
return (
|
|
80
|
+
getBranchNames(repository)
|
|
81
|
+
// Throw the original error if we fail to get the branch names
|
|
82
|
+
.catch(() => Promise.reject(error))
|
|
83
|
+
.then(names =>
|
|
84
|
+
// If we find the new name in the list of branches we can't
|
|
85
|
+
// safely assume it's a case-only rename and have to
|
|
86
|
+
// propagate the original error, otherwise try again with -M
|
|
87
|
+
names.includes(newName)
|
|
88
|
+
? Promise.reject(error)
|
|
89
|
+
: renameBranch(repository, branch, newName, true)
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
throw error
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Delete the branch locally.
|
|
100
|
+
*/
|
|
101
|
+
export async function deleteLocalBranch(
|
|
102
|
+
repository: Repository,
|
|
103
|
+
branchName: string
|
|
104
|
+
): Promise<true> {
|
|
105
|
+
await git(['branch', '-D', branchName], repository.path, 'deleteLocalBranch')
|
|
106
|
+
return true
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Deletes a remote branch
|
|
111
|
+
*
|
|
112
|
+
* @param remoteName - the name of the remote to delete the branch from
|
|
113
|
+
* @param remoteBranchName - the name of the branch on the remote
|
|
114
|
+
*/
|
|
115
|
+
export async function deleteRemoteBranch(
|
|
116
|
+
repository: Repository,
|
|
117
|
+
remote: IRemote,
|
|
118
|
+
remoteBranchName: string
|
|
119
|
+
): Promise<true> {
|
|
120
|
+
const args = ['push', remote.name, `:${remoteBranchName}`]
|
|
121
|
+
|
|
122
|
+
// If the user is not authenticated, the push is going to fail
|
|
123
|
+
// Let this propagate and leave it to the caller to handle
|
|
124
|
+
const result = await git(args, repository.path, 'deleteRemoteBranch', {
|
|
125
|
+
env: await envForRemoteOperation(remote.url),
|
|
126
|
+
expectedErrors: new Set<DugiteError>([DugiteError.BranchDeletionFailed]),
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
// It's possible that the delete failed because the ref has already
|
|
130
|
+
// been deleted on the remote. If we identify that specific
|
|
131
|
+
// error we can safely remove our remote ref which is what would
|
|
132
|
+
// happen if the push didn't fail.
|
|
133
|
+
if (result.gitError === DugiteError.BranchDeletionFailed) {
|
|
134
|
+
const ref = `refs/remotes/${remote.name}/${remoteBranchName}`
|
|
135
|
+
await deleteRef(repository, ref)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return true
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Finds branches that have a tip equal to the given committish
|
|
143
|
+
*
|
|
144
|
+
* @param repository within which to execute the command
|
|
145
|
+
* @param commitish a sha, HEAD, etc that the branch(es) tip should be
|
|
146
|
+
* @returns list branch names. null if an error is encountered
|
|
147
|
+
*/
|
|
148
|
+
export async function getBranchesPointedAt(
|
|
149
|
+
repository: Repository,
|
|
150
|
+
commitish: string
|
|
151
|
+
): Promise<Array<string> | null> {
|
|
152
|
+
const args = [
|
|
153
|
+
'branch',
|
|
154
|
+
`--points-at=${commitish}`,
|
|
155
|
+
'--format=%(refname:short)',
|
|
156
|
+
]
|
|
157
|
+
// this command has an implicit \n delimiter
|
|
158
|
+
const { stdout, exitCode } = await git(
|
|
159
|
+
args,
|
|
160
|
+
repository.path,
|
|
161
|
+
'branchPointedAt',
|
|
162
|
+
{
|
|
163
|
+
// - 1 is returned if a common ancestor cannot be resolved
|
|
164
|
+
// - 129 is returned if ref is malformed
|
|
165
|
+
// "warning: ignoring broken ref refs/remotes/origin/main."
|
|
166
|
+
successExitCodes: new Set([0, 1, 129]),
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
if (exitCode === 1 || exitCode === 129) {
|
|
170
|
+
return null
|
|
171
|
+
}
|
|
172
|
+
// split (and remove trailing element cause its always an empty string)
|
|
173
|
+
return stdout.split('\n').slice(0, -1)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Gets all branches that have been merged into the given branch
|
|
178
|
+
*
|
|
179
|
+
* @param repository The repository in which to search
|
|
180
|
+
* @param branchName The to be used as the base branch
|
|
181
|
+
* @returns map of branch canonical refs paired to its sha
|
|
182
|
+
*/
|
|
183
|
+
export async function getMergedBranches(
|
|
184
|
+
repository: Repository,
|
|
185
|
+
branchName: string
|
|
186
|
+
): Promise<Map<string, string>> {
|
|
187
|
+
const canonicalBranchRef = formatAsLocalRef(branchName)
|
|
188
|
+
const { formatArgs, parse } = createForEachRefParser({
|
|
189
|
+
sha: '%(objectname)',
|
|
190
|
+
canonicalRef: '%(refname)',
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
const args = ['branch', ...formatArgs, '--merged', branchName]
|
|
194
|
+
const mergedBranches = new Map<string, string>()
|
|
195
|
+
const { stdout } = await git(args, repository.path, 'mergedBranches')
|
|
196
|
+
|
|
197
|
+
for (const branch of parse(stdout)) {
|
|
198
|
+
// Don't include the branch we're using to compare against
|
|
199
|
+
// in the list of branches merged into that branch.
|
|
200
|
+
if (branch.canonicalRef !== canonicalBranchRef) {
|
|
201
|
+
mergedBranches.set(branch.canonicalRef, branch.sha)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return mergedBranches
|
|
206
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { git } from './core'
|
|
2
|
+
import { Repository } from '../models/repository'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Forcefully updates the working directory with information from the index
|
|
6
|
+
* for a given set of files.
|
|
7
|
+
*
|
|
8
|
+
* This method is essentially the same as running `git checkout -- files`
|
|
9
|
+
* except by using `checkout-index` we can pass the files we want updated
|
|
10
|
+
* on stdin, avoiding all issues with too long arguments.
|
|
11
|
+
*
|
|
12
|
+
* Note that this function will not yield errors for paths that don't
|
|
13
|
+
* exist in the index (-q).
|
|
14
|
+
*
|
|
15
|
+
* @param repository The repository in which to update the working directory
|
|
16
|
+
* with information from the index
|
|
17
|
+
*
|
|
18
|
+
* @param paths The relative paths in the working directory to update
|
|
19
|
+
* with information from the index.
|
|
20
|
+
*/
|
|
21
|
+
export async function checkoutIndex(
|
|
22
|
+
repository: Repository,
|
|
23
|
+
paths: ReadonlyArray<string>
|
|
24
|
+
) {
|
|
25
|
+
if (!paths.length) {
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const options = {
|
|
30
|
+
successExitCodes: new Set([0, 1]),
|
|
31
|
+
stdin: paths.join('\0'),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
await git(
|
|
35
|
+
['checkout-index', '-f', '-u', '-q', '--stdin', '-z'],
|
|
36
|
+
repository.path,
|
|
37
|
+
'checkoutIndex',
|
|
38
|
+
options
|
|
39
|
+
)
|
|
40
|
+
}
|