1package gitcompat
2
3import (
4 "bufio"
5 "encoding/hex"
6 "fmt"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "sort"
11 "strings"
12 "time"
13
14 "arche/internal/object"
15 "arche/internal/repo"
16 "arche/internal/store"
17)
18
19const mapFileName = "git_map"
20
21func IsGitRepo(repoRoot string) bool {
22 _, err := os.Stat(filepath.Join(repoRoot, ".git"))
23 return err == nil
24}
25
26func GitInit(dir string) error {
27 cmd := exec.Command("git", "init", "-q", dir)
28 cmd.Stdout = os.Stdout
29 cmd.Stderr = os.Stderr
30 return cmd.Run()
31}
32
33func EnsureGitIgnore(repoRoot string) error {
34 path := filepath.Join(repoRoot, ".gitignore")
35 var existing string
36 if data, err := os.ReadFile(path); err == nil {
37 existing = string(data)
38 }
39 for _, line := range strings.Split(existing, "\n") {
40 t := strings.TrimSpace(line)
41 if t == ".arche/" || t == ".arche" {
42 return nil
43 }
44 }
45 f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
46 if err != nil {
47 return err
48 }
49 defer f.Close()
50 if existing != "" && !strings.HasSuffix(existing, "\n") {
51 fmt.Fprintln(f)
52 }
53 _, err = fmt.Fprintln(f, ".arche/")
54 return err
55}
56
57func LoadMap(archeDir string) (map[string]string, error) {
58 path := filepath.Join(archeDir, mapFileName)
59 f, err := os.Open(path)
60 if os.IsNotExist(err) {
61 return map[string]string{}, nil
62 }
63 if err != nil {
64 return nil, err
65 }
66 defer f.Close()
67 m := map[string]string{}
68 sc := bufio.NewScanner(f)
69 for sc.Scan() {
70 line := sc.Text()
71 if line == "" || strings.HasPrefix(line, "#") {
72 continue
73 }
74 parts := strings.Fields(line)
75 if len(parts) == 2 {
76 m[parts[0]] = parts[1]
77 }
78 }
79 return m, sc.Err()
80}
81
82func SaveMap(archeDir string, m map[string]string) error {
83 path := filepath.Join(archeDir, mapFileName)
84 f, err := os.Create(path)
85 if err != nil {
86 return err
87 }
88 defer f.Close()
89 keys := make([]string, 0, len(m))
90 for k := range m {
91 keys = append(keys, k)
92 }
93 sort.Strings(keys)
94 for _, k := range keys {
95 if _, err := fmt.Fprintf(f, "%s %s\n", k, m[k]); err != nil {
96 return err
97 }
98 }
99 return nil
100}
101
102func MirrorCommit(repoRoot string, r *repo.Repo, commitID [32]byte) (string, error) {
103 c, err := r.ReadCommit(commitID)
104 if err != nil {
105 return "", fmt.Errorf("read arche commit: %w", err)
106 }
107 archeHex := hex.EncodeToString(commitID[:])
108
109 m, err := LoadMap(r.ArcheDir())
110 if err != nil {
111 return "", err
112 }
113
114 if out, err := exec.Command("git", "-C", repoRoot, "add", "-A").CombinedOutput(); err != nil {
115 return "", fmt.Errorf("git add -A: %w\n%s", err, out)
116 }
117
118 msg := c.Message
119 if msg == "" {
120 msg = "(empty)"
121 }
122 ts := c.Author.Timestamp.Format(time.RFC3339)
123
124 commitCmd := exec.Command("git", "-C", repoRoot, "commit", "--allow-empty", "-m", msg)
125 commitCmd.Env = append(os.Environ(),
126 "GIT_AUTHOR_NAME="+c.Author.Name,
127 "GIT_AUTHOR_EMAIL="+c.Author.Email,
128 "GIT_AUTHOR_DATE="+ts,
129 "GIT_COMMITTER_NAME="+c.Committer.Name,
130 "GIT_COMMITTER_EMAIL="+c.Committer.Email,
131 "GIT_COMMITTER_DATE="+ts,
132 )
133 _, _ = commitCmd.Output()
134
135 headOut, err := exec.Command("git", "-C", repoRoot, "rev-parse", "HEAD").Output()
136 if err != nil {
137 return "", fmt.Errorf("git rev-parse HEAD: %w", err)
138 }
139 gitHash := strings.TrimSpace(string(headOut))
140
141 m[archeHex] = gitHash
142 if err := SaveMap(r.ArcheDir(), m); err != nil {
143 return "", err
144 }
145 return gitHash, nil
146}
147
148func CheckoutCommit(repoRoot, archeDir string, commitID [32]byte) error {
149 m, err := LoadMap(archeDir)
150 if err != nil {
151 return err
152 }
153 archeHex := hex.EncodeToString(commitID[:])
154 gitHash, ok := m[archeHex]
155 if !ok {
156 // No mirror yet – skip silently.
157 return nil
158 }
159
160 out, err := exec.Command("git", "-C", repoRoot, "checkout", "-q", gitHash).CombinedOutput()
161 if err != nil {
162 return fmt.Errorf("git checkout: %w\n%s", err, out)
163 }
164 return nil
165}
166
167func SyncPush(repoRoot, remote string) error {
168 cmd := exec.Command("git", "-C", repoRoot, "push", remote)
169 cmd.Stdout = os.Stdout
170 cmd.Stderr = os.Stderr
171 return cmd.Run()
172}
173
174func SyncPull(repoRoot, remote string) error {
175 cmd := exec.Command("git", "-C", repoRoot, "pull", "--rebase", remote)
176 cmd.Stdout = os.Stdout
177 cmd.Stderr = os.Stderr
178 return cmd.Run()
179}
180
181func ImportFromGit(repoRoot string, r *repo.Repo) error {
182 return importGitHistory(repoRoot, r, true)
183}
184
185func ImportFromGitOnce(repoRoot string, r *repo.Repo) error {
186 return importGitHistory(repoRoot, r, false)
187}
188
189func importGitHistory(repoRoot string, r *repo.Repo, writeMap bool) error {
190 raw, err := exec.Command("git", "-C", repoRoot, "log",
191 "--reverse", "--topo-order",
192 "--format=%H%x00%P%x00%an%x00%ae%x00%aI%x00%B%x00",
193 ).Output()
194 if err != nil {
195 return fmt.Errorf("git log: %w", err)
196 }
197
198 gitToArche := map[string][32]byte{}
199 archeMap := map[string]string{}
200 var lastArcheID [32]byte
201
202 records := strings.Split(string(raw), "\x00\n")
203 for _, rec := range records {
204 rec = strings.TrimSpace(rec)
205 if rec == "" {
206 continue
207 }
208 parts := strings.SplitN(rec, "\x00", 6)
209 if len(parts) < 5 {
210 continue
211 }
212 gitSHA := strings.TrimSpace(parts[0])
213 parents := strings.Fields(strings.TrimSpace(parts[1]))
214 authorName := parts[2]
215 authorEmail := parts[3]
216 authorDateStr := strings.TrimSpace(parts[4])
217 message := strings.TrimSpace(parts[5])
218 if message == "" {
219 message = "(imported from git)"
220 }
221
222 ts, err := time.Parse(time.RFC3339, authorDateStr)
223 if err != nil {
224 ts = time.Now()
225 }
226
227 tx, err := r.Store.Begin()
228 if err != nil {
229 return err
230 }
231
232 treeID, err := importGitTreeInTx(repoRoot, r, tx, gitSHA)
233 if err != nil {
234 r.Store.Rollback(tx)
235 return fmt.Errorf("import tree for %s: %w", gitSHA[:8], err)
236 }
237
238 var archeParents [][32]byte
239 for _, p := range parents {
240 if aid, ok := gitToArche[p]; ok {
241 archeParents = append(archeParents, aid)
242 }
243 }
244
245 sig := object.Signature{Name: authorName, Email: authorEmail, Timestamp: ts}
246 changeID, err := r.Store.AllocChangeID(tx)
247 if err != nil {
248 r.Store.Rollback(tx)
249 return err
250 }
251
252 c := &object.Commit{
253 TreeID: treeID,
254 Parents: archeParents,
255 ChangeID: changeID,
256 Author: sig,
257 Committer: sig,
258 Message: message,
259 Phase: object.PhasePublic,
260 }
261 commitID, err := repo.WriteCommitTx(r.Store, tx, c)
262 if err != nil {
263 r.Store.Rollback(tx)
264 return err
265 }
266 if err := r.Store.SetChangeCommit(tx, changeID, commitID); err != nil {
267 r.Store.Rollback(tx)
268 return err
269 }
270 if err := r.Store.Commit(tx); err != nil {
271 return err
272 }
273
274 gitToArche[gitSHA] = commitID
275 archeMap[hex.EncodeToString(commitID[:])] = gitSHA
276 lastArcheID = commitID
277
278 fmt.Printf(" imported %s -> ch:%s\n", gitSHA[:8], changeID[:8])
279 }
280
281 if writeMap {
282 if err := SaveMap(r.ArcheDir(), archeMap); err != nil {
283 return err
284 }
285 }
286
287 if lastArcheID == (object.ZeroID) {
288 return nil
289 }
290
291 lastCommit, err := r.ReadCommit(lastArcheID)
292 if err != nil {
293 return err
294 }
295
296 branchOut, _ := exec.Command("git", "-C", repoRoot, "branch", "--show-current").Output()
297 bmName := strings.TrimSpace(string(branchOut))
298 if bmName == "" {
299 bmName = "main"
300 }
301
302 bm := store.Bookmark{Name: bmName, CommitID: lastArcheID}
303 tx, err := r.Store.Begin()
304 if err != nil {
305 return err
306 }
307 if err := r.Store.SetBookmark(tx, bm); err != nil {
308 r.Store.Rollback(tx)
309 return err
310 }
311 if err := r.Store.Commit(tx); err != nil {
312 return err
313 }
314
315 return r.WriteHead(object.FormatChangeID(lastCommit.ChangeID))
316}
317
318type importEntry struct {
319 relPath string
320 blobID [32]byte
321 mode object.EntryMode
322}
323
324func importGitTreeInTx(repoRoot string, r *repo.Repo, tx *store.Tx, gitCommitSHA string) ([32]byte, error) {
325 out, err := exec.Command("git", "-C", repoRoot, "ls-tree", "-r", gitCommitSHA).Output()
326 if err != nil {
327 return [32]byte{}, fmt.Errorf("git ls-tree: %w", err)
328 }
329
330 var entries []importEntry
331 for _, line := range strings.Split(string(out), "\n") {
332 line = strings.TrimSpace(line)
333 if line == "" {
334 continue
335 }
336 tabIdx := strings.Index(line, "\t")
337 if tabIdx < 0 {
338 continue
339 }
340 meta := strings.Fields(line[:tabIdx])
341 if len(meta) < 3 {
342 continue
343 }
344 gitMode := meta[0]
345 gitBlobSHA := meta[2]
346 relPath := line[tabIdx+1:]
347
348 content, err := exec.Command("git", "-C", repoRoot, "cat-file", "blob", gitBlobSHA).Output()
349 if err != nil {
350 return [32]byte{}, fmt.Errorf("cat-file %s: %w", gitBlobSHA, err)
351 }
352
353 blob := &object.Blob{Content: content}
354 blobID, err := repo.WriteBlobTx(r.Store, tx, blob)
355 if err != nil {
356 return [32]byte{}, err
357 }
358
359 mode := object.ModeFile
360 switch gitMode {
361 case "100755":
362 mode = object.ModeExec
363 case "120000":
364 mode = object.ModeSymlink
365 }
366
367 entries = append(entries, importEntry{relPath: relPath, blobID: blobID, mode: mode})
368 }
369
370 return buildNestedTree(r, tx, entries)
371}
372
373func buildNestedTree(r *repo.Repo, tx *store.Tx, entries []importEntry) ([32]byte, error) {
374 tree := &object.Tree{}
375
376 dirs := map[string][]importEntry{}
377 for _, e := range entries {
378 slash := strings.Index(e.relPath, "/")
379 if slash < 0 {
380 tree.Entries = append(tree.Entries, object.TreeEntry{
381 Name: e.relPath,
382 Mode: e.mode,
383 ObjectID: e.blobID,
384 })
385 } else {
386 dirName := e.relPath[:slash]
387 dirs[dirName] = append(dirs[dirName], importEntry{
388 relPath: e.relPath[slash+1:],
389 blobID: e.blobID,
390 mode: e.mode,
391 })
392 }
393 }
394
395 dirNames := make([]string, 0, len(dirs))
396 for d := range dirs {
397 dirNames = append(dirNames, d)
398 }
399 sort.Strings(dirNames)
400
401 for _, dirName := range dirNames {
402 subID, err := buildNestedTree(r, tx, dirs[dirName])
403 if err != nil {
404 return [32]byte{}, err
405 }
406 tree.Entries = append(tree.Entries, object.TreeEntry{
407 Name: dirName,
408 Mode: object.ModeDir,
409 ObjectID: subID,
410 })
411 }
412
413 return repo.WriteTreeTx(r.Store, tx, tree)
414}