pilegit 0.1.9

Git stacking with style — interactive TUI for stacked PRs
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
use std::collections::HashMap;
use std::process::Command;

use color_eyre::{eyre::eyre, Result};

use super::Forge;
use crate::core::stack::{PatchEntry, PatchStatus};
use crate::git::ops::Repo;

/// Phabricator integration via `arc` CLI.
///
/// Key insight: `arc diff` amends the commit to add a `Differential Revision:`
/// trailer. To keep this trailer in the branch history, submit/update use
/// interactive rebase (`edit` marker) so the amendment happens in-place.
pub struct Phabricator;

impl Forge for Phabricator {
    fn name(&self) -> &str { "Phabricator" }
    fn needs_description_editor(&self) -> bool { false }

    fn get_trailers(&self, body: &str) -> Vec<String> {
        body.lines()
            .filter(|l| l.trim().starts_with("Differential Revision:"))
            .map(|l| l.trim().to_string())
            .collect()
    }

    fn submit(
        &self, repo: &Repo, hash: &str, subject: &str,
        _base: &str, _body: &str,
    ) -> Result<String> {
        let short = &hash[..7.min(hash.len())];
        let branch_name = repo.make_pgit_branch_name(subject);

        // Pause rebase at the target commit
        match repo.rebase_edit_commit(short) {
            Ok(false) => {} // paused — good
            Ok(true) => return Err(eyre!("Commit {} not found in stack", short)),
            Err(e) => return Err(eyre!("Failed to start rebase: {}", e)),
        }

        // Run arc diff interactively with full terminal access.
        let status = Command::new("arc")
            .current_dir(&repo.workdir)
            .args(["diff", "HEAD^"])
            .status();

        // Parse revision ID from the (possibly amended) commit message
        let msg = repo.git_pub(&["log", "-1", "--format=%B"])
            .unwrap_or_default();
        let revision_id = parse_revision_id(&msg);

        // Capture the amended commit hash before continuing rebase
        let amended_hash = repo.git_pub(&["rev-parse", "HEAD"])
            .unwrap_or_default().trim().to_string();

        // Continue rebase to replay the rest of the stack
        let rebase_ok = match repo.rebase_continue() {
            Ok(true) => true,
            Ok(false) => false, // conflicts
            Err(_) => false,
        };

        // A revision ID in the commit trailer is the strongest signal of success.
        // Arc sometimes exits non-zero even on success (e.g. lint warnings).
        let arc_ran = status.is_ok();
        let exit_ok = status.map(|s| s.success()).unwrap_or(false);

        if revision_id.is_some() || exit_ok {
            // Create and push a pgit branch so CI/CD (e.g. Drone) can detect the commit
            if rebase_ok {
                // After rebase, find the new hash by matching the Differential Revision trailer
                if let Ok(new_hash) = find_commit_with_revision(&repo, revision_id) {
                    let _ = repo.git_pub(&["branch", "-f", &branch_name, &new_hash]);
                    let _ = repo.git_pub(&["push", "-f", "origin", &branch_name]);
                }
            } else if !amended_hash.is_empty() {
                let _ = repo.git_pub(&["branch", "-f", &branch_name, &amended_hash]);
                let _ = repo.git_pub(&["push", "-f", "origin", &branch_name]);
            }

            let id_str = revision_id
                .map(|id| format!("D{}", id))
                .unwrap_or_else(|| "unknown".to_string());
            if rebase_ok {
                Ok(format!("Revision created: {}", id_str))
            } else {
                Ok(format!("Revision created: {} (rebase has conflicts — resolve and run `git rebase --continue`)", id_str))
            }
        } else if !arc_ran {
            let _ = repo.rebase_abort();
            Err(eyre!("arc not found — install arcanist"))
        } else {
            let _ = repo.rebase_abort();
            Err(eyre!("arc diff failed"))
        }
    }

