Skip to content

Commit 6d9a247

Browse files
authored
Merge pull request #3779 from OpenNeuroOrg/cli/git-annex-avoid-checkout
refactor(cli): Build git-annex branch without a checkout
2 parents e595e81 + 2552cb7 commit 6d9a247

5 files changed

Lines changed: 326 additions & 123 deletions

File tree

.github/workflows/deno.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,5 @@ jobs:
3434
- uses: codecov/codecov-action@v5
3535
if: ${{ always() }}
3636
with:
37-
files: coverage.lcov
37+
files: cli/coverage.lcov
38+
token: ${{ secrets.CODECOV_TOKEN }}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { assertEquals, assertExists } from "@std/assert"
2+
import { CommitBuilder } from "./commitBuilder.ts"
3+
import { GitWorkerContext } from "./types/git-context.ts"
4+
import { default as git } from "isomorphic-git"
5+
6+
Deno.test("CommitBuilder", async (t) => {
7+
const testDir = await Deno.makeTempDir()
8+
const context = new GitWorkerContext(
9+
"ds000000",
10+
testDir,
11+
testDir,
12+
"http://localhost",
13+
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJmNjZhNzRjNS05ZDhmLTQ2M2MtOGE2ZS1lYTE3ODljYTNiOTIiLCJlbWFpbCI6Im5lbGxAZGV2LW5lbGwuY29tIiwicHJvdmlkZXIiOiJnb29nbGUiLCJuYW1lIjoiTmVsbCBIYXJkY2FzdGxlIiwiYWRtaW4iOnRydWUsImlhdCI6MTcwMDUyNDIzNCwiZXhwIjoxNzMyMDYwMjM0fQ.5glc_uoxqcRJ4KWn2EvRR0hH-ono2MPJH0wqvcXBIOg",
14+
)
15+
16+
// Initialize a repo for testing
17+
await git.init({ ...context.config(), defaultBranch: "main" })
18+
19+
await t.step("creates a new commit on a new branch", async () => {
20+
const builder = new CommitBuilder(context)
21+
builder.add("file1.txt", "content1")
22+
builder.add("dir/file2.txt", "content2")
23+
24+
const commitOid = await builder.commit("main", "initial commit")
25+
assertExists(commitOid)
26+
27+
const log = await git.log({ ...context.config(), ref: "main" })
28+
assertEquals(log[0].commit.message, "initial commit\n")
29+
30+
const { blob: b1 } = await git.readBlob({
31+
...context.config(),
32+
oid: commitOid!,
33+
filepath: "file1.txt",
34+
})
35+
assertEquals(new TextDecoder().decode(b1), "content1")
36+
37+
const { blob: b2 } = await git.readBlob({
38+
...context.config(),
39+
oid: commitOid!,
40+
filepath: "dir/file2.txt",
41+
})
42+
assertEquals(new TextDecoder().decode(b2), "content2")
43+
})
44+
45+
await t.step("updates existing tree with new commit", async () => {
46+
const builder = new CommitBuilder(context)
47+
// Update existing file and add a new one
48+
builder.add("file1.txt", "updated content")
49+
builder.add("new-file.txt", "new content")
50+
51+
const commitOid = await builder.commit("main", "second commit")
52+
assertExists(commitOid)
53+
54+
const log = await git.log({ ...context.config(), ref: "main" })
55+
assertEquals(log[0].commit.message, "second commit\n")
56+
assertEquals(log.length, 2)
57+
58+
const { blob: b1 } = await git.readBlob({
59+
...context.config(),
60+
oid: commitOid!,
61+
filepath: "file1.txt",
62+
})
63+
assertEquals(new TextDecoder().decode(b1), "updated content")
64+
65+
const { blob: b2 } = await git.readBlob({
66+
...context.config(),
67+
oid: commitOid!,
68+
filepath: "dir/file2.txt",
69+
})
70+
assertEquals(new TextDecoder().decode(b2), "content2")
71+
72+
const { blob: b3 } = await git.readBlob({
73+
...context.config(),
74+
oid: commitOid!,
75+
filepath: "new-file.txt",
76+
})
77+
assertEquals(new TextDecoder().decode(b3), "new content")
78+
})
79+
80+
await t.step("handles binary data", async () => {
81+
const builder = new CommitBuilder(context)
82+
const binaryData = new Uint8Array([0, 1, 2, 3])
83+
builder.add("binary.bin", binaryData)
84+
85+
const commitOid = await builder.commit("main", "binary commit")
86+
assertExists(commitOid)
87+
88+
const { blob } = await git.readBlob({
89+
...context.config(),
90+
oid: commitOid!,
91+
filepath: "binary.bin",
92+
})
93+
assertEquals(blob, binaryData)
94+
})
95+
96+
await t.step("creates deep directory structures", async () => {
97+
const builder = new CommitBuilder(context)
98+
builder.add("a/b/c/d/file.txt", "deep")
99+
100+
const commitOid = await builder.commit("main", "deep commit")
101+
assertExists(commitOid)
102+
103+
const { blob } = await git.readBlob({
104+
...context.config(),
105+
oid: commitOid!,
106+
filepath: "a/b/c/d/file.txt",
107+
})
108+
assertEquals(new TextDecoder().decode(blob), "deep")
109+
})
110+
111+
// Clean up
112+
await Deno.remove(testDir, { recursive: true })
113+
})

