procoder-cli 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/AGENTS.md +122 -0
- package/CONTRIBUTORS.md +79 -0
- package/Makefile +82 -0
- package/README.md +125 -0
- package/bin/procoder.js +28 -0
- package/cmd/procoder/main.go +15 -0
- package/cmd/procoder-return/main.go +67 -0
- package/cmd/procoder-return/main_test.go +77 -0
- package/go.mod +3 -0
- package/internal/app/app.go +224 -0
- package/internal/app/app_test.go +253 -0
- package/internal/app/roundtrip_test.go +215 -0
- package/internal/apply/apply.go +1069 -0
- package/internal/apply/apply_test.go +794 -0
- package/internal/errs/errors.go +114 -0
- package/internal/exchange/exchange_test.go +165 -0
- package/internal/exchange/id.go +66 -0
- package/internal/exchange/json.go +64 -0
- package/internal/exchange/types.go +55 -0
- package/internal/gitx/gitx.go +105 -0
- package/internal/gitx/gitx_test.go +51 -0
- package/internal/output/errors.go +49 -0
- package/internal/output/errors_test.go +41 -0
- package/internal/prepare/prepare.go +788 -0
- package/internal/prepare/prepare_test.go +416 -0
- package/internal/returnpkg/returnpkg.go +589 -0
- package/internal/returnpkg/returnpkg_test.go +489 -0
- package/internal/testutil/gitrepo/repo.go +113 -0
- package/package.json +47 -0
- package/scripts/postinstall.js +263 -0
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
package returnpkg
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"archive/zip"
|
|
5
|
+
"bytes"
|
|
6
|
+
"fmt"
|
|
7
|
+
"io"
|
|
8
|
+
"os"
|
|
9
|
+
"os/exec"
|
|
10
|
+
"path/filepath"
|
|
11
|
+
"sort"
|
|
12
|
+
"strings"
|
|
13
|
+
"testing"
|
|
14
|
+
"time"
|
|
15
|
+
|
|
16
|
+
"github.com/amxv/procoder/internal/errs"
|
|
17
|
+
"github.com/amxv/procoder/internal/exchange"
|
|
18
|
+
"github.com/amxv/procoder/internal/output"
|
|
19
|
+
"github.com/amxv/procoder/internal/prepare"
|
|
20
|
+
"github.com/amxv/procoder/internal/testutil/gitrepo"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
func TestRunReturnHappyPath(t *testing.T) {
|
|
24
|
+
env := setupPreparedExportRepo(t)
|
|
25
|
+
|
|
26
|
+
baseOID := strings.TrimSpace(runGit(t, env.exportRoot, "rev-parse", env.exchange.Task.RootRef))
|
|
27
|
+
appendFile(t, filepath.Join(env.exportRoot, "README.md"), "remote change\n")
|
|
28
|
+
runGit(t, env.exportRoot, "add", "README.md")
|
|
29
|
+
runGit(t, env.exportRoot, "commit", "-m", "remote task work")
|
|
30
|
+
newOID := strings.TrimSpace(runGit(t, env.exportRoot, "rev-parse", env.exchange.Task.RootRef))
|
|
31
|
+
|
|
32
|
+
result, err := Run(Options{
|
|
33
|
+
CWD: env.exportRoot,
|
|
34
|
+
ToolVersion: "0.1.0-test",
|
|
35
|
+
Now: func() time.Time { return time.Date(2026, time.March, 20, 13, 45, 0, 0, time.UTC) },
|
|
36
|
+
})
|
|
37
|
+
if err != nil {
|
|
38
|
+
t.Fatalf("Run returned error: %v", err)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
expectedName := fmt.Sprintf("procoder-return-%s.zip", env.exchange.ExchangeID)
|
|
42
|
+
expectedPath := filepath.Join(env.exportRoot, expectedName)
|
|
43
|
+
if filepath.Base(result.ReturnPackagePath) != expectedName {
|
|
44
|
+
t.Fatalf("unexpected return package filename: got %q want %q", filepath.Base(result.ReturnPackagePath), expectedName)
|
|
45
|
+
}
|
|
46
|
+
if !filepath.IsAbs(result.ReturnPackagePath) {
|
|
47
|
+
t.Fatalf("expected absolute return package path, got %q", result.ReturnPackagePath)
|
|
48
|
+
}
|
|
49
|
+
gotInfo, err := os.Stat(result.ReturnPackagePath)
|
|
50
|
+
if err != nil {
|
|
51
|
+
t.Fatalf("expected return package at %q: %v", result.ReturnPackagePath, err)
|
|
52
|
+
}
|
|
53
|
+
wantInfo, err := os.Stat(expectedPath)
|
|
54
|
+
if err != nil {
|
|
55
|
+
t.Fatalf("expected return package at %q: %v", expectedPath, err)
|
|
56
|
+
}
|
|
57
|
+
if !os.SameFile(gotInfo, wantInfo) {
|
|
58
|
+
t.Fatalf("return package path mismatch: got %q want %q", result.ReturnPackagePath, expectedPath)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
status := strings.TrimSpace(runGit(t, env.exportRoot, "status", "--porcelain=v1", "--untracked-files=all"))
|
|
62
|
+
if status != "" {
|
|
63
|
+
t.Fatalf("expected exported repo to remain clean, got status:\n%s", status)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
exclude := mustReadFile(t, filepath.Join(env.exportRoot, ".git", "info", "exclude"))
|
|
67
|
+
if !strings.Contains(exclude, expectedName) {
|
|
68
|
+
t.Fatalf("expected export exclude to contain return package %q, got:\n%s", expectedName, exclude)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
entries := listZipEntries(t, result.ReturnPackagePath)
|
|
72
|
+
expectedEntries := []string{returnBundleName, returnJSONName}
|
|
73
|
+
sort.Strings(entries)
|
|
74
|
+
sort.Strings(expectedEntries)
|
|
75
|
+
if strings.Join(entries, ",") != strings.Join(expectedEntries, ",") {
|
|
76
|
+
t.Fatalf("unexpected return package contents: got %v want %v", entries, expectedEntries)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
extractDir := unzipFile(t, result.ReturnPackagePath)
|
|
80
|
+
returnRecordPath := filepath.Join(extractDir, returnJSONName)
|
|
81
|
+
returnRecord, err := exchange.ReadReturn(returnRecordPath)
|
|
82
|
+
if err != nil {
|
|
83
|
+
t.Fatalf("ReadReturn failed: %v", err)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if returnRecord.Protocol != exchange.ReturnProtocolV1 {
|
|
87
|
+
t.Fatalf("unexpected return protocol: got %q want %q", returnRecord.Protocol, exchange.ReturnProtocolV1)
|
|
88
|
+
}
|
|
89
|
+
if returnRecord.ExchangeID != env.exchange.ExchangeID {
|
|
90
|
+
t.Fatalf("unexpected exchange id: got %q want %q", returnRecord.ExchangeID, env.exchange.ExchangeID)
|
|
91
|
+
}
|
|
92
|
+
if returnRecord.BundleFile != returnBundleName {
|
|
93
|
+
t.Fatalf("unexpected bundle file: got %q want %q", returnRecord.BundleFile, returnBundleName)
|
|
94
|
+
}
|
|
95
|
+
if returnRecord.Task.RootRef != env.exchange.Task.RootRef {
|
|
96
|
+
t.Fatalf("unexpected task root ref: got %q want %q", returnRecord.Task.RootRef, env.exchange.Task.RootRef)
|
|
97
|
+
}
|
|
98
|
+
if returnRecord.Task.BaseOID != env.exchange.Task.BaseOID {
|
|
99
|
+
t.Fatalf("unexpected task base oid: got %q want %q", returnRecord.Task.BaseOID, env.exchange.Task.BaseOID)
|
|
100
|
+
}
|
|
101
|
+
if len(returnRecord.Updates) != 1 {
|
|
102
|
+
t.Fatalf("expected exactly one task ref update, got %d", len(returnRecord.Updates))
|
|
103
|
+
}
|
|
104
|
+
update := returnRecord.Updates[0]
|
|
105
|
+
if update.Ref != env.exchange.Task.RootRef {
|
|
106
|
+
t.Fatalf("unexpected updated ref: got %q want %q", update.Ref, env.exchange.Task.RootRef)
|
|
107
|
+
}
|
|
108
|
+
if update.OldOID != baseOID {
|
|
109
|
+
t.Fatalf("unexpected old oid: got %q want %q", update.OldOID, baseOID)
|
|
110
|
+
}
|
|
111
|
+
if update.NewOID != newOID {
|
|
112
|
+
t.Fatalf("unexpected new oid: got %q want %q", update.NewOID, newOID)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
bundlePath := filepath.Join(extractDir, returnBundleName)
|
|
116
|
+
runGit(t, env.exportRoot, "bundle", "verify", bundlePath)
|
|
117
|
+
|
|
118
|
+
success := FormatSuccess(result)
|
|
119
|
+
if !strings.Contains(success, "Return package: "+result.ReturnPackagePath) {
|
|
120
|
+
t.Fatalf("expected absolute return package path in success output, got:\n%s", success)
|
|
121
|
+
}
|
|
122
|
+
if !strings.Contains(success, "sandbox:"+filepath.ToSlash(result.ReturnPackagePath)) {
|
|
123
|
+
t.Fatalf("expected sandbox hint in success output, got:\n%s", success)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
func TestRunReturnAllowsSiblingTaskFamilyBranch(t *testing.T) {
|
|
128
|
+
env := setupPreparedExportRepo(t)
|
|
129
|
+
|
|
130
|
+
taskShort := strings.TrimPrefix(env.exchange.Task.RootRef, "refs/heads/")
|
|
131
|
+
siblingRef := env.exchange.Task.RefPrefix + "/experiment"
|
|
132
|
+
siblingShort := strings.TrimPrefix(siblingRef, "refs/heads/")
|
|
133
|
+
|
|
134
|
+
runGit(t, env.exportRoot, "branch", siblingShort, taskShort)
|
|
135
|
+
runGit(t, env.exportRoot, "checkout", siblingShort)
|
|
136
|
+
appendFile(t, filepath.Join(env.exportRoot, "README.md"), "sibling branch change\n")
|
|
137
|
+
runGit(t, env.exportRoot, "add", "README.md")
|
|
138
|
+
runGit(t, env.exportRoot, "commit", "-m", "experiment branch work")
|
|
139
|
+
runGit(t, env.exportRoot, "checkout", taskShort)
|
|
140
|
+
|
|
141
|
+
result, err := Run(Options{CWD: env.exportRoot})
|
|
142
|
+
if err != nil {
|
|
143
|
+
t.Fatalf("Run returned error: %v", err)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
extractDir := unzipFile(t, result.ReturnPackagePath)
|
|
147
|
+
returnRecord, err := exchange.ReadReturn(filepath.Join(extractDir, returnJSONName))
|
|
148
|
+
if err != nil {
|
|
149
|
+
t.Fatalf("ReadReturn failed: %v", err)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
update, ok := findUpdate(returnRecord.Updates, siblingRef)
|
|
153
|
+
if !ok {
|
|
154
|
+
t.Fatalf("expected sibling task-family ref %q in return updates, got %#v", siblingRef, returnRecord.Updates)
|
|
155
|
+
}
|
|
156
|
+
if update.OldOID != "" {
|
|
157
|
+
t.Fatalf("expected new sibling ref old oid to be empty, got %q", update.OldOID)
|
|
158
|
+
}
|
|
159
|
+
if strings.TrimSpace(update.NewOID) == "" {
|
|
160
|
+
t.Fatalf("expected new sibling ref new oid to be present")
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
runGit(t, env.exportRoot, "bundle", "verify", filepath.Join(extractDir, returnBundleName))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
func TestRunReturnFailsDirtyWorktree(t *testing.T) {
|
|
167
|
+
env := setupPreparedExportRepo(t)
|
|
168
|
+
appendFile(t, filepath.Join(env.exportRoot, "README.md"), "dirty\n")
|
|
169
|
+
|
|
170
|
+
_, err := Run(Options{CWD: env.exportRoot})
|
|
171
|
+
assertErrorContains(t, err, errs.CodeWorktreeDirty,
|
|
172
|
+
"uncommitted or untracked changes",
|
|
173
|
+
"README.md",
|
|
174
|
+
"rerun ./procoder-return",
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
func TestRunReturnFailsUntrackedChanges(t *testing.T) {
|
|
179
|
+
env := setupPreparedExportRepo(t)
|
|
180
|
+
writeFile(t, filepath.Join(env.exportRoot, "scratch.txt"), "untracked\n")
|
|
181
|
+
|
|
182
|
+
_, err := Run(Options{CWD: env.exportRoot})
|
|
183
|
+
assertErrorContains(t, err, errs.CodeWorktreeDirty,
|
|
184
|
+
"?? scratch.txt",
|
|
185
|
+
"rerun ./procoder-return",
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
func TestRunReturnFailsNoNewCommits(t *testing.T) {
|
|
190
|
+
env := setupPreparedExportRepo(t)
|
|
191
|
+
|
|
192
|
+
_, err := Run(Options{CWD: env.exportRoot})
|
|
193
|
+
assertErrorContains(t, err, errs.CodeNoNewCommits,
|
|
194
|
+
"Task branch family: "+env.exchange.Task.RefPrefix+"/*",
|
|
195
|
+
"create at least one commit",
|
|
196
|
+
"rerun ./procoder-return",
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
func TestRunReturnFailsOutOfScopeBranchMutation(t *testing.T) {
|
|
201
|
+
env := setupPreparedExportRepo(t)
|
|
202
|
+
|
|
203
|
+
appendFile(t, filepath.Join(env.exportRoot, "README.md"), "task commit\n")
|
|
204
|
+
runGit(t, env.exportRoot, "add", "README.md")
|
|
205
|
+
runGit(t, env.exportRoot, "commit", "-m", "task commit")
|
|
206
|
+
runGit(t, env.exportRoot, "branch", "-f", "main", "HEAD")
|
|
207
|
+
|
|
208
|
+
_, err := Run(Options{CWD: env.exportRoot})
|
|
209
|
+
assertErrorContains(t, err, errs.CodeRefOutOfScope,
|
|
210
|
+
"outside the allowed task branch family",
|
|
211
|
+
"refs/heads/main",
|
|
212
|
+
env.exchange.Task.RefPrefix+"/*",
|
|
213
|
+
"rerun ./procoder-return",
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
func TestRunReturnFailsTagMutation(t *testing.T) {
|
|
218
|
+
env := setupPreparedExportRepo(t)
|
|
219
|
+
runGit(t, env.exportRoot, "tag", "v-new")
|
|
220
|
+
|
|
221
|
+
_, err := Run(Options{CWD: env.exportRoot})
|
|
222
|
+
assertErrorContains(t, err, errs.CodeRefOutOfScope,
|
|
223
|
+
"Changed tags:",
|
|
224
|
+
"refs/tags/v-new",
|
|
225
|
+
"rerun ./procoder-return",
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
func TestRunReturnFailsNonDescendantTaskRef(t *testing.T) {
|
|
230
|
+
env := setupPreparedExportRepo(t)
|
|
231
|
+
|
|
232
|
+
treeOID := strings.TrimSpace(runGit(t, env.exportRoot, "write-tree"))
|
|
233
|
+
orphanOID := strings.TrimSpace(runGitInput(t, env.exportRoot, "orphan task ref\n", "commit-tree", treeOID))
|
|
234
|
+
runGit(t, env.exportRoot, "update-ref", env.exchange.Task.RootRef, orphanOID)
|
|
235
|
+
|
|
236
|
+
_, err := Run(Options{CWD: env.exportRoot})
|
|
237
|
+
assertErrorContains(t, err, errs.CodeRefOutOfScope,
|
|
238
|
+
"does not descend from the task base commit",
|
|
239
|
+
"Ref: "+env.exchange.Task.RootRef,
|
|
240
|
+
"rerun ./procoder-return",
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
func TestRunReturnFailsMissingExchangeMetadata(t *testing.T) {
|
|
245
|
+
env := setupPreparedExportRepo(t)
|
|
246
|
+
exchangePath := filepath.Join(env.exportRoot, ".git", "procoder", "exchange.json")
|
|
247
|
+
if err := os.Remove(exchangePath); err != nil {
|
|
248
|
+
t.Fatalf("remove exchange metadata failed: %v", err)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
_, err := Run(Options{CWD: env.exportRoot})
|
|
252
|
+
assertErrorContains(t, err, errs.CodeInvalidExchange,
|
|
253
|
+
"Path: .git/procoder/exchange.json",
|
|
254
|
+
"run ./procoder-return only inside a repository created by procoder prepare",
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
func TestRunReturnFailsInvalidExchangeMetadata(t *testing.T) {
|
|
259
|
+
env := setupPreparedExportRepo(t)
|
|
260
|
+
exchangePath := filepath.Join(env.exportRoot, ".git", "procoder", "exchange.json")
|
|
261
|
+
writeFile(t, exchangePath, "{ invalid json\n")
|
|
262
|
+
|
|
263
|
+
_, err := Run(Options{CWD: env.exportRoot})
|
|
264
|
+
assertErrorContains(t, err, errs.CodeInvalidExchange,
|
|
265
|
+
"Path: .git/procoder/exchange.json",
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
type preparedEnv struct {
|
|
270
|
+
sourceRepo *gitrepo.Repo
|
|
271
|
+
exportRoot string
|
|
272
|
+
exchange exchange.Exchange
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
func setupPreparedExportRepo(t *testing.T) preparedEnv {
|
|
276
|
+
t.Helper()
|
|
277
|
+
|
|
278
|
+
source := gitrepo.New(t)
|
|
279
|
+
source.WriteFile("README.md", "source content\n")
|
|
280
|
+
source.CommitAll("initial")
|
|
281
|
+
source.Git("branch", "feature/context")
|
|
282
|
+
source.Git("tag", "v1.0.0")
|
|
283
|
+
|
|
284
|
+
helperPath := writeHelperBinary(t)
|
|
285
|
+
prepareResult, err := prepare.Run(prepare.Options{
|
|
286
|
+
CWD: source.Dir,
|
|
287
|
+
ToolVersion: "0.1.0-test",
|
|
288
|
+
HelperPath: helperPath,
|
|
289
|
+
Now: func() time.Time { return time.Date(2026, time.March, 20, 11, 30, 15, 0, time.UTC) },
|
|
290
|
+
Random: bytes.NewReader([]byte{0x0a, 0x0b, 0x0c}),
|
|
291
|
+
})
|
|
292
|
+
if err != nil {
|
|
293
|
+
t.Fatalf("prepare.Run failed: %v", err)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
exportRoot := unzipTaskPackage(t, prepareResult.TaskPackagePath)
|
|
297
|
+
ex, err := exchange.ReadExchange(filepath.Join(exportRoot, ".git", "procoder", "exchange.json"))
|
|
298
|
+
if err != nil {
|
|
299
|
+
t.Fatalf("ReadExchange(export) failed: %v", err)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return preparedEnv{
|
|
303
|
+
sourceRepo: source,
|
|
304
|
+
exportRoot: exportRoot,
|
|
305
|
+
exchange: ex,
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
func assertErrorContains(t *testing.T, err error, wantCode errs.Code, contains ...string) {
|
|
310
|
+
t.Helper()
|
|
311
|
+
|
|
312
|
+
if err == nil {
|
|
313
|
+
t.Fatalf("expected error with code %s", wantCode)
|
|
314
|
+
}
|
|
315
|
+
typed, ok := errs.As(err)
|
|
316
|
+
if !ok {
|
|
317
|
+
t.Fatalf("expected typed error, got %T (%v)", err, err)
|
|
318
|
+
}
|
|
319
|
+
if typed.Code != wantCode {
|
|
320
|
+
t.Fatalf("unexpected error code: got %s want %s", typed.Code, wantCode)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
formatted := output.FormatError(err)
|
|
324
|
+
for _, fragment := range contains {
|
|
325
|
+
if !strings.Contains(formatted, fragment) {
|
|
326
|
+
t.Fatalf("expected error output to contain %q, got:\n%s", fragment, formatted)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
func writeHelperBinary(t *testing.T) string {
|
|
332
|
+
t.Helper()
|
|
333
|
+
|
|
334
|
+
path := filepath.Join(t.TempDir(), "procoder-return")
|
|
335
|
+
writeFile(t, path, "#!/bin/sh\nexit 0\n")
|
|
336
|
+
if err := os.Chmod(path, 0o755); err != nil {
|
|
337
|
+
t.Fatalf("chmod helper failed: %v", err)
|
|
338
|
+
}
|
|
339
|
+
return path
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
func listZipEntries(t *testing.T, zipPath string) []string {
|
|
343
|
+
t.Helper()
|
|
344
|
+
|
|
345
|
+
reader, err := zip.OpenReader(zipPath)
|
|
346
|
+
if err != nil {
|
|
347
|
+
t.Fatalf("zip.OpenReader failed: %v", err)
|
|
348
|
+
}
|
|
349
|
+
defer reader.Close()
|
|
350
|
+
|
|
351
|
+
names := make([]string, 0, len(reader.File))
|
|
352
|
+
for _, f := range reader.File {
|
|
353
|
+
names = append(names, f.Name)
|
|
354
|
+
}
|
|
355
|
+
return names
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
func unzipFile(t *testing.T, zipPath string) string {
|
|
359
|
+
t.Helper()
|
|
360
|
+
|
|
361
|
+
reader, err := zip.OpenReader(zipPath)
|
|
362
|
+
if err != nil {
|
|
363
|
+
t.Fatalf("zip.OpenReader failed: %v", err)
|
|
364
|
+
}
|
|
365
|
+
defer reader.Close()
|
|
366
|
+
|
|
367
|
+
dest := t.TempDir()
|
|
368
|
+
for _, file := range reader.File {
|
|
369
|
+
targetPath := filepath.Join(dest, file.Name)
|
|
370
|
+
safePrefix := filepath.Clean(dest) + string(os.PathSeparator)
|
|
371
|
+
if !strings.HasPrefix(filepath.Clean(targetPath), safePrefix) {
|
|
372
|
+
t.Fatalf("zip entry escapes destination: %q", file.Name)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if file.FileInfo().IsDir() {
|
|
376
|
+
if err := os.MkdirAll(targetPath, 0o755); err != nil {
|
|
377
|
+
t.Fatalf("mkdir failed: %v", err)
|
|
378
|
+
}
|
|
379
|
+
continue
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
|
383
|
+
t.Fatalf("mkdir file dir failed: %v", err)
|
|
384
|
+
}
|
|
385
|
+
in, err := file.Open()
|
|
386
|
+
if err != nil {
|
|
387
|
+
t.Fatalf("open zip entry failed: %v", err)
|
|
388
|
+
}
|
|
389
|
+
out, err := os.OpenFile(targetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, file.Mode())
|
|
390
|
+
if err != nil {
|
|
391
|
+
_ = in.Close()
|
|
392
|
+
t.Fatalf("open extracted file failed: %v", err)
|
|
393
|
+
}
|
|
394
|
+
if _, err := io.Copy(out, in); err != nil {
|
|
395
|
+
_ = out.Close()
|
|
396
|
+
_ = in.Close()
|
|
397
|
+
t.Fatalf("copy extracted file failed: %v", err)
|
|
398
|
+
}
|
|
399
|
+
_ = out.Close()
|
|
400
|
+
_ = in.Close()
|
|
401
|
+
}
|
|
402
|
+
return dest
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
func unzipTaskPackage(t *testing.T, zipPath string) string {
|
|
406
|
+
t.Helper()
|
|
407
|
+
|
|
408
|
+
dest := unzipFile(t, zipPath)
|
|
409
|
+
return filepath.Join(dest, filepath.Base(filepath.Dir(zipPath)))
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
func runGit(t *testing.T, dir string, args ...string) string {
|
|
413
|
+
t.Helper()
|
|
414
|
+
|
|
415
|
+
cmd := exec.Command("git", args...)
|
|
416
|
+
cmd.Dir = dir
|
|
417
|
+
var stdout bytes.Buffer
|
|
418
|
+
var stderr bytes.Buffer
|
|
419
|
+
cmd.Stdout = &stdout
|
|
420
|
+
cmd.Stderr = &stderr
|
|
421
|
+
|
|
422
|
+
if err := cmd.Run(); err != nil {
|
|
423
|
+
t.Fatalf("git %s failed:\nstdout:\n%s\nstderr:\n%s\nerr:%v", strings.Join(args, " "), stdout.String(), stderr.String(), err)
|
|
424
|
+
}
|
|
425
|
+
return stdout.String()
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
func runGitInput(t *testing.T, dir, input string, args ...string) string {
|
|
429
|
+
t.Helper()
|
|
430
|
+
|
|
431
|
+
cmd := exec.Command("git", args...)
|
|
432
|
+
cmd.Dir = dir
|
|
433
|
+
cmd.Stdin = strings.NewReader(input)
|
|
434
|
+
var stdout bytes.Buffer
|
|
435
|
+
var stderr bytes.Buffer
|
|
436
|
+
cmd.Stdout = &stdout
|
|
437
|
+
cmd.Stderr = &stderr
|
|
438
|
+
|
|
439
|
+
if err := cmd.Run(); err != nil {
|
|
440
|
+
t.Fatalf("git %s failed:\nstdout:\n%s\nstderr:\n%s\nerr:%v", strings.Join(args, " "), stdout.String(), stderr.String(), err)
|
|
441
|
+
}
|
|
442
|
+
return stdout.String()
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
func appendFile(t *testing.T, path, extra string) {
|
|
446
|
+
t.Helper()
|
|
447
|
+
|
|
448
|
+
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
449
|
+
if err != nil {
|
|
450
|
+
t.Fatalf("open %s for append failed: %v", path, err)
|
|
451
|
+
}
|
|
452
|
+
if _, err := f.WriteString(extra); err != nil {
|
|
453
|
+
_ = f.Close()
|
|
454
|
+
t.Fatalf("append to %s failed: %v", path, err)
|
|
455
|
+
}
|
|
456
|
+
if err := f.Close(); err != nil {
|
|
457
|
+
t.Fatalf("close %s failed: %v", path, err)
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
func writeFile(t *testing.T, path, content string) {
|
|
462
|
+
t.Helper()
|
|
463
|
+
|
|
464
|
+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
465
|
+
t.Fatalf("mkdir %s failed: %v", filepath.Dir(path), err)
|
|
466
|
+
}
|
|
467
|
+
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
468
|
+
t.Fatalf("write %s failed: %v", path, err)
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
func mustReadFile(t *testing.T, path string) string {
|
|
473
|
+
t.Helper()
|
|
474
|
+
|
|
475
|
+
data, err := os.ReadFile(path)
|
|
476
|
+
if err != nil {
|
|
477
|
+
t.Fatalf("read %s failed: %v", path, err)
|
|
478
|
+
}
|
|
479
|
+
return string(data)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
func findUpdate(updates []exchange.RefUpdate, ref string) (exchange.RefUpdate, bool) {
|
|
483
|
+
for _, update := range updates {
|
|
484
|
+
if update.Ref == ref {
|
|
485
|
+
return update, true
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return exchange.RefUpdate{}, false
|
|
489
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
package gitrepo
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
stderrors "errors"
|
|
6
|
+
"os"
|
|
7
|
+
"os/exec"
|
|
8
|
+
"path/filepath"
|
|
9
|
+
"strings"
|
|
10
|
+
"testing"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
type Repo struct {
|
|
14
|
+
t *testing.T
|
|
15
|
+
Dir string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type Result struct {
|
|
19
|
+
Stdout string
|
|
20
|
+
Stderr string
|
|
21
|
+
ExitCode int
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
func New(t *testing.T) *Repo {
|
|
25
|
+
t.Helper()
|
|
26
|
+
|
|
27
|
+
dir := t.TempDir()
|
|
28
|
+
r := &Repo{
|
|
29
|
+
t: t,
|
|
30
|
+
Dir: dir,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
result := r.Run("init", "--initial-branch=main")
|
|
34
|
+
if result.ExitCode != 0 {
|
|
35
|
+
result = r.Run("init")
|
|
36
|
+
if result.ExitCode != 0 {
|
|
37
|
+
t.Fatalf("git init failed:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
|
38
|
+
}
|
|
39
|
+
r.Git("branch", "-M", "main")
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
r.Git("config", "user.name", "Test User")
|
|
43
|
+
r.Git("config", "user.email", "test@example.com")
|
|
44
|
+
r.Git("config", "commit.gpgsign", "false")
|
|
45
|
+
|
|
46
|
+
return r
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
func (r *Repo) WriteFile(relPath, contents string) string {
|
|
50
|
+
r.t.Helper()
|
|
51
|
+
|
|
52
|
+
fullPath := filepath.Join(r.Dir, relPath)
|
|
53
|
+
if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
|
|
54
|
+
r.t.Fatalf("mkdir for %s failed: %v", relPath, err)
|
|
55
|
+
}
|
|
56
|
+
if err := os.WriteFile(fullPath, []byte(contents), 0o644); err != nil {
|
|
57
|
+
r.t.Fatalf("write %s failed: %v", relPath, err)
|
|
58
|
+
}
|
|
59
|
+
return fullPath
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func (r *Repo) CommitAll(message string) string {
|
|
63
|
+
r.t.Helper()
|
|
64
|
+
|
|
65
|
+
r.Git("add", "-A")
|
|
66
|
+
r.Git("commit", "-m", message)
|
|
67
|
+
return strings.TrimSpace(r.Git("rev-parse", "HEAD"))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
func (r *Repo) Git(args ...string) string {
|
|
71
|
+
r.t.Helper()
|
|
72
|
+
|
|
73
|
+
result := r.Run(args...)
|
|
74
|
+
if result.ExitCode != 0 {
|
|
75
|
+
r.t.Fatalf(
|
|
76
|
+
"git %s failed with code %d\nstdout:\n%s\nstderr:\n%s",
|
|
77
|
+
strings.Join(args, " "),
|
|
78
|
+
result.ExitCode,
|
|
79
|
+
result.Stdout,
|
|
80
|
+
result.Stderr,
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
return result.Stdout
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func (r *Repo) Run(args ...string) Result {
|
|
87
|
+
r.t.Helper()
|
|
88
|
+
|
|
89
|
+
cmd := exec.Command("git", args...)
|
|
90
|
+
cmd.Dir = r.Dir
|
|
91
|
+
var stdout bytes.Buffer
|
|
92
|
+
var stderr bytes.Buffer
|
|
93
|
+
cmd.Stdout = &stdout
|
|
94
|
+
cmd.Stderr = &stderr
|
|
95
|
+
|
|
96
|
+
err := cmd.Run()
|
|
97
|
+
return Result{
|
|
98
|
+
Stdout: stdout.String(),
|
|
99
|
+
Stderr: stderr.String(),
|
|
100
|
+
ExitCode: exitCode(err),
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
func exitCode(err error) int {
|
|
105
|
+
if err == nil {
|
|
106
|
+
return 0
|
|
107
|
+
}
|
|
108
|
+
var exitErr *exec.ExitError
|
|
109
|
+
if stderrors.As(err, &exitErr) {
|
|
110
|
+
return exitErr.ExitCode()
|
|
111
|
+
}
|
|
112
|
+
return -1
|
|
113
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "procoder-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Offline Git exchange CLI for local repositories and locked-down ChatGPT coding containers",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "amxv",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/amxv/procoder.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/amxv/procoder#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/amxv/procoder/issues"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"procoder": "bin/procoder.js"
|
|
17
|
+
},
|
|
18
|
+
"config": {
|
|
19
|
+
"cliBinaryName": "procoder"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"bin/procoder.js",
|
|
23
|
+
"scripts/postinstall.js",
|
|
24
|
+
"cmd",
|
|
25
|
+
"internal",
|
|
26
|
+
"go.mod",
|
|
27
|
+
"README.md",
|
|
28
|
+
"AGENTS.md",
|
|
29
|
+
"CONTRIBUTORS.md",
|
|
30
|
+
"Makefile"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"postinstall": "node scripts/postinstall.js",
|
|
34
|
+
"test": "node --test scripts/postinstall.test.js && node --check bin/procoder.js && node --check scripts/postinstall.js",
|
|
35
|
+
"lint": "npm run test"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=18"
|
|
39
|
+
},
|
|
40
|
+
"keywords": [
|
|
41
|
+
"git",
|
|
42
|
+
"offline",
|
|
43
|
+
"cli",
|
|
44
|
+
"chatgpt",
|
|
45
|
+
"exchange"
|
|
46
|
+
]
|
|
47
|
+
}
|