A vibe coded tangled fork which supports pijul.
1package pijul
2
3import (
4 "bufio"
5 "bytes"
6 "encoding/json"
7 "fmt"
8 "strconv"
9 "strings"
10 "time"
11)
12
13// Change represents a Pijul change (analogous to a Git commit)
14type Change struct {
15 // Hash is the unique identifier for this change (base32 encoded)
16 Hash string `json:"hash"`
17
18 // Authors who created this change
19 Authors []Author `json:"authors"`
20
21 // Message is the change description
22 Message string `json:"message"`
23
24 // Timestamp when the change was recorded
25 Timestamp time.Time `json:"timestamp"`
26
27 // Dependencies are hashes of changes this change depends on
28 Dependencies []string `json:"dependencies,omitempty"`
29
30 // Channel where this change exists
31 Channel string `json:"channel,omitempty"`
32}
33
34// Author represents a change author
35type Author struct {
36 Name string `json:"name"`
37 Email string `json:"email,omitempty"`
38}
39
40// Changes returns a list of changes in the repository
41// offset and limit control pagination
42func (p *PijulRepo) Changes(offset, limit int) ([]Change, error) {
43 args := []string{"--offset", strconv.Itoa(offset), "--limit", strconv.Itoa(limit)}
44
45 if p.channelName != "" {
46 args = append(args, "--channel", p.channelName)
47 }
48
49 output, err := p.log(args...)
50 if err != nil {
51 if isNoChangesError(err) {
52 return []Change{}, nil
53 }
54 return nil, fmt.Errorf("pijul log: %w", err)
55 }
56
57 return parseLogOutput(output)
58}
59
60// TotalChanges returns the total number of changes in the current channel
61func (p *PijulRepo) TotalChanges() (int, error) {
62 // pijul log doesn't have a --count option, so we need to count
63 // We can use pijul log with a large limit or iterate
64 args := []string{"--hash-only"}
65
66 if p.channelName != "" {
67 args = append(args, "--channel", p.channelName)
68 }
69
70 output, err := p.log(args...)
71 if err != nil {
72 if isNoChangesError(err) {
73 return 0, nil
74 }
75 return 0, fmt.Errorf("pijul log: %w", err)
76 }
77
78 // Count lines (each line is a change hash)
79 lines := strings.Split(strings.TrimSpace(string(output)), "\n")
80 if len(lines) == 1 && lines[0] == "" {
81 return 0, nil
82 }
83
84 return len(lines), nil
85}
86
87func isNoChangesError(err error) bool {
88 if err == nil {
89 return false
90 }
91 lower := strings.ToLower(err.Error())
92 return strings.Contains(lower, "no changes") || strings.Contains(lower, "no change")
93}
94
95// GetChange retrieves details for a specific change by hash
96func (p *PijulRepo) GetChange(hash string) (*Change, error) {
97 // Use pijul change to get change details
98 output, err := p.change(hash)
99 if err != nil {
100 return nil, fmt.Errorf("pijul change %s: %w", hash, err)
101 }
102
103 return parseChangeOutput(hash, output)
104}
105
106// parseLogOutput parses the output of pijul log
107// Expected format (default output):
108//
109// Hash: XXXXX
110// Author: Name <email>
111// Date: 2024-01-01 12:00:00
112//
113// Message line 1
114// Message line 2
115func parseLogOutput(output []byte) ([]Change, error) {
116 var changes []Change
117 scanner := bufio.NewScanner(bytes.NewReader(output))
118
119 var current *Change
120 var messageLines []string
121 inMessage := false
122
123 for scanner.Scan() {
124 line := scanner.Text()
125
126 if strings.HasPrefix(line, "Hash: ") || strings.HasPrefix(line, "Change ") {
127 // Save previous change if exists
128 if current != nil {
129 current.Message = strings.TrimSpace(strings.Join(messageLines, "\n"))
130 changes = append(changes, *current)
131 }
132
133 hashLine := line
134 if strings.HasPrefix(hashLine, "Change ") {
135 hashLine = strings.Replace(hashLine, "Change ", "Hash: ", 1)
136 }
137 current = &Change{
138 Hash: strings.TrimPrefix(hashLine, "Hash: "),
139 }
140 messageLines = nil
141 inMessage = false
142 continue
143 }
144
145 if current == nil {
146 continue
147 }
148
149 if strings.HasPrefix(line, "Author: ") {
150 authorStr := strings.TrimPrefix(line, "Author: ")
151 author := parseAuthor(authorStr)
152 current.Authors = append(current.Authors, author)
153 continue
154 }
155
156 if strings.HasPrefix(line, "Date: ") {
157 dateStr := strings.TrimPrefix(line, "Date: ")
158 if t, err := parseTimestamp(dateStr); err == nil {
159 current.Timestamp = t
160 }
161 continue
162 }
163
164 // Empty line before message
165 if line == "" && !inMessage {
166 inMessage = true
167 continue
168 }
169
170 if inMessage {
171 messageLines = append(messageLines, strings.TrimPrefix(line, " "))
172 }
173 }
174
175 // Don't forget the last change
176 if current != nil {
177 current.Message = strings.TrimSpace(strings.Join(messageLines, "\n"))
178 changes = append(changes, *current)
179 }
180
181 return changes, scanner.Err()
182}
183
184// parseChangeOutput parses the output of pijul change <hash>
185func parseChangeOutput(hash string, output []byte) (*Change, error) {
186 change := &Change{Hash: hash}
187
188 scanner := bufio.NewScanner(bytes.NewReader(output))
189 var messageLines []string
190 inMessage := false
191 inDeps := false
192
193 for scanner.Scan() {
194 line := scanner.Text()
195
196 if strings.HasPrefix(line, "# Authors") {
197 inDeps = false
198 continue
199 }
200
201 if strings.HasPrefix(line, "# Dependencies") {
202 inDeps = true
203 continue
204 }
205
206 if strings.HasPrefix(line, "# Message") {
207 inDeps = false
208 inMessage = true
209 continue
210 }
211
212 if strings.HasPrefix(line, "# ") {
213 inDeps = false
214 inMessage = false
215 continue
216 }
217
218 if inDeps && strings.TrimSpace(line) != "" {
219 change.Dependencies = append(change.Dependencies, strings.TrimSpace(line))
220 continue
221 }
222
223 if inMessage {
224 messageLines = append(messageLines, line)
225 continue
226 }
227
228 // Parse author line
229 if strings.Contains(line, "<") && strings.Contains(line, ">") {
230 author := parseAuthor(line)
231 change.Authors = append(change.Authors, author)
232 }
233 }
234
235 change.Message = strings.TrimSpace(strings.Join(messageLines, "\n"))
236
237 return change, scanner.Err()
238}
239
240// parseAuthor parses an author string like "Name <email>"
241func parseAuthor(s string) Author {
242 s = strings.TrimSpace(s)
243
244 // Try to extract email from angle brackets
245 if start := strings.Index(s, "<"); start != -1 {
246 if end := strings.Index(s, ">"); end > start {
247 return Author{
248 Name: strings.TrimSpace(s[:start]),
249 Email: strings.TrimSpace(s[start+1 : end]),
250 }
251 }
252 }
253
254 return Author{Name: s}
255}
256
257// parseTimestamp parses various timestamp formats
258func parseTimestamp(s string) (time.Time, error) {
259 s = strings.TrimSpace(s)
260
261 // Try common formats
262 formats := []string{
263 "2006-01-02 15:04:05 -0700",
264 "2006-01-02 15:04:05",
265 time.RFC3339,
266 time.RFC3339Nano,
267 }
268
269 for _, format := range formats {
270 if t, err := time.Parse(format, s); err == nil {
271 return t, nil
272 }
273 }
274
275 return time.Time{}, fmt.Errorf("unable to parse timestamp: %s", s)
276}
277
278// ChangeJSON represents the JSON output format for pijul log --json
279type ChangeJSON struct {
280 Hash string `json:"hash"`
281 Authors []string `json:"authors"`
282 Message string `json:"message"`
283 Timestamp string `json:"timestamp"`
284 Dependencies []string `json:"dependencies,omitempty"`
285}
286
287// ChangesJSON returns changes using JSON output format (if available in pijul version)
288func (p *PijulRepo) ChangesJSON(offset, limit int) ([]Change, error) {
289 args := []string{
290 "--offset", strconv.Itoa(offset),
291 "-n", strconv.Itoa(limit),
292 "--json",
293 }
294
295 if p.channelName != "" {
296 args = append(args, "--channel", p.channelName)
297 }
298
299 output, err := p.log(args...)
300 if err != nil {
301 // Fall back to text parsing if JSON not supported
302 return p.Changes(offset, limit)
303 }
304
305 var jsonChanges []ChangeJSON
306 if err := json.Unmarshal(output, &jsonChanges); err != nil {
307 // Fall back to text parsing if JSON parsing fails
308 return p.Changes(offset, limit)
309 }
310
311 changes := make([]Change, len(jsonChanges))
312 for i, jc := range jsonChanges {
313 changes[i] = Change{
314 Hash: jc.Hash,
315 Message: jc.Message,
316 Dependencies: jc.Dependencies,
317 }
318
319 for _, authorStr := range jc.Authors {
320 changes[i].Authors = append(changes[i].Authors, parseAuthor(authorStr))
321 }
322
323 if t, err := parseTimestamp(jc.Timestamp); err == nil {
324 changes[i].Timestamp = t
325 }
326 }
327
328 return changes, nil
329}