gitignore.go

v0.3.0
Doc Versions Source
1
package sync
2
3
import (
4
	"context"
5
	"path"
6
	"sort"
7
	"strings"
8
9
	"go.bigb.es/auxilia/culpa"
10
	"go.bigb.es/auxilia/legatus"
11
)
12
13
// Managed-block markers. EnsureGitignore owns only the lines between them; any
14
// content outside the block is preserved verbatim across rewrites.
15
const (
16
	beginMarker = "# >>> legatus managed >>>"
17
	endMarker   = "# <<< legatus managed <<<"
18
)
19
20
// EnsureGitignore writes (idempotently) a .gitignore at remoteRoot containing the
21
// given entries, deduped and sorted, inside the managed marker block. Manual
22
// lines outside the block survive. It performs no write when the managed block
23
// already matches, reporting changed=false.
24
func EnsureGitignore(ctx context.Context, conn legatus.Conn, remoteRoot string, entries []string) (bool, error) {
25
	gitignore := path.Join(remoteRoot, ".gitignore")
26
	merged, changed, err := planGitignore(ctx, conn, gitignore, entries)
27
	if err != nil {
28
		return false, err
29
	}
30
	if !changed {
31
		return false, nil
32
	}
33
	if err := conn.Put(ctx, strings.NewReader(merged), gitignore, 0o644); err != nil {
34
		return false, culpa.Wrapf(err, "sync: write %s", gitignore)
35
	}
36
	return true, nil
37
}
38
39
// planGitignore reads the current .gitignore (empty when absent) and computes the
40
// merged content plus whether it differs — the shared read+merge used by both
41
// EnsureGitignore (which writes) and Gitignore.Check (which must not).
42
func planGitignore(ctx context.Context, conn legatus.Conn, gitignore string, entries []string) (string, bool, error) {
43
	existing, _, err := readRemoteFile(ctx, conn, gitignore)
44
	if err != nil {
45
		return "", false, err
46
	}
47
	merged, changed := mergeGitignore(existing, entries)
48
	return merged, changed, nil
49
}
50
51
// mergeGitignore splices a deduped+sorted managed block into existing, preserving
52
// bytes before and after the markers. changed reports whether the result differs.
53
func mergeGitignore(existing string, entries []string) (string, bool) {
54
	block := renderManagedBlock(entries)
55
	lines := strings.Split(existing, "\n")
56
57
	begin, end := -1, -1
58
	for i, l := range lines {
59
		switch strings.TrimSpace(l) {
60
		case beginMarker:
61
			if begin == -1 {
62
				begin = i
63
			}
64
		case endMarker:
65
			if begin != -1 && i >= begin && end == -1 {
66
				end = i
67
			}
68
		}
69
	}
70
71
	var merged string
72
	if begin != -1 && end != -1 {
73
		parts := append([]string{}, lines[:begin]...)
74
		parts = append(parts, strings.Split(block, "\n")...)
75
		parts = append(parts, lines[end+1:]...)
76
		merged = strings.Join(parts, "\n")
77
	} else if existing == "" {
78
		merged = block + "\n"
79
	} else {
80
		sep := existing
81
		if !strings.HasSuffix(sep, "\n") {
82
			sep += "\n"
83
		}
84
		merged = sep + block + "\n"
85
	}
86
	return merged, merged != existing
87
}
88
89
// renderManagedBlock builds the marker-bracketed block from deduped, sorted entries.
90
func renderManagedBlock(entries []string) string {
91
	seen := map[string]bool{}
92
	uniq := make([]string, 0, len(entries))
93
	for _, e := range entries {
94
		if e == "" || seen[e] {
95
			continue
96
		}
97
		seen[e] = true
98
		uniq = append(uniq, e)
99
	}
100
	sort.Strings(uniq)
101
102
	var b strings.Builder
103
	b.WriteString(beginMarker + "\n")
104
	for _, e := range uniq {
105
		b.WriteString(e + "\n")
106
	}
107
	b.WriteString(endMarker)
108
	return b.String()
109
}
110
111
// readRemoteFile returns a remote file's raw content and whether it exists. An
112
// absent file is (",", false, nil), not an error; a real transport error raises.
113
// Unlike runShell it does not trim, so byte-exact idempotency holds.
114
func readRemoteFile(ctx context.Context, conn legatus.Conn, remote string) (string, bool, error) {
115
	res, err := conn.Exec(ctx, legatus.Cmd{Path: "test", Args: []string{"-f", remote}})
116
	if err != nil {
117
		return "", false, culpa.Wrapf(err, "sync: stat %s", remote)
118
	}
119
	if res.ExitCode != 0 {
120
		return "", false, nil
121
	}
122
	data, err := downloadRemote(ctx, conn, remote)
123
	if err != nil {
124
		return "", false, err
125
	}
126
	return string(data), true, nil
127
}
128
129
// Gitignore is the step form of EnsureGitignore, so a deploy Flow can ensure the
130
// managed .gitignore declaratively alongside GitGuard.
131
type Gitignore struct {
132
	// RemoteRoot is the remote repository directory holding the .gitignore.
133
	RemoteRoot string
134
	// Entries are the patterns written inside the managed block.
135
	Entries []string
136
}
137
138
// Name identifies the step.
139
func (g *Gitignore) Name() string { return "sync.gitignore" }
140
141
// Check reports whether the managed block would change.
142
func (g *Gitignore) Check(fc *legatus.FlowCtx) (bool, error) {
143
	_, changed, err := planGitignore(fc.Ctx(), fc.Conn(), path.Join(g.RemoteRoot, ".gitignore"), g.Entries)
144
	return changed, err
145
}
146
147
// Apply writes the managed block when it differs, delegating to EnsureGitignore.
148
func (g *Gitignore) Apply(fc *legatus.FlowCtx) (legatus.Outcome, error) {
149
	changed, err := EnsureGitignore(fc.Ctx(), fc.Conn(), g.RemoteRoot, g.Entries)
150
	if err != nil {
151
		return legatus.Outcome{}, err
152
	}
153
	msg := "gitignore up to date"
154
	if changed {
155
		msg = "gitignore updated"
156
	}
157
	return legatus.Outcome{Changed: changed, Message: msg}, nil
158
}
159

Source Files