cli/src/worker/commitBuilder.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { default as git } from "isomorphic-git"
2+
import type { GitWorkerContext } from "./types/git-context.ts"
3+
4+
export class CommitBuilder {
5+
private updates: Map<string, Uint8Array> = new Map()
6+
7+
constructor(private context: GitWorkerContext) {}
8+
9+
add(path: string, content: string | Uint8Array) {
10+
const data = typeof content === "string"
11+
? new TextEncoder().encode(content)
12+
: content
13+
this.updates.set(path, data)
14+
}
15+
16+
async commit(branch: string, message: string) {
17+
const options = this.context.config()
18+
let parentCommit
19+
let parentTree
20+
try {
21+
parentCommit = await git.resolveRef({ ...options, ref: branch })
22+
const commitObj = await git.readCommit({ ...options, oid: parentCommit })
23+
parentTree = commitObj.commit.tree
24+
} catch {
25+
// Branch might not exist or is empty
26+
parentCommit = undefined
27+
parentTree = undefined
28+
}
29+
30+
const newTree = await this.buildTree(parentTree, this.updates)
31+
32+
if (!newTree) {
33+
return undefined
34+
}
35+
36+
const now = new Date()
37+
const timestamp = Math.floor(now.getTime() / 1000)
38+
const timezoneOffset = now.getTimezoneOffset()
39+
const author = {
40+
...this.context.author,
41+
timestamp,
42+
timezoneOffset,
43+
}
44+
45+
const commitOid = await git.writeCommit({
46+
...options,
47+
commit: {
48+
message,
49+
tree: newTree,
50+
parent: parentCommit ? [parentCommit] : [],
51+
author,
52+
committer: author,
53+
},
54+
})
55+
56+
await git.writeRef({
57+
...options,
58+
ref: `refs/heads/${branch}`,
59+
value: commitOid,
60+
force: true,
61+
})
62+
63+
return commitOid
64+
}
65+
66+
private async buildTree(
67+
baseOid: string | undefined,
68+
updates: Map<string, Uint8Array>,
69+
): Promise<string> {
70+
const options = this.context.config()
71+
let entries: {
72+
mode: string
73+
path: string
74+
oid: string
75+
type: "blob" | "tree" | "commit"
76+
}[] = []
77+
if (baseOid) {
78+
const treeResult = await git.readTree({ ...options, oid: baseOid })
79+
entries = treeResult.tree
80+
}
81+
82+
// Group updates by current path segment
83+
const bySegment = new Map<string, Map<string, Uint8Array>>()
84+
const fileUpdates = new Map<string, Uint8Array>()
85+
86+
for (const [path, content] of updates) {
87+
const parts = path.split("/")
88+
const segment = parts[0]
89+
if (parts.length === 1) {
90+
fileUpdates.set(segment, content)
91+
} else {
92+
if (!bySegment.has(segment)) {
93+
bySegment.set(segment, new Map())
94+
}
95+
bySegment.get(segment)!.set(parts.slice(1).join("/"), content)
96+
}
97+
}
98+
99+
// Process subdirectories
100+
for (const [segment, childUpdates] of bySegment) {
101+
const existingEntryIndex = entries.findIndex((e) => e.path === segment)
102+
const existingEntry = existingEntryIndex >= 0
103+
? entries[existingEntryIndex]
104+
: undefined
105+
106+
const childBaseOid = existingEntry && existingEntry.type === "tree"
107+
? existingEntry.oid
108+
: undefined
109+
110+
const newChildOid = await this.buildTree(childBaseOid, childUpdates)
111+
112+
const newEntry = {
113+
mode: "040000",
114+
path: segment,
115+
oid: newChildOid,
116+
type: "tree" as const,
117+
}
118+
119+
if (existingEntryIndex >= 0) {
120+
entries[existingEntryIndex] = newEntry
121+
} else {
122+
entries.push(newEntry)
123+
}
124+
}
125+
126+
// Process files
127+
for (const [filename, content] of fileUpdates) {
128+
const blobOid = await git.writeBlob({ ...options, blob: content })
129+
const newEntry = {
130+
mode: "100644",
131+
path: filename,
132+
oid: blobOid,
133+
type: "blob" as const,
134+
}
135+
136+
const existingEntryIndex = entries.findIndex((e) => e.path === filename)
137+
if (existingEntryIndex >= 0) {
138+
entries[existingEntryIndex] = newEntry
139+
} else {
140+
entries.push(newEntry)
141+
}
142+
}
143+
144+
return await git.writeTree({ ...options, tree: entries })
145+
}
146+
}

cli/src/worker/git.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,5 +128,5 @@ LICENSE annex.largefiles=nothing`),
128128
assertArrayIncludes(expectedFiles, [relativePath])
129129
}
130130
}
131-
assertEquals(gitObjects, 10)
131+
assertEquals(gitObjects, 15)
132132
})

0 commit comments

Comments
 (0)