    fn update(
        &self, repo: &Repo, hash: &str, subject: &str, _base: &str,
    ) -> Result<String> {
        let short = &hash[..7.min(hash.len())];
        let branch_name = repo.make_pgit_branch_name(subject);

        // Get revision ID from the commit message before rebasing
        let msg = repo.git_pub(&["log", "-1", "--format=%B", hash])
            .unwrap_or_default();
        let revision_id = parse_revision_id(&msg);

        // Pause rebase at the target commit
        match repo.rebase_edit_commit(short) {
            Ok(false) => {}
            Ok(true) => return Err(eyre!("Commit {} not found in stack", short)),
            Err(e) => return Err(eyre!("Failed to start rebase: {}", e)),
        }

        // Run arc diff to update the existing revision
        let status = match &revision_id {
            Some(id) => {
                Command::new("arc")
                    .current_dir(&repo.workdir)
                    .args(["diff", "HEAD^", "--update", &format!("D{}", id)])
                    .status()
            }
            None => {
                Command::new("arc")
                    .current_dir(&repo.workdir)
                    .args(["diff", "HEAD^"])
                    .status()
            }
        };

        // Re-parse revision ID from commit message (arc may have amended it)
        let msg = repo.git_pub(&["log", "-1", "--format=%B"])
            .unwrap_or_default();
        let revision_id = parse_revision_id(&msg).or(revision_id);

        // Capture the amended commit hash before continuing rebase
        let amended_hash = repo.git_pub(&["rev-parse", "HEAD"])
            .unwrap_or_default().trim().to_string();

        // Continue rebase
        let rebase_ok = match repo.rebase_continue() {
            Ok(true) => true,
            Ok(false) => false,
            Err(_) => false,
        };

        let arc_ran = status.is_ok();
        let exit_ok = status.map(|s| s.success()).unwrap_or(false);

        if revision_id.is_some() || exit_ok {
            // Update the pgit branch so CI/CD sees the new diff
            if rebase_ok {
                if let Ok(new_hash) = find_commit_with_revision(&repo, revision_id) {
                    let _ = repo.git_pub(&["branch", "-f", &branch_name, &new_hash]);
                    let _ = repo.git_pub(&["push", "-f", "origin", &branch_name]);
                }
            } else if !amended_hash.is_empty() {
                let _ = repo.git_pub(&["branch", "-f", &branch_name, &amended_hash]);
                let _ = repo.git_pub(&["push", "-f", "origin", &branch_name]);
            }

            let id_str = revision_id
                .map(|id| format!("D{}", id))
                .unwrap_or_else(|| "unknown".to_string());
            if rebase_ok {
                Ok(format!("Revision updated: {}", id_str))
            } else {
                Ok(format!("Revision updated: {} (rebase has conflicts — resolve and run `git rebase --continue`)", id_str))
            }
        } else if !arc_ran {
            let _ = repo.rebase_abort();
            Err(eyre!("arc not found — install arcanist"))
        } else {
            let _ = repo.rebase_abort();
            Err(eyre!("arc diff failed"))
        }
    }

    fn list_open(&self, _repo: &Repo) -> (HashMap<String, u32>, bool) {
        (HashMap::new(), false)
    }

    fn edit_base(&self, _repo: &Repo, _branch: &str, _base: &str) -> bool {
        true
    }

    fn mark_submitted(&self, repo: &Repo, patches: &mut [PatchEntry]) {
        // Scan commit messages for "Differential Revision:" trailers.
        // These trailers are added by arc diff during submit and preserved
        // through rebases, so they persist in the branch history.
        for patch in patches.iter_mut() {
            let full = repo.git_pub(&["log", "-1", "--format=%B", &patch.hash])
                .unwrap_or_default();
            if let Some((id, url)) = parse_revision_id_and_url(&full) {
                patch.status = PatchStatus::Submitted;
                patch.pr_number = Some(id);
                patch.pr_url = Some(url);
            }
        }
    }

    fn sync(
        &self, repo: &Repo, patches: &[PatchEntry],
        on_progress: &dyn Fn(&str),
    ) -> Result<Vec<String>> {
        // For sync, we use detached HEAD since we just need to upload the
        // latest diff — the Differential Revision trailer is already in the
        // commit from the initial submit. arc detects it automatically.
        let branch = repo.get_current_branch()?;
        let mut updates = Vec::new();

        for patch in patches {
            if patch.status != PatchStatus::Submitted { continue; }
            let id = match patch.pr_number {
                Some(id) => id,
                None => continue,
            };

            on_progress(&format!("Updating D{}: {} ...", id, &patch.subject));

            if repo.git_pub(&["checkout", "--quiet", &patch.hash]).is_err() {
                updates.push(format!("⚠ D{} checkout failed, skipping", id));
                continue;
            }

            // Non-interactive: --message provides update comment (no editor),
            // stdin null prevents any remaining prompts.
            // Note: --verbatim and --update are mutually exclusive in arc.
            let status = Command::new("arc")
                .current_dir(&repo.workdir)
                .args(["diff", "HEAD^", "--update", &format!("D{}", id),
                    "--message", "Updated diff"])
                .stdin(std::process::Stdio::null())
                .status();

            match status {
                Ok(s) if s.success() => {
                    updates.push(format!("✓ D{} updated", id));
                }
                _ => {
                    updates.push(format!("⚠ D{} update failed", id));
                }
            }

            // Push pgit branch so CI/CD (e.g. Drone) sees the update
            let branch_name = repo.make_pgit_branch_name(&patch.subject);
            let _ = repo.git_pub(&["branch", "-f", &branch_name, &patch.hash]);
            let _ = repo.git_pub(&["push", "-f", "origin", &branch_name]);
        }

        let _ = repo.git_pub(&["checkout", "--quiet", &branch]);
        Ok(updates)
    }
}

/// Parse a Phabricator revision ID from a commit message.
/// Looks for "Differential Revision: .../DXXXX" or just "D" followed by digits.
fn parse_revision_id(message: &str) -> Option<u32> {
    parse_revision_id_and_url(message).map(|(id, _)| id)
}

