Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit ad71871

Browse files
authoredMay 5, 2025··
feat: add checkout features (#123)
1 parent d1fac8f commit ad71871

File tree

5 files changed

+525
-0
lines changed

5 files changed

+525
-0
lines changed
 

‎index.d.ts

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,148 @@ export interface BranchesFilter {
109109
/** Branch type to filter. */
110110
type?: BranchType
111111
}
112+
export interface CheckoutOptions {
113+
/**
114+
* Indicate that this checkout should perform a dry run by checking for
115+
* conflicts but not make any actual changes.
116+
*/
117+
dryRun?: boolean
118+
/**
119+
* Take any action necessary to get the working directory to match the
120+
* target including potentially discarding modified files.
121+
*/
122+
force?: boolean
123+
/**
124+
* Indicate that the checkout should be performed safely, allowing new
125+
* files to be created but not overwriting existing files or changes.
126+
*
127+
* This is the default.
128+
*/
129+
safe?: boolean
130+
/**
131+
* In safe mode, create files that don't exist.
132+
*
133+
* Defaults to false.
134+
*/
135+
recreateMissing?: boolean
136+
/**
137+
* In safe mode, apply safe file updates even when there are conflicts
138+
* instead of canceling the checkout.
139+
*
140+
* Defaults to false.
141+
*/
142+
allowConflicts?: boolean
143+
/**
144+
* Remove untracked files from the working dir.
145+
*
146+
* Defaults to false.
147+
*/
148+
removeUntracked?: boolean
149+
/**
150+
* Remove ignored files from the working dir.
151+
*
152+
* Defaults to false.
153+
*/
154+
removeIgnored?: boolean
155+
/**
156+
* Only update the contents of files that already exist.
157+
*
158+
* If set, files will not be created or deleted.
159+
*
160+
* Defaults to false.
161+
*/
162+
updateOnly?: boolean
163+
/**
164+
* Prevents checkout from writing the updated files' information to the
165+
* index.
166+
*
167+
* Defaults to true.
168+
*/
169+
updateIndex?: boolean
170+
/**
171+
* Indicate whether the index and git attributes should be refreshed from
172+
* disk before any operations.
173+
*
174+
* Defaults to true,
175+
*/
176+
refresh?: boolean
177+
/**
178+
* Skip files with unmerged index entries.
179+
*
180+
* Defaults to false.
181+
*/
182+
skipUnmerged?: boolean
183+
/**
184+
* Indicate whether the checkout should proceed on conflicts by using the
185+
* stage 2 version of the file ("ours").
186+
*
187+
* Defaults to false.
188+
*/
189+
useOurs?: boolean
190+
/**
191+
* Indicate whether the checkout should proceed on conflicts by using the
192+
* stage 3 version of the file ("theirs").
193+
*
194+
* Defaults to false.
195+
*/
196+
useTheirs?: boolean
197+
/**
198+
* Indicate whether ignored files should be overwritten during the checkout.
199+
*
200+
* Defaults to true.
201+
*/
202+
overwriteIgnored?: boolean
203+
/**
204+
* Indicate whether a normal merge file should be written for conflicts.
205+
*
206+
* Defaults to false.
207+
*/
208+
conflictStyleMerge?: boolean
209+
/**
210+
* Indicates whether to include common ancestor data in diff3 format files
211+
* for conflicts.
212+
*
213+
* Defaults to false.
214+
*/
215+
conflictStyleDiff3?: boolean
216+
/**
217+
* Treat paths specified in `path` as exact file paths
218+
* instead of as pathspecs.
219+
*/
220+
disablePathspecMatch?: boolean
221+
/** Indicate whether to apply filters like CRLF conversion. */
222+
disableFilters?: boolean
223+
/**
224+
* Set the mode with which new directories are created.
225+
*
226+
* Default is 0755
227+
*/
228+
dirPerm?: number
229+
/**
230+
* Set the mode with which new files are created.
231+
*
232+
* The default is 0644 or 0755 as dictated by the blob.
233+
*/
234+
filePerm?: number
235+
/**
236+
* Add a path to be checked out.
237+
*
238+
* The path is a [pathspec](https://git-scm.com/docs/gitglossary.html#Documentation/gitglossary.txt-aiddefpathspecapathspec) pattern, unless
239+
* `disablePathspecMatch` is set.
240+
*
241+
* If no paths are specified, then all files are checked out. Otherwise
242+
* only these specified paths are checked out.
243+
*/
244+
path?: string
245+
/** Set the directory to check out to */
246+
targetDir?: string
247+
/** The name of the common ancestor side of conflicts */
248+
ancestorLabel?: string
249+
/** The name of the common our side of conflicts */
250+
ourLabel?: string
251+
/** The name of the common their side of conflicts */
252+
theirLabel?: string
253+
}
112254
export interface CommitOptions {
113255
updateRef?: string
114256
/**
@@ -3900,6 +4042,55 @@ export declare class Repository {
39004042
* ```
39014043
*/
39024044
branches(filter?: BranchesFilter | undefined | null): Branches
4045+
/**
4046+
* Updates files in the index and the working tree to match the content of
4047+
* the commit pointed at by HEAD.
4048+
*
4049+
* @category Repository/Methods
4050+
* @signature
4051+
* ```ts
4052+
* class Repository {
4053+
* checkoutHead(options?: CheckoutOptions | undefined | null): void;
4054+
* }
4055+
* ```
4056+
*
4057+
* @param {CheckoutOptions} [options] - Options for checkout.
4058+
*/
4059+
checkoutHead(options?: CheckoutOptions | undefined | null): void
4060+
/**
4061+
* Updates files in the working tree to match the content of the index.
4062+
*
4063+
* @category Repository/Methods
4064+
* @signature
4065+
* ```ts
4066+
* class Repository {
4067+
* checkoutIndex(
4068+
* index?: Index | undefined | null,
4069+
* options?: CheckoutOptions | undefined | null,
4070+
* ): void;
4071+
* }
4072+
* ```
4073+
*
4074+
* @param {Index} [index] - Index to checkout. If not given, the repository's index will be used.
4075+
* @param {CheckoutOptions} [options] - Options for checkout.
4076+
*/
4077+
checkoutIndex(index?: Index | undefined | null, options?: CheckoutOptions | undefined | null): void
4078+
/**
4079+
* Updates files in the index and working tree to match the content of the
4080+
* tree pointed at by the treeish.
4081+
*
4082+
* @category Repository/Methods
4083+
* @signature
4084+
* ```ts
4085+
* class Repository {
4086+
* checkoutTree(treeish: GitObject, options?: CheckoutOptions | undefined | null): void;
4087+
* }
4088+
* ```
4089+
*
4090+
* @param {GitObject} treeish - Git object which tree pointed.
4091+
* @param {CheckoutOptions} [options] - Options for checkout.
4092+
*/
4093+
checkoutTree(treeish: GitObject, options?: CheckoutOptions | undefined | null): void
39034094
/**
39044095
* Lookup a reference to one of the commits in a repository.
39054096
*

‎justfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ _default:
44
alias f := format
55
alias t := test
66
alias l := lint
7+
alias b := build
78
alias bench := benchmarks
89

910
# Setup development environment

‎src/checkout.rs

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
use crate::index::Index;
2+
use crate::object::GitObject;
3+
use crate::repository::Repository;
4+
use napi_derive::napi;
5+
use std::path::Path;
6+
7+
#[napi(object)]
8+
pub struct CheckoutOptions {
9+
/// Indicate that this checkout should perform a dry run by checking for
10+
/// conflicts but not make any actual changes.
11+
pub dry_run: Option<bool>,
12+
/// Take any action necessary to get the working directory to match the
13+
/// target including potentially discarding modified files.
14+
pub force: Option<bool>,
15+
/// Indicate that the checkout should be performed safely, allowing new
16+
/// files to be created but not overwriting existing files or changes.
17+
///
18+
/// This is the default.
19+
pub safe: Option<bool>,
20+
/// In safe mode, create files that don't exist.
21+
///
22+
/// Defaults to false.
23+
pub recreate_missing: Option<bool>,
24+
/// In safe mode, apply safe file updates even when there are conflicts
25+
/// instead of canceling the checkout.
26+
///
27+
/// Defaults to false.
28+
pub allow_conflicts: Option<bool>,
29+
/// Remove untracked files from the working dir.
30+
///
31+
/// Defaults to false.
32+
pub remove_untracked: Option<bool>,
33+
/// Remove ignored files from the working dir.
34+
///
35+
/// Defaults to false.
36+
pub remove_ignored: Option<bool>,
37+
/// Only update the contents of files that already exist.
38+
///
39+
/// If set, files will not be created or deleted.
40+
///
41+
/// Defaults to false.
42+
pub update_only: Option<bool>,
43+
/// Prevents checkout from writing the updated files' information to the
44+
/// index.
45+
///
46+
/// Defaults to true.
47+
pub update_index: Option<bool>,
48+
/// Indicate whether the index and git attributes should be refreshed from
49+
/// disk before any operations.
50+
///
51+
/// Defaults to true,
52+
pub refresh: Option<bool>,
53+
/// Skip files with unmerged index entries.
54+
///
55+
/// Defaults to false.
56+
pub skip_unmerged: Option<bool>,
57+
/// Indicate whether the checkout should proceed on conflicts by using the
58+
/// stage 2 version of the file ("ours").
59+
///
60+
/// Defaults to false.
61+
pub use_ours: Option<bool>,
62+
/// Indicate whether the checkout should proceed on conflicts by using the
63+
/// stage 3 version of the file ("theirs").
64+
///
65+
/// Defaults to false.
66+
pub use_theirs: Option<bool>,
67+
/// Indicate whether ignored files should be overwritten during the checkout.
68+
///
69+
/// Defaults to true.
70+
pub overwrite_ignored: Option<bool>,
71+
/// Indicate whether a normal merge file should be written for conflicts.
72+
///
73+
/// Defaults to false.
74+
pub conflict_style_merge: Option<bool>,
75+
/// Indicates whether to include common ancestor data in diff3 format files
76+
/// for conflicts.
77+
///
78+
/// Defaults to false.
79+
pub conflict_style_diff3: Option<bool>,
80+
/// Treat paths specified in `path` as exact file paths
81+
/// instead of as pathspecs.
82+
pub disable_pathspec_match: Option<bool>,
83+
/// Indicate whether to apply filters like CRLF conversion.
84+
pub disable_filters: Option<bool>,
85+
/// Set the mode with which new directories are created.
86+
///
87+
/// Default is 0755
88+
pub dir_perm: Option<i32>,
89+
/// Set the mode with which new files are created.
90+
///
91+
/// The default is 0644 or 0755 as dictated by the blob.
92+
pub file_perm: Option<i32>,
93+
/// Add a path to be checked out.
94+
///
95+
/// The path is a [pathspec](https://git-scm.com/docs/gitglossary.html#Documentation/gitglossary.txt-aiddefpathspecapathspec) pattern, unless
96+
/// `disablePathspecMatch` is set.
97+
///
98+
/// If no paths are specified, then all files are checked out. Otherwise
99+
/// only these specified paths are checked out.
100+
pub path: Option<String>,
101+
/// Set the directory to check out to
102+
pub target_dir: Option<String>,
103+
/// The name of the common ancestor side of conflicts
104+
pub ancestor_label: Option<String>,
105+
/// The name of the common our side of conflicts
106+
pub our_label: Option<String>,
107+
/// The name of the common their side of conflicts
108+
pub their_label: Option<String>,
109+
}
110+
111+
impl From<CheckoutOptions> for git2::build::CheckoutBuilder<'static> {
112+
fn from(value: CheckoutOptions) -> Self {
113+
let mut builder = git2::build::CheckoutBuilder::new();
114+
if let Some(true) = value.dry_run {
115+
builder.dry_run();
116+
}
117+
if let Some(true) = value.force {
118+
builder.force();
119+
}
120+
if let Some(true) = value.safe {
121+
builder.safe();
122+
}
123+
if let Some(allow) = value.recreate_missing {
124+
builder.recreate_missing(allow);
125+
}
126+
if let Some(allow) = value.allow_conflicts {
127+
builder.allow_conflicts(allow);
128+
}
129+
if let Some(remove) = value.remove_untracked {
130+
builder.remove_untracked(remove);
131+
}
132+
if let Some(remove) = value.remove_ignored {
133+
builder.remove_ignored(remove);
134+
}
135+
if let Some(update) = value.update_only {
136+
builder.update_only(update);
137+
}
138+
if let Some(update) = value.update_index {
139+
builder.update_index(update);
140+
}
141+
if let Some(refresh) = value.refresh {
142+
builder.refresh(refresh);
143+
}
144+
if let Some(skip) = value.skip_unmerged {
145+
builder.skip_unmerged(skip);
146+
}
147+
if let Some(ours) = value.use_ours {
148+
builder.use_ours(ours);
149+
}
150+
if let Some(theirs) = value.use_theirs {
151+
builder.use_theirs(theirs);
152+
}
153+
if let Some(overwrite) = value.overwrite_ignored {
154+
builder.overwrite_ignored(overwrite);
155+
}
156+
if let Some(on) = value.conflict_style_merge {
157+
builder.conflict_style_merge(on);
158+
}
159+
if let Some(on) = value.conflict_style_diff3 {
160+
builder.conflict_style_diff3(on);
161+
}
162+
if let Some(on) = value.disable_pathspec_match {
163+
builder.disable_pathspec_match(on);
164+
}
165+
if let Some(disable) = value.disable_filters {
166+
builder.disable_filters(disable);
167+
}
168+
if let Some(perm) = value.dir_perm {
169+
builder.dir_perm(perm);
170+
}
171+
if let Some(perm) = value.file_perm {
172+
builder.file_perm(perm);
173+
}
174+
if let Some(path) = value.path {
175+
builder.path(path);
176+
}
177+
if let Some(dst) = value.target_dir {
178+
builder.target_dir(Path::new(&dst));
179+
}
180+
if let Some(label) = value.ancestor_label {
181+
builder.ancestor_label(&label);
182+
}
183+
if let Some(label) = value.our_label {
184+
builder.our_label(&label);
185+
}
186+
if let Some(label) = value.their_label {
187+
builder.their_label(&label);
188+
}
189+
builder
190+
}
191+
}
192+
193+
#[napi]
194+
impl Repository {
195+
#[napi]
196+
/// Updates files in the index and the working tree to match the content of
197+
/// the commit pointed at by HEAD.
198+
///
199+
/// @category Repository/Methods
200+
/// @signature
201+
/// ```ts
202+
/// class Repository {
203+
/// checkoutHead(options?: CheckoutOptions | undefined | null): void;
204+
/// }
205+
/// ```
206+
///
207+
/// @param {CheckoutOptions} [options] - Options for checkout.
208+
pub fn checkout_head(&self, options: Option<CheckoutOptions>) -> crate::Result<()> {
209+
let mut builder = options.map(git2::build::CheckoutBuilder::from);
210+
self.inner.checkout_head(builder.as_mut())?;
211+
Ok(())
212+
}
213+
214+
#[napi]
215+
/// Updates files in the working tree to match the content of the index.
216+
///
217+
/// @category Repository/Methods
218+
/// @signature
219+
/// ```ts
220+
/// class Repository {
221+
/// checkoutIndex(
222+
/// index?: Index | undefined | null,
223+
/// options?: CheckoutOptions | undefined | null,
224+
/// ): void;
225+
/// }
226+
/// ```
227+
///
228+
/// @param {Index} [index] - Index to checkout. If not given, the repository's index will be used.
229+
/// @param {CheckoutOptions} [options] - Options for checkout.
230+
pub fn checkout_index(&self, index: Option<&mut Index>, options: Option<CheckoutOptions>) -> crate::Result<()> {
231+
let mut builder = options.map(git2::build::CheckoutBuilder::from);
232+
let git_index = index.map(|x| &mut x.inner);
233+
self.inner.checkout_index(git_index, builder.as_mut())?;
234+
Ok(())
235+
}
236+
237+
#[napi]
238+
/// Updates files in the index and working tree to match the content of the
239+
/// tree pointed at by the treeish.
240+
///
241+
/// @category Repository/Methods
242+
/// @signature
243+
/// ```ts
244+
/// class Repository {
245+
/// checkoutTree(treeish: GitObject, options?: CheckoutOptions | undefined | null): void;
246+
/// }
247+
/// ```
248+
///
249+
/// @param {GitObject} treeish - Git object which tree pointed.
250+
/// @param {CheckoutOptions} [options] - Options for checkout.
251+
pub fn checkout_tree(&self, treeish: &GitObject, options: Option<CheckoutOptions>) -> crate::Result<()> {
252+
let mut builder = options.map(git2::build::CheckoutBuilder::from);
253+
self.inner.checkout_tree(&treeish.inner, builder.as_mut())?;
254+
Ok(())
255+
}
256+
}

‎src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
pub mod blame;
44
pub mod blob;
55
pub mod branch;
6+
pub mod checkout;
67
pub mod commit;
78
pub mod config;
89
pub mod diff;

‎tests/checkout.spec.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import fs from 'node:fs/promises';
2+
import path from 'node:path';
3+
import { describe, expect, it } from 'vitest';
4+
import { openRepository } from '../index';
5+
import { useFixture } from './fixtures';
6+
7+
describe('checkout', () => {
8+
it('smoke checkout', async () => {
9+
const p = await useFixture('empty');
10+
const repo = await openRepository(p);
11+
repo.checkoutHead();
12+
});
13+
14+
it('checkout branches', async () => {
15+
const p = await useFixture('empty');
16+
const repo = await openRepository(p);
17+
const sig = { name: 'Seokju Na', email: 'seokju.me@toss.im' };
18+
19+
let index = repo.index();
20+
const treeId = index.writeTree();
21+
const tree = repo.getTree(treeId);
22+
const firstOid = repo.commit(tree, '1', {
23+
updateRef: 'HEAD',
24+
author: sig,
25+
committer: sig,
26+
parents: [repo.head().target()!],
27+
});
28+
const firstCommit = repo.getCommit(firstOid);
29+
30+
repo.createBranch('branch-a', firstCommit);
31+
repo.createBranch('branch-b', firstCommit);
32+
33+
await fs.writeFile(path.join(p, 'file1'), 'A');
34+
index = repo.index();
35+
index.addPath('file1');
36+
const treeAId = index.writeTree();
37+
const treeA = repo.getTree(treeAId);
38+
const aOid = repo.commit(treeA, 'A', {
39+
updateRef: 'refs/heads/branch-a',
40+
author: sig,
41+
committer: sig,
42+
parents: [firstOid],
43+
});
44+
repo.setHead('refs/heads/branch-a');
45+
repo.checkoutHead();
46+
expect(repo.head().target()).toEqual(aOid);
47+
48+
await fs.writeFile(path.join(p, 'file2'), 'B');
49+
index = repo.index();
50+
index.addPath('file2');
51+
const treeBId = index.writeTree();
52+
const treeB = repo.getTree(treeBId);
53+
const bOid = repo.commit(treeB, 'B', {
54+
updateRef: 'refs/heads/branch-b',
55+
author: sig,
56+
committer: sig,
57+
parents: [firstOid],
58+
});
59+
repo.setHead('refs/heads/branch-b');
60+
repo.checkoutHead();
61+
expect(repo.head().target()).toEqual(bOid);
62+
});
63+
64+
it('conflict error when index is changed', async () => {
65+
const p = await useFixture('commits');
66+
const repo = await openRepository(p);
67+
68+
await fs.writeFile(path.join(p, 'added.txt'), 'added');
69+
const index = repo.index();
70+
index.addPath('added.txt');
71+
index.write();
72+
73+
expect(() => repo.checkoutIndex()).toThrowError();
74+
expect(() => repo.checkoutIndex(index)).toThrowError();
75+
});
76+
});

0 commit comments

Comments
 (0)
Please sign in to comment.