@harryisfish/gitt 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,8 +18,10 @@ pnpm add -g @harryisfish/gitt
18
18
 
19
19
  ## Usage
20
20
 
21
+ ### Basic Commands
22
+
21
23
  ```bash
22
- # Default behavior (auto-clean)
24
+ # Default behavior (auto-clean deleted branches)
23
25
  gitt
24
26
 
25
27
  # Interactive mode (select branches to delete)
@@ -31,8 +33,63 @@ gitt --interactive
31
33
  gitt -d
32
34
  # or
33
35
  gitt --dry-run
36
+
37
+ # Check version
38
+ gitt -v
39
+ # or
40
+ gitt --version
41
+
42
+ # Show help
43
+ gitt -h
44
+ # or
45
+ gitt --help
46
+ ```
47
+
48
+ ### Stale Branch Cleaning (NEW)
49
+
50
+ Clean up branches that haven't been updated for a long time:
51
+
52
+ ```bash
53
+ # Find branches inactive for 90+ days (default)
54
+ gitt --stale
55
+
56
+ # Custom threshold (e.g., 30 days)
57
+ gitt --stale 30
58
+
59
+ # Preview stale branches without deleting
60
+ gitt --stale --dry-run
61
+
62
+ # Interactively select stale branches to delete
63
+ gitt --stale -i
34
64
  ```
35
65
 
66
+ **Note:** Stale branch detection:
67
+ - Checks the last commit date on each branch
68
+ - Automatically excludes the main branch
69
+ - Protects branches in use by Git worktrees
70
+ - Can be combined with interactive (`-i`) and dry-run (`-d`) modes
71
+
72
+
73
+ ## Features
74
+
75
+ ### 🔄 Auto Update Notification
76
+ Gitt automatically checks for updates on each run and notifies you when a new version is available.
77
+
78
+ ### 🌳 Worktree Protection
79
+ Branches currently checked out in Git worktrees are automatically protected from deletion to prevent errors.
80
+
81
+ ### 🧹 Smart Branch Cleanup
82
+ - **Remote-deleted branches**: Automatically detect and clean branches removed from remote
83
+ - **Stale branches**: Find and remove branches inactive for X days
84
+ - **Merge status check**: Safely handles both merged and unmerged branches
85
+ - **Interactive mode**: Manual selection with clear indicators
86
+ - **Dry-run mode**: Preview changes before applying
87
+
88
+ ### 🛡️ Branch Protection
89
+ - Honors `.gitt` ignore patterns
90
+ - Respects Git worktree usage
91
+ - Never touches the main branch
92
+
36
93
  ## Configuration
37
94
 
38
95
  ### Main Branch Detection
@@ -16,7 +16,12 @@ const git = (0, simple_git_1.simpleGit)();
16
16
  */
