A vibe coded tangled fork which supports pijul.
at master 329 lines 7.9 kB view raw
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}