/// Find the commit hash in the current stack that has the given revision ID
/// in its Differential Revision trailer. Used after rebase when hashes change.
fn find_commit_with_revision(repo: &Repo, revision_id: Option<u32>) -> Result<String> {
    let target_id = revision_id.ok_or_else(|| eyre!("No revision ID to search for"))?;
    let base = repo.detect_base()?;
    let log = repo.git_pub(&["log", "--format=%H %B", &format!("{}..HEAD", base)])?;

    // Each commit is: <hash> <full message body>
    // Commits are separated by the next line starting with a 40+ char hash
    let mut current_hash = String::new();
    let mut current_body = String::new();

    for line in log.lines() {
        // Check if this line starts a new commit (40-char hex hash)
        let is_new_commit = line.len() >= 40
            && line.chars().take(40).all(|c| c.is_ascii_hexdigit())
            && line.chars().nth(40).map_or(true, |c| c == ' ');

        if is_new_commit {
            // Check the previous commit
            if !current_hash.is_empty() {
                if let Some(id) = parse_revision_id(&current_body) {
                    if id == target_id {
                        return Ok(current_hash);
                    }
                }
            }
            current_hash = line.split_whitespace().next().unwrap_or("").to_string();
            current_body = line[current_hash.len()..].trim().to_string();
            current_body.push('\n');
        } else {
            current_body.push_str(line);
            current_body.push('\n');
        }
    }

    // Check the last commit
    if !current_hash.is_empty() {
        if let Some(id) = parse_revision_id(&current_body) {
            if id == target_id {
                return Ok(current_hash);
            }
        }
    }

    Err(eyre!("Commit with D{} not found in stack", target_id))
}

/// Parse both revision ID and URL from a commit message.
/// Returns (id, url) where url is the full "Differential Revision:" value.
fn parse_revision_id_and_url(message: &str) -> Option<(u32, String)> {
    for line in message.lines() {
        let line = line.trim();
        if line.starts_with("Differential Revision:") {
            let url_part = line.trim_start_matches("Differential Revision:").trim();
            if let Some(d_pos) = line.rfind('D') {
                let num_str: String = line[d_pos + 1..]
                    .chars()
                    .take_while(|c| c.is_ascii_digit())
                    .collect();
                if let Ok(id) = num_str.parse::<u32>() {
                    return Some((id, url_part.to_string()));
                }
            }
        }
    }
    None
}

/// Parse a Phabricator revision ID from arc's stdout output.
/// Looks for "Revision URI: .../DXXXX" patterns in arc's output.
#[allow(dead_code)]
fn parse_revision_from_arc_output(output: &str) -> Option<u32> {
    for line in output.lines() {
        let line = line.trim();
        // Match "Revision URI: https://phab.example.com/D1234"
        if line.contains("Revision URI:") || line.contains("revision/") {
            if let Some(d_pos) = line.rfind("/D") {
                let num_str: String = line[d_pos + 2..]
                    .chars()
                    .take_while(|c| c.is_ascii_digit())
                    .collect();
                if let Ok(id) = num_str.parse::<u32>() {
                    return Some(id);
                }
            }
        }
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_revision_url() {
        let msg = "Some commit\n\nDifferential Revision: https://phab.example.com/D1234";
        assert_eq!(parse_revision_id(msg), Some(1234));
        let (id, url) = parse_revision_id_and_url(msg).unwrap();
        assert_eq!(id, 1234);
        assert_eq!(url, "https://phab.example.com/D1234");
    }

    #[test]
    fn parse_revision_bare() {
        assert_eq!(parse_revision_id("Differential Revision: D5678"), Some(5678));
        let (id, url) = parse_revision_id_and_url("Differential Revision: D5678").unwrap();
        assert_eq!(id, 5678);
        assert_eq!(url, "D5678");
    }

    #[test]
    fn parse_revision_multiline() {
        let msg = "fix bug\n\nDifferential Revision: https://phab.co/D42\nSome other line";
        assert_eq!(parse_revision_id(msg), Some(42));
    }

    #[test]
    fn parse_revision_not_present() {
        assert_eq!(parse_revision_id("just a commit message"), None);
        assert_eq!(parse_revision_id(""), None);
    }

    #[test]
    fn parse_revision_wrong_prefix() {
        assert_eq!(parse_revision_id("Reviewed-by: D9999"), None);
    }

    #[test]
    fn parse_arc_output_revision_uri() {
        let output = "Updated an existing Differential revision:\n        Revision URI: https://p.daedalean.ai/D32750\n\nIncluded changes:\n  M  file.rs";
        assert_eq!(parse_revision_from_arc_output(output), Some(32750));
    }

    #[test]
    fn parse_arc_output_created() {
        let output = "Created a new Differential revision:\n        Revision URI: https://phab.example.com/D999";
        assert_eq!(parse_revision_from_arc_output(output), Some(999));
    }

    #[test]
    fn parse_arc_output_no_revision() {
        assert_eq!(parse_revision_from_arc_output("Linting...\n OKAY"), None);
        assert_eq!(parse_revision_from_arc_output(""), None);
    }
}