17
17
  async function cleanDeletedBranches(options = {}) {
18
18
  try {
19
- const tasks = new listr2_1.Listr([
19
+ const state = {
20
+ mainBranch: '',
21
+ deletedBranches: []
22
+ };
23
+ // Phase 1: Discovery
24
+ const discoveryTasks = new listr2_1.Listr([
20
25
  {
21
26
  title: 'Fetch main from remote',
22
27
  task: async (ctx) => {
@@ -28,100 +33,138 @@ async function cleanDeletedBranches(options = {}) {
28
33
  {
29
34
  title: 'Switch to main branch',
30
35
  task: async (ctx) => {
31
- await git.checkout(ctx.mainBranch);
36
+ // Check if we are on a branch that will be deleted?
37
+ // For now, just try to switch to main to be safe.
38
+ // But if main is checked out in another worktree, this might fail?
39
+ // Let's just try.
40
+ try {
41
+ await git.checkout(ctx.mainBranch);
42
+ }
43
+ catch (e) {
44
+ // If we can't checkout main (e.g. dirty state), warn but continue?
45
+ // Ideally we should be on main to delete other branches safely.
46
+ }
32
47
  }
33
48
  },
34
49
  {
35
50
  title: 'Sync main with remote',
36
51
  task: async () => {
37
- await git.pull();
52
+ try {
53
+ await git.pull();
54
+ }
55
+ catch (e) {
56
+ // Ignore pull errors (e.g. if not on branch)
57
+ }
38
58
  }
39
59
  },
40
60
  {
41
- title: 'Fetch and prune remote branches',
61
+ title: 'Analyze branches',
42
62
  task: async (ctx) => {
43
63
  await git.fetch(['--prune']);
44
64
  const branchSummary = await git.branch(['-vv']);
45
65
  const config = await (0, config_1.readConfigFile)();
46
66
  const ignorePatterns = config.ignoreBranches || [];
47
- let deletedBranches = branchSummary.all.filter(branch => {
48
- const branchInfo = branchSummary.branches[branch];
49
- return branchInfo.label && branchInfo.label.includes(': gone]');
50
- });
67
+ const worktreeBranches = await (0, git_1.getWorktrees)();
68
+ let candidates = [];
69
+ if (options.stale) {
70
+ // Stale mode: check all local branches
71
+ const allBranches = branchSummary.all;
72
+ for (const branch of allBranches) {
73
+ if (branch === ctx.mainBranch)
74
+ continue;
75
+ const days = await (0, git_1.getBranchLastCommitTime)(branch);
76
+ if (days > (options.staleDays || 90)) {
77
+ candidates.push({
78
+ name: branch,
79
+ reason: `Stale (${days} days)`
80
+ });
81
+ }
82
+ }
83
+ }
84
+ else {
85
+ // Default mode: check "gone" branches
86
+ const goneBranches = branchSummary.all.filter(branch => {
87
+ const branchInfo = branchSummary.branches[branch];
88
+ return branchInfo.label && branchInfo.label.includes(': gone]');
89
+ });
90
+ candidates = goneBranches.map(b => ({ name: b, reason: 'Remote deleted' }));
91
+ }
51
92
  // Filter out ignored branches
52
93
  if (ignorePatterns.length > 0) {
53
- deletedBranches = deletedBranches.filter(branch => {
54
- const isIgnored = ignorePatterns.some(pattern => (0, minimatch_1.minimatch)(branch, pattern));
94
+ candidates = candidates.filter(c => {
95
+ const isIgnored = ignorePatterns.some(pattern => (0, minimatch_1.minimatch)(c.name, pattern));
55
96
  return !isIgnored;
56
97
  });
57
98
  }
58
- ctx.deletedBranches = deletedBranches;
59
- }
60
- },
61
- {
62
- title: 'Select branches to delete',
63
- enabled: (ctx) => !!options.interactive && Array.isArray(ctx.deletedBranches) && ctx.deletedBranches.length > 0,
64
- task: async (ctx, task) => {
65
- const branches = ctx.deletedBranches;
66
- // Pause the listr task to show prompt
67
- // We need to stop the spinner to show the prompt clearly
68
- // But listr2 doesn't make it easy to pause/resume in the middle of a task flow easily without custom renderers
69
- // So we will do the prompt and then update the context
70
- // Actually, listr2 supports prompts but it's complex.
71
- // Let's try to use the prompt directly.
72
- // We might need to handle the UI interference.
73
- // For now, let's just run the prompt.
74
- // In a real TTY, listr2 might fight for control.
75
- // A common pattern is to run the prompt outside listr or use listr's prompt adapter.
76
- // But here we are inside a task.
77
- // Let's try to use the prompt.
78
- try {
79
- const selected = await (0, prompts_1.checkbox)({
80
- message: 'Select branches to delete:',
81
- choices: branches.map(b => ({ name: b, value: b, checked: true })),
82
- });
83
- ctx.deletedBranches = selected;
84
- }
85
- catch (e) {
86
- // If user cancels, we might want to abort or just delete nothing
87
- ctx.deletedBranches = [];
88
- }
89
- }
90
- },
91
- {
92
- title: 'Dry Run: List branches to be deleted',
93
- enabled: (ctx) => !!options.dryRun && Array.isArray(ctx.deletedBranches) && ctx.deletedBranches.length > 0,
94
- task: (ctx, task) => {
95
- const branches = ctx.deletedBranches;
96
- task.output = `The following branches would be deleted:\n${branches.map(b => ` - ${b}`).join('\n')}`;
97
- // We don't want to fail, just show info.
98
- // But listr output is transient.
99
- // We will print it at the end or use a separate log.
100
- console.log('\nDry Run: The following branches would be deleted:');
101
- branches.forEach(b => console.log(` - ${b}`));
102
- ctx.deletedBranches = []; // Clear so next step does nothing
99
+ // Filter out worktree branches
100
+ candidates = candidates.filter(c => !worktreeBranches.includes(c.name));
101
+ // Check merge status for each branch
102
+ const branchesWithStatus = await Promise.all(candidates.map(async (c) => {
103
+ const isMerged = await (0, git_1.isBranchMerged)(c.name, ctx.mainBranch);
104
+ return { ...c, isMerged };
105
+ }));
106
+ ctx.deletedBranches = branchesWithStatus;
103
107
  }
104
- },
108
+ }
109
+ ]);
110
+ await discoveryTasks.run(state);
111
+ // Phase 2: Interaction / Filtering
112
+ if (state.deletedBranches.length === 0) {
113
+ (0, errors_2.printSuccess)('No branches need to be cleaned up');
114
+ return;
115
+ }
116
+ if (options.interactive) {
117
+ try {
118
+ const choices = state.deletedBranches.map(b => ({
119
+ name: `${b.name} (${b.reason}${b.isMerged ? '' : ', Unmerged'})`,
120
+ value: b,
121
+ checked: b.isMerged // Only check merged branches by default
122
+ }));
123
+ const selected = await (0, prompts_1.checkbox)({
124
+ message: 'Select branches to delete:',
125
+ choices: choices,
126
+ });
127
+ state.deletedBranches = selected;
128
+ }
129
+ catch (e) {
130
+ // User cancelled
131
+ throw new Error('Operation cancelled');
132
+ }
133
+ }
134
+ else {
135
+ // Auto mode: Filter out unmerged branches
136
+ const unmerged = state.deletedBranches.filter(b => !b.isMerged);
137
+ if (unmerged.length > 0) {
138
+ console.log('\nSkipping unmerged branches (use -i to force delete):');
139
+ unmerged.forEach(b => console.log(` - ${b.name} (${b.reason})`));
140
+ }
141
+ state.deletedBranches = state.deletedBranches.filter(b => b.isMerged);
142
+ }
143
+ if (state.deletedBranches.length === 0) {
144
+ (0, errors_2.printSuccess)('No branches selected for deletion');
145
+ return;
146
+ }
147
+ if (options.dryRun) {
148
+ console.log('\nDry Run: The following branches would be deleted:');
149
+ state.deletedBranches.forEach(b => console.log(` - ${b.name} (${b.reason})`));
150
+ return;
151
+ }
152
+ // Phase 3: Execution
153
+ const deleteTasks = new listr2_1.Listr([
105
154
  {
106
- title: 'Delete branches removed on remote',
107
- enabled: (ctx) => Array.isArray(ctx.deletedBranches),
108
- skip: (ctx) => {
109
- if (!ctx.deletedBranches || ctx.deletedBranches.length === 0) {
110
- return 'No branches need to be cleaned up';
111
- }
112
- return false;
113
- },
155
+ title: 'Delete branches',
114
156
  task: (ctx) => {
115
- return new listr2_1.Listr((ctx.deletedBranches || []).map(branch => ({
116
- title: `Delete ${branch}`,
157
+ return new listr2_1.Listr(state.deletedBranches.map(branch => ({
158
+ title: `Delete ${branch.name}`,
117
159
  task: async () => {
118
- await git.branch(['-D', branch]);
160
+ // Always use -D to force delete if we are here (user confirmed or it's merged)
161
+ await git.branch(['-D', branch.name]);
119
162
  }
120
163
  })), { concurrent: false });
121
164
  }
122
165
  }
123
166
  ]);
124
- await tasks.run();
167
+ await deleteTasks.run(state);
125
168
  (0, errors_2.printSuccess)('Branch cleanup completed');
126
169
  }
127
170
  catch (error) {
package/dist/index.js CHANGED
@@ -86,12 +86,16 @@ Commands:
86
86
  Options:
87
87
  -i, --interactive Interactive mode: Select branches to delete
88
88
  -d, --dry-run Dry run: Show what would be deleted without deleting
89
+ --stale [days] Find stale branches (default: 90 days, use with a number for custom)
90
+ -v, --version Show version number
89
91
  -h, --help Show this help message
90
92
 
91
93
  Examples:
92
94
  gitt # Auto-clean deleted branches
93
95
  gitt -i # Select branches to delete interactively
94
96
  gitt -d # Preview deletion
97
+ gitt --stale # Find branches inactive for 90+ days
98
+ gitt --stale 30 # Find branches inactive for 30+ days
95
99
  gitt ignore "temp/*" # Ignore branches matching "temp/*"
96
100
  gitt set-main master # Set main branch to 'master'
97
101
  `);
@@ -100,11 +104,27 @@ async function main() {
100
104
  try {
101
105
  const args = process.argv.slice(2);
102
106
  const command = args[0];
107
+ // Check for version command
108
+ if (args.includes('-v') || args.includes('--version')) {
109
+ const packageJson = require('../package.json');
110
+ console.log(`v${packageJson.version}`);
111
+ process.exit(0);
112
+ }
103
113
  // Check for help command
104
114
  if (args.includes('-h') || args.includes('--help')) {
105
115
  printHelp();
106
116
  process.exit(0);
107
117
  }
118
+ // Check for updates
119
+ try {
120
+ // Use eval to prevent TypeScript from transpiling dynamic import to require()
121
+ const { default: updateNotifier } = await eval('import("update-notifier")');
122
+ const pkg = require('../package.json');
123
+ updateNotifier({ pkg }).notify();
124
+ }
125
+ catch (e) {
126
+ // Ignore update check errors
127
+ }
108
128
  // 检查 Git 仓库
109
129
  await checkGitRepo();
110
130
  if (command === 'set-main') {
@@ -125,8 +145,25 @@ async function main() {
125
145
  // Parse options
126
146
  const isInteractive = args.includes('-i') || args.includes('--interactive');
127
147
  const isDryRun = args.includes('-d') || args.includes('--dry-run');
148
+ // Parse stale options
149
+ const staleIndex = args.indexOf('--stale');
150
+ const isStale = staleIndex !== -1;
151
+ let staleDays = 90; // Default 3 months
152
+ if (isStale) {
153
+ // Check if next arg is a number (days)
154
+ // Note: This is a simple check, ideally use a proper arg parser like commander or yargs
155
+ const nextArg = args[staleIndex + 1];
156
+ if (nextArg && !nextArg.startsWith('-') && !isNaN(Number(nextArg))) {
157
+ staleDays = Number(nextArg);
158
+ }
159
+ }
128
160
  // 默认执行清理操作
129
- await (0, clean_1.cleanDeletedBranches)({ interactive: isInteractive, dryRun: isDryRun });
161
+ await (0, clean_1.cleanDeletedBranches)({
162
+ interactive: isInteractive,
163
+ dryRun: isDryRun,
164
+ stale: isStale,
165
+ staleDays: staleDays
166
+ });
130
167
  }
131
168
  // 退出程序
132
169
  process.exit(0);
package/dist/utils/git.js CHANGED
@@ -2,6 +2,9 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.getMainBranch = getMainBranch;
4
4
  exports.setMainBranch = setMainBranch;
5
+ exports.isBranchMerged = isBranchMerged;
6
+ exports.getWorktrees = getWorktrees;
7
+ exports.getBranchLastCommitTime = getBranchLastCommitTime;
5
8
  const simple_git_1 = require("simple-git");
6
9
  const errors_1 = require("../errors");
7
10
  const config_1 = require("./config");
@@ -74,3 +77,59 @@ async function setMainBranch(branch) {
74
77
  throw new errors_1.GitError('Failed to set main branch configuration');
75
78
  }
76
79
  }
80
+ /**
81
+ * Check if a branch is merged into the main branch.
82
+ */
83
+ async function isBranchMerged(branch, mainBranch) {
84
+ try {
85
+ const mergedBranches = await git.branch(['--merged', mainBranch]);
86
+ return mergedBranches.all.includes(branch);
87
+ }
88
+ catch (error) {
89
+ return false;
90
+ }
91
+ }
92
+ /**
93
+ * Get a list of branches that are currently checked out in worktrees.
94
+ */
95
+ async function getWorktrees() {
96
+ try {
97
+ // Output format:
98
+ // /path/to/repo (HEAD detached at 123456)
99
+ // /path/to/worktree [branch-name]
100
+ const worktrees = await git.raw(['worktree', 'list']);
101
+ const lines = worktrees.split('\n').filter(Boolean);
102
+ const branches = [];
103
+ for (const line of lines) {
104
+ // Extract branch name from [branch-name]
105
+ const match = line.match(/\[(.*?)\]/);
106
+ if (match && match[1]) {
107
+ branches.push(match[1]);
108
+ }
109
+ }
110
+ return branches;
111
+ }
112
+ catch (error) {
113
+ // If worktrees are not supported or error occurs, return empty list
114
+ return [];
115
+ }
116
+ }
117
+ /**
118
+ * Get the last commit time (in days) for a branch.
119
+ * Returns the number of days since the last commit.
120
+ */
121
+ async function getBranchLastCommitTime(branch) {
122
+ try {
123
+ // Get unix timestamp of last commit
124
+ const timestamp = await git.raw(['log', '-1', '--format=%at', branch]);
125
+ const lastCommitDate = new Date(parseInt(timestamp.trim()) * 1000);
126
+ const now = new Date();
127
+ const diffTime = Math.abs(now.getTime() - lastCommitDate.getTime());
128
+ const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
129
+ return diffDays;
130
+ }
131
+ catch (error) {
132
+ // If branch doesn't exist or error, return 0 (treat as active/new to be safe)
133
+ return 0;
134
+ }
135
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harryisfish/gitt",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "A command-line tool to help you manage Git repositories and remote repositories, such as keeping in sync, pushing, pulling, etc.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -39,9 +39,11 @@
39
39
  "dependencies": {
40
40
  "@inquirer/prompts": "^3.3.0",
41
41
  "@types/minimatch": "^6.0.0",
42
+ "@types/update-notifier": "^6.0.8",
42
43
  "listr2": "^8.0.0",
43
44
  "minimatch": "^10.1.1",
44
- "simple-git": "^3.22.0"
45
+ "simple-git": "^3.22.0",
46
+ "update-notifier": "^7.3.1"
45
47
  },
46
48
  "engines": {
47
49
  "node": ">=14.0.0"