arche / internal/gitcompat/gitcompat.go

commit 154431fd